diff --git a/src/NzbDrone.Core.Test/Files/Indexers/TitansOfTv/RecentFeed.json b/src/NzbDrone.Core.Test/Files/Indexers/TitansOfTv/RecentFeed.json new file mode 100644 index 000000000..f593c3153 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/TitansOfTv/RecentFeed.json @@ -0,0 +1,67 @@ +{ + "code": "SUCCESSFUL", + "http_code": 200, + "limit": "2", + "offset": 0, + "results": [ + { + "air_date": "20150623", + "anonymous": 1, + "codec": "x264", + "container": "MKV", + "created_at": "2015-06-25 04:13:44", + "download": "https://titansof.tv/api/torrents/19445/download?apikey=abc", + "ecommentUrl": "https://titansof.tv/series/287053/episode/5453241#comments", + "episode": "S02E04", + "episodeUrl": "https://titansof.tv/series/287053/episode/5453241", + "episode_id": "5453241", + "id": "19445", + "language": "en", + "leechers": 5, + "network": "truTV", + "origin": "Scene", + "release_name": "Series.Title.S02E04.720p.HDTV.x264-W4F", + "resolution": "720p", + "season": "", + "season_id": 0, + "seeders": 2, + "series": "Series Title", + "series_id": "287053", + "size": 435402993, + "snatched": 0, + "source": "HDTV", + "updated_at": "2015-06-25 04:13:44", + "user_id": 0 + }, + { + "air_date": "20150624", + "anonymous": 1, + "codec": "x264", + "container": "MKV", + "created_at": "2015-06-25 04:11:59", + "download": "https://titansof.tv/api/torrents/19444/download?apikey=abc", + "ecommentUrl": "https://titansof.tv/series/75382/episode/5443517#comments", + "episode": "S21E10", + "episodeUrl": "https://titansof.tv/series/75382/episode/5443517", + "episode_id": "5443517", + "id": "19444", + "language": "en", + "leechers": 0, + "network": "FX", + "origin": "User", + "release_name": "Series.Title.S21E10.720p.HDTV.x264-KOENiG", + "resolution": "720p", + "season": "", + "season_id": 0, + "seeders": 1, + "series": "Series Title", + "series_id": "75382", + "size": 949968933, + "snatched": 0, + "source": "HDTV", + "updated_at": "2015-06-25 04:11:59", + "user_id": 0 + } + ], + "total": 18546 +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/IndexerTests/TitansOfTvTests/TitansOfTvFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TitansOfTvTests/TitansOfTvFixture.cs new file mode 100644 index 000000000..ea2e74441 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/TitansOfTvTests/TitansOfTvFixture.cs @@ -0,0 +1,155 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; +using System; +using System.Linq; +using FluentAssertions; +using NzbDrone.Core.Indexers.TitansOfTv; + +namespace NzbDrone.Core.Test.IndexerTests.TitansOfTvTests +{ + [TestFixture] + public class TitansOfTvFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition + { + Name = "TitansOfTV", + Settings = new TitansOfTvSettings { ApiKey = "abc", BaseUrl = "https://titansof.tv/api/torrents" } + }; + } + + [Test] + public void should_parse_recent_feed_from_BroadcastheNet() + { + var recentFeed = ReadAllText(@"Files/Indexers/TitansOfTv/RecentFeed.json"); + + 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(2); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.Guid.Should().Be("ToTV-19445"); + torrentInfo.Title.Should().Be("Series.Title.S02E04.720p.HDTV.x264-W4F"); + torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + torrentInfo.DownloadUrl.Should().Be("https://titansof.tv/api/torrents/19445/download?apikey=abc"); + torrentInfo.InfoUrl.Should().Be("https://titansof.tv/series/287053/episode/5453241"); + torrentInfo.CommentUrl.Should().BeNullOrEmpty(); + torrentInfo.Indexer.Should().Be(Subject.Definition.Name); + torrentInfo.PublishDate.Should().Be(DateTime.Parse("2015-06-25 04:13:44")); + torrentInfo.Size.Should().Be(435402993); + torrentInfo.InfoHash.Should().BeNullOrEmpty(); + torrentInfo.TvRageId.Should().Be(0); + torrentInfo.MagnetUrl.Should().BeNullOrEmpty(); + torrentInfo.Peers.Should().Be(2+5); + torrentInfo.Seeders.Should().Be(2); + } + + private void VerifyBackOff() + { + // TODO How to detect (and implement) back-off logic. + } + + [Test] + public void should_back_off_on_bad_request() + { + Mocker.GetMock() + .Setup(v => v.Execute(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0], System.Net.HttpStatusCode.BadRequest)); + + var results = Subject.FetchRecent(); + + results.Should().BeEmpty(); + + VerifyBackOff(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_back_off_and_report_api_key_invalid() + { + Mocker.GetMock() + .Setup(v => v.Execute(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0], System.Net.HttpStatusCode.Unauthorized)); + + var results = Subject.FetchRecent(); + + results.Should().BeEmpty(); + + results.Should().BeEmpty(); + + VerifyBackOff(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_back_off_on_unknown_method() + { + Mocker.GetMock() + .Setup(v => v.Execute(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0], System.Net.HttpStatusCode.NotFound)); + + var results = Subject.FetchRecent(); + + results.Should().BeEmpty(); + + VerifyBackOff(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_back_off_api_limit_reached() + { + Mocker.GetMock() + .Setup(v => v.Execute(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0], System.Net.HttpStatusCode.ServiceUnavailable)); + + var results = Subject.FetchRecent(); + + results.Should().BeEmpty(); + + VerifyBackOff(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_replace_https_http_as_needed() + { + var recentFeed = ReadAllText(@"Files/Indexers/TitansOfTv/RecentFeed.json"); + + (Subject.Definition.Settings as TitansOfTvSettings).BaseUrl = "http://titansof.tv/api/torrents"; + + recentFeed = recentFeed.Replace("http:", "https:"); + + 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(2); + releases.First().Should().BeOfType(); + + var torrentInfo = releases.First() as TorrentInfo; + + torrentInfo.DownloadUrl.Should().Be("http://titansof.tv/api/torrents/19445/download?apikey=abc"); + torrentInfo.InfoUrl.Should().Be("http://titansof.tv/series/287053/episode/5453241"); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 761a3ff94..4da4f578f 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -221,6 +221,7 @@ + @@ -509,6 +510,9 @@ + + Always + Always diff --git a/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTv.cs b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTv.cs new file mode 100644 index 000000000..826f0d394 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTv.cs @@ -0,0 +1,45 @@ +using System; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Indexers.TitansOfTv +{ + public class TitansOfTv : HttpIndexerBase + { + public TitansOfTv(IHttpClient httpClient, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, configService, parsingService, logger) + { + + } + + public override string Name + { + get + { + return "Titans of TV"; + } + } + + public override DownloadProtocol Protocol + { + get + { + return DownloadProtocol.Torrent; + } + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new TitansOfTvRequestGenerator() { Settings = Settings }; + } + + public override IParseIndexerResponse GetParser() + { + return new TitansOfTvParser(); + } + + public override Boolean SupportsSearch { get { return true; } } + } +} diff --git a/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvApiResult.cs b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvApiResult.cs new file mode 100644 index 000000000..67112dd89 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvApiResult.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.Indexers.TitansOfTv +{ + public class Result + { + public string id { get; set; } + public string series_id { get; set; } + public string episode_id { get; set; } + public string season_id { get; set; } + public string seeders { get; set; } + public string leechers { get; set; } + public string size { get; set; } + public string snatched { get; set; } + public int user_id { get; set; } + public string anonymous { get; set; } + public string container { get; set; } + public string codec { get; set; } + public string source { get; set; } + public string resolution { get; set; } + public string origin { get; set; } + public string language { get; set; } + public string release_name { get; set; } + public string tracker_updated_at { get; set; } + public DateTime created_at { get; set; } + public DateTime updated_at { get; set; } + public string season { get; set; } + public string episode { get; set; } + public string series { get; set; } + public string network { get; set; } + public string download { get; set; } + public string episodeUrl { get; set; } + } + + public class ApiResult + { + public string code { get; set; } + public int http_code { get; set; } + public int total { get; set; } + public int offset { get; set; } + public int limit { get; set; } + public List results { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvParser.cs b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvParser.cs new file mode 100644 index 000000000..8e833c0d2 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvParser.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using NzbDrone.Core.Indexers.Exceptions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Indexers.TitansOfTv +{ + public class TitansOfTvParser : IParseIndexerResponse + { + private static readonly Regex RegexProtocol = new Regex("^https?:", RegexOptions.Compiled); + + public IList ParseResponse(IndexerResponse indexerResponse) + { + var results = new List(); + + switch (indexerResponse.HttpResponse.StatusCode) + { + case HttpStatusCode.Unauthorized: + throw new ApiKeyException("API Key invalid or not authorized"); + case HttpStatusCode.NotFound: + throw new IndexerException(indexerResponse, "Indexer API call returned NotFound, the Indexer API may have changed."); + case HttpStatusCode.ServiceUnavailable: + throw new RequestLimitReachedException("Indexer API is temporarily unavailable, try again later"); + default: + if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new IndexerException(indexerResponse, "Indexer API call returned an unexpected StatusCode [{0}]", indexerResponse.HttpResponse.StatusCode); + } + break; + } + + var content = indexerResponse.HttpResponse.Content; + var parsed = JsonConvert.DeserializeObject(content); + var protocol = indexerResponse.HttpRequest.Url.Scheme + ":"; + + foreach (var parsedItem in parsed.results) + { + var release = new TorrentInfo(); + release.Guid = String.Format("ToTV-{0}", parsedItem.id); + release.DownloadUrl = RegexProtocol.Replace(parsedItem.download, protocol); + release.InfoUrl = RegexProtocol.Replace(parsedItem.episodeUrl, protocol); + release.DownloadProtocol = DownloadProtocol.Torrent; + release.Title = parsedItem.release_name; + release.Size = Convert.ToInt64(parsedItem.size); + release.Seeders = Convert.ToInt32(parsedItem.seeders); + release.Peers = Convert.ToInt32(parsedItem.leechers) + release.Seeders; + release.PublishDate = parsedItem.created_at; + results.Add(release); + } + + return results; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvRequestGenerator.cs b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvRequestGenerator.cs new file mode 100644 index 000000000..6d2120e0f --- /dev/null +++ b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvRequestGenerator.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Indexers.TitansOfTv +{ + public class TitansOfTvRequestGenerator : IIndexerRequestGenerator + { + public TitansOfTvSettings Settings { get; set; } + + public IList> GetRecentRequests() + { + var pageableRequests = new List>(); + var innerList = new List(); + var httpRequest = BuildHttpRequest(GetBaseUrl()); + + innerList.Add(new IndexerRequest(httpRequest)); + pageableRequests.Add(innerList); + + return pageableRequests; + } + + private HttpRequest BuildHttpRequest(string url) + { + var httpRequest = new HttpRequest(url, HttpAccept.Json); + httpRequest.Headers["X-Authorization"] = Settings.ApiKey; + + return httpRequest; + } + + public IList> GetSearchRequests(IndexerSearch.Definitions.SingleEpisodeSearchCriteria searchCriteria) + { + var url = GetBaseUrl() + "&series_id={series}&episode={episode}"; + var requests = new List>(); + var innerList = new List(); + requests.Add(innerList); + + var httpRequest = BuildHttpRequest(url); + var episodeString = String.Format("S{0:00}E{1:00}", searchCriteria.SeasonNumber, searchCriteria.EpisodeNumber); + httpRequest.AddSegment("series", searchCriteria.Series.TvdbId.ToString(CultureInfo.InvariantCulture)); + httpRequest.AddSegment("episode", episodeString); + + var request = new IndexerRequest(httpRequest); + innerList.Add(request); + + return requests; + } + + public IList> GetSearchRequests(IndexerSearch.Definitions.SeasonSearchCriteria searchCriteria) + { + var url = GetBaseUrl() + "&series_id={series}&season={season}"; + var requests = new List>(); + var innerList = new List(); + requests.Add(innerList); + + var httpRequest = BuildHttpRequest(url); + var seasonString = String.Format("Season {0:00}", searchCriteria.SeasonNumber); + httpRequest.AddSegment("series", searchCriteria.Series.TvdbId.ToString(CultureInfo.InvariantCulture)); + httpRequest.AddSegment("season", seasonString); + + var request = new IndexerRequest(httpRequest); + innerList.Add(request); + + httpRequest = BuildHttpRequest(url); + seasonString = String.Format("Season {0}", searchCriteria.SeasonNumber); + httpRequest.AddSegment("series", searchCriteria.Series.TvdbId.ToString(CultureInfo.InvariantCulture)); + httpRequest.AddSegment("season", seasonString); + + request = new IndexerRequest(httpRequest); + innerList.Add(request); + + return requests; + } + + public IList> GetSearchRequests(IndexerSearch.Definitions.DailyEpisodeSearchCriteria searchCriteria) + { + var url = GetBaseUrl() + "&series_id={series}&air_date={air_date}"; + var requests = new List>(); + var innerList = new List(); + + requests.Add(innerList); + + var httpRequest = BuildHttpRequest(url); + var airDate = searchCriteria.AirDate.ToString("yyyy-MM-dd"); + + httpRequest.AddSegment("series", searchCriteria.Series.TvdbId.ToString(CultureInfo.InvariantCulture)); + httpRequest.AddSegment("air_date", airDate); + + var request = new IndexerRequest(httpRequest); + innerList.Add(request); + + return requests; + } + + public IList> GetSearchRequests(IndexerSearch.Definitions.AnimeEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + public IList> GetSearchRequests(IndexerSearch.Definitions.SpecialEpisodeSearchCriteria searchCriteria) + { + return new List>(); + } + + private string GetBaseUrl() + { + return Settings.BaseUrl + "?limit=100"; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvSettings.cs b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvSettings.cs new file mode 100644 index 000000000..48eb3cbcf --- /dev/null +++ b/src/NzbDrone.Core/Indexers/TitansOfTv/TitansOfTvSettings.cs @@ -0,0 +1,37 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.TitansOfTv +{ + + public class TitansOfTvSettingsValidator : AbstractValidator + { + public TitansOfTvSettingsValidator() + { + RuleFor(c => c.ApiKey).NotEmpty(); + } + } + + public class TitansOfTvSettings : IProviderConfig + { + private static readonly TitansOfTvSettingsValidator Validator = new TitansOfTvSettingsValidator(); + + public TitansOfTvSettings() + { + BaseUrl = "http://titansof.tv/api/torrents"; + } + + [FieldDefinition(0, Label = "API URL", Advanced = true, HelpText = "Do not change this unless you know what you're doing. Since your API key will be sent to that host.")] + public string BaseUrl { get; set; } + + [FieldDefinition(1, Label = "API key", HelpText = "Enter your ToTV API key. (My Account->API->Site API Key)")] + public string ApiKey { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index d5159fbd7..a1fe7661c 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -536,6 +536,11 @@ + + + + +