From 94d7f768a1841703cfce454ce3ebaa7d161ad689 Mon Sep 17 00:00:00 2001 From: Yukine Date: Sat, 19 Jun 2021 00:03:38 +0200 Subject: [PATCH] feat(Indexer): add SpeedApp C# indexer --- .../Indexers/Definitions/SpeedApp.cs | 606 ++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 src/NzbDrone.Core/Indexers/Definitions/SpeedApp.cs diff --git a/src/NzbDrone.Core/Indexers/Definitions/SpeedApp.cs b/src/NzbDrone.Core/Indexers/Definitions/SpeedApp.cs new file mode 100644 index 000000000..30fe7409c --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Definitions/SpeedApp.cs @@ -0,0 +1,606 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Net; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using FluentValidation; +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Indexers.Exceptions; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.Definitions +{ + public class SpeedApp : TorrentIndexerBase + { + public override string Name => "SpeedApp.io"; + + public override string BaseUrl => "https://speedapp.io"; + + private string ApiUrl => $"{BaseUrl}/api"; + + private string LoginUrl => $"{ApiUrl}/login"; + + public override string Description => "SpeedApp is a ROMANIAN Private Torrent Tracker for MOVIES / TV / GENERAL"; + + public override string Language => "ro-ro"; + + public override Encoding Encoding => Encoding.UTF8; + + public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; + + public override IndexerCapabilities Capabilities => SetCapabilities(); + + private IIndexerRepository _indexerRepository; + + public SpeedApp(IHttpClient httpClient, IEventAggregator eventAggregator, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger, IIndexerRepository indexerRepository) + : base(httpClient, eventAggregator, indexerStatusService, configService, logger) + { + _indexerRepository = indexerRepository; + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new SpeedAppRequestGenerator(Capabilities, Settings, ApiUrl); + } + + public override IParseIndexerResponse GetParser() + { + return new SpeedAppParser(ApiUrl); + } + + protected override bool CheckIfLoginNeeded(HttpResponse httpResponse) + { + return Settings.ApiKey.IsNullOrWhiteSpace() || httpResponse.StatusCode == HttpStatusCode.Unauthorized; + } + + protected override async Task DoLogin() + { + var requestBuilder = new HttpRequestBuilder(LoginUrl) + { + LogResponseContent = true, + AllowAutoRedirect = true, + Method = HttpMethod.POST, + }; + + var request = requestBuilder.Build(); + + var data = new SpeedAppAuthenticationRequest + { + Email = Settings.Email, + Password = Settings.Password + }; + + request.SetContent(JsonConvert.SerializeObject(data)); + + request.Headers.ContentType = MediaTypeNames.Application.Json; + + var response = await ExecuteAuth(request); + + var statusCode = (int)response.StatusCode; + + if (statusCode is < 200 or > 400) + { + throw new HttpException(response); + } + + var parsedResponse = JsonConvert.DeserializeObject(response.Content); + + Settings.ApiKey = parsedResponse.Token; + + if (Definition.Id > 0) + { + _indexerRepository.UpdateSettings((IndexerDefinition)Definition); + } + + _logger.Debug("SpeedApp authentication succeeded."); + } + + protected override void ModifyRequest(IndexerRequest request) + { + request.HttpRequest.Headers.Set("Authorization", $"Bearer {Settings.ApiKey}"); + } + + public override async Task Download(Uri link) + { + Cookies = GetCookies(); + + if (link.Scheme == "magnet") + { + ValidateMagnet(link.OriginalString); + return Encoding.UTF8.GetBytes(link.OriginalString); + } + + var requestBuilder = new HttpRequestBuilder(link.AbsoluteUri); + + if (Cookies != null) + { + requestBuilder.SetCookies(Cookies); + } + + var request = requestBuilder.Build(); + request.AllowAutoRedirect = FollowRedirect; + request.Headers.Set("Authorization", $"Bearer {Settings.ApiKey}"); + + byte[] torrentData; + + try + { + var response = await _httpClient.ExecuteAsync(request); + torrentData = response.ResponseData; + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + _logger.Error(ex, "Downloading torrent file for release failed since it no longer exists ({0})", link.AbsoluteUri); + throw new ReleaseUnavailableException("Downloading torrent failed", ex); + } + + if ((int)ex.Response.StatusCode == 429) + { + _logger.Error("API Grab Limit reached for {0}", link.AbsoluteUri); + } + else + { + _logger.Error(ex, "Downloading torrent file for release failed ({0})", link.AbsoluteUri); + } + + throw new ReleaseDownloadException("Downloading torrent failed", ex); + } + catch (WebException ex) + { + _logger.Error(ex, "Downloading torrent file for release failed ({0})", link.AbsoluteUri); + + throw new ReleaseDownloadException("Downloading torrent failed", ex); + } + catch (Exception) + { + _indexerStatusService.RecordFailure(Definition.Id); + _logger.Error("Downloading torrent failed"); + throw; + } + + return torrentData; + } + + private IndexerCapabilities SetCapabilities() + { + var caps = new IndexerCapabilities + { + TvSearchParams = new List + { + TvSearchParam.Q, + TvSearchParam.Season, + TvSearchParam.Ep, + }, + MovieSearchParams = new List + { + MovieSearchParam.Q, + MovieSearchParam.ImdbId, + }, + MusicSearchParams = new List + { + MusicSearchParam.Q, + }, + BookSearchParams = new List + { + BookSearchParam.Q, + }, + }; + + caps.Categories.AddCategoryMapping(38, NewznabStandardCategory.Movies, "Movie Packs"); + caps.Categories.AddCategoryMapping(10, NewznabStandardCategory.MoviesSD, "Movies: SD"); + caps.Categories.AddCategoryMapping(35, NewznabStandardCategory.MoviesSD, "Movies: SD Ro"); + caps.Categories.AddCategoryMapping(8, NewznabStandardCategory.MoviesHD, "Movies: HD"); + caps.Categories.AddCategoryMapping(29, NewznabStandardCategory.MoviesHD, "Movies: HD Ro"); + caps.Categories.AddCategoryMapping(7, NewznabStandardCategory.MoviesDVD, "Movies: DVD"); + caps.Categories.AddCategoryMapping(2, NewznabStandardCategory.MoviesDVD, "Movies: DVD Ro"); + caps.Categories.AddCategoryMapping(17, NewznabStandardCategory.MoviesBluRay, "Movies: BluRay"); + caps.Categories.AddCategoryMapping(24, NewznabStandardCategory.MoviesBluRay, "Movies: BluRay Ro"); + caps.Categories.AddCategoryMapping(59, NewznabStandardCategory.Movies, "Movies: Ro"); + caps.Categories.AddCategoryMapping(57, NewznabStandardCategory.MoviesUHD, "Movies: 4K (2160p) Ro"); + caps.Categories.AddCategoryMapping(61, NewznabStandardCategory.MoviesUHD, "Movies: 4K (2160p)"); + caps.Categories.AddCategoryMapping(41, NewznabStandardCategory.TV, "TV Packs"); + caps.Categories.AddCategoryMapping(66, NewznabStandardCategory.TV, "TV Packs Ro"); + caps.Categories.AddCategoryMapping(45, NewznabStandardCategory.TVSD, "TV Episodes"); + caps.Categories.AddCategoryMapping(46, NewznabStandardCategory.TVSD, "TV Episodes Ro"); + caps.Categories.AddCategoryMapping(43, NewznabStandardCategory.TVHD, "TV Episodes HD"); + caps.Categories.AddCategoryMapping(44, NewznabStandardCategory.TVHD, "TV Episodes HD Ro"); + caps.Categories.AddCategoryMapping(60, NewznabStandardCategory.TV, "TV Ro"); + caps.Categories.AddCategoryMapping(11, NewznabStandardCategory.PCGames, "Games: PC-ISO"); + caps.Categories.AddCategoryMapping(52, NewznabStandardCategory.Console, "Games: Console"); + caps.Categories.AddCategoryMapping(1, NewznabStandardCategory.PC0day, "Applications"); + caps.Categories.AddCategoryMapping(14, NewznabStandardCategory.PC, "Applications: Linux"); + caps.Categories.AddCategoryMapping(37, NewznabStandardCategory.PCMac, "Applications: Mac"); + caps.Categories.AddCategoryMapping(19, NewznabStandardCategory.PCMobileOther, "Applications: Mobile"); + caps.Categories.AddCategoryMapping(62, NewznabStandardCategory.TV, "TV Cartoons"); + caps.Categories.AddCategoryMapping(3, NewznabStandardCategory.TVAnime, "TV Anime / Hentai"); + caps.Categories.AddCategoryMapping(6, NewznabStandardCategory.BooksEBook, "E-books"); + caps.Categories.AddCategoryMapping(5, NewznabStandardCategory.Audio, "Music"); + caps.Categories.AddCategoryMapping(64, NewznabStandardCategory.AudioVideo, "Music Video"); + caps.Categories.AddCategoryMapping(18, NewznabStandardCategory.Other, "Images"); + caps.Categories.AddCategoryMapping(22, NewznabStandardCategory.TVSport, "TV Sports"); + caps.Categories.AddCategoryMapping(58, NewznabStandardCategory.TVSport, "TV Sports Ro"); + caps.Categories.AddCategoryMapping(9, NewznabStandardCategory.TVDocumentary, "TV Documentary"); + caps.Categories.AddCategoryMapping(63, NewznabStandardCategory.TVDocumentary, "TV Documentary Ro"); + caps.Categories.AddCategoryMapping(65, NewznabStandardCategory.Other, "Tutorial"); + caps.Categories.AddCategoryMapping(67, NewznabStandardCategory.OtherMisc, "Miscellaneous"); + caps.Categories.AddCategoryMapping(15, NewznabStandardCategory.XXX, "XXX Movies"); + caps.Categories.AddCategoryMapping(47, NewznabStandardCategory.XXX, "XXX DVD"); + caps.Categories.AddCategoryMapping(48, NewznabStandardCategory.XXX, "XXX HD"); + caps.Categories.AddCategoryMapping(49, NewznabStandardCategory.XXXImageSet, "XXX Images"); + caps.Categories.AddCategoryMapping(50, NewznabStandardCategory.XXX, "XXX Packs"); + caps.Categories.AddCategoryMapping(51, NewznabStandardCategory.XXX, "XXX SD"); + + return caps; + } + } + + public class SpeedAppRequestGenerator : IIndexerRequestGenerator + { + public Func> GetCookies { get; set; } + + public Action, DateTime?> CookiesUpdater { get; set; } + + private IndexerCapabilities Capabilities { get; } + + private SpeedAppSettings Settings { get; } + + private string BaseUrl { get; } + + public SpeedAppRequestGenerator(IndexerCapabilities capabilities, SpeedAppSettings settings, string baseUrl) + { + Capabilities = capabilities; + Settings = settings; + BaseUrl = baseUrl; + } + + public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria) + { + return GetSearch(searchCriteria, searchCriteria.FullImdbId); + } + + public IndexerPageableRequestChain GetSearchRequests(MusicSearchCriteria searchCriteria) + { + return GetSearch(searchCriteria); + } + + public IndexerPageableRequestChain GetSearchRequests(TvSearchCriteria searchCriteria) + { + return GetSearch(searchCriteria, searchCriteria.FullImdbId, searchCriteria.Season, searchCriteria.Episode); + } + + public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria) + { + return GetSearch(searchCriteria); + } + + public IndexerPageableRequestChain GetSearchRequests(BasicSearchCriteria searchCriteria) + { + return GetSearch(searchCriteria); + } + + private IndexerPageableRequestChain GetSearch(SearchCriteriaBase searchCriteria, string imdbId = null, int? season = null, string episode = null) + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests($"{searchCriteria.SanitizedSearchTerm}", searchCriteria.Categories, imdbId, season, episode)); + + return pageableRequests; + } + + private IEnumerable GetPagedRequests(string term, int[] categories, string imdbId = null, int? season = null, string episode = null) + { + var qc = new NameValueCollection(); + + if (imdbId.IsNotNullOrWhiteSpace()) + { + qc.Add("imdbId", imdbId); + } + else + { + qc.Add("search", term); + } + + if (season != null) + { + qc.Add("season", season.Value.ToString()); + } + + if (episode != null) + { + qc.Add("episode", episode); + } + + var cats = Capabilities.Categories.MapTorznabCapsToTrackers(categories); + + if (cats.Count > 0) + { + foreach (var cat in cats) + { + qc.Add("categories[]", cat); + } + } + + var searchUrl = BaseUrl + "/torrent?" + qc.GetQueryString(); + + var request = new IndexerRequest(searchUrl, HttpAccept.Json); + + request.HttpRequest.Headers.Set("Authorization", $"Bearer {Settings.ApiKey}"); + + yield return request; + } + } + + public class SpeedAppParser : IParseIndexerResponse + { + public string BaseUrl { get; set; } + + public Action, DateTime?> CookiesUpdater { get; set; } + + public SpeedAppParser(string baseUrl) + { + BaseUrl = baseUrl; + } + + public IList ParseResponse(IndexerResponse indexerResponse) + { + if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new IndexerException(indexerResponse, $"Unexpected response status {indexerResponse.HttpResponse.StatusCode} code from API request"); + } + + if (!indexerResponse.HttpResponse.Headers.ContentType.Contains(HttpAccept.Json.Value)) + { + throw new IndexerException(indexerResponse, $"Unexpected response header {indexerResponse.HttpResponse.Headers.ContentType} from API request, expected {HttpAccept.Json.Value}"); + } + + var jsonResponse = new HttpResponse>(indexerResponse.HttpResponse); + + return jsonResponse.Resource.Select(torrent => new TorrentInfo + { + Guid = torrent.Id.ToString(), + Title = torrent.Name, + Description = torrent.ShortDescription, + Size = torrent.Size, + ImdbId = ParseUtil.GetImdbID(torrent.ImdbId).GetValueOrDefault(), + DownloadUrl = $"{BaseUrl}/torrent/{torrent.Id}/download", + InfoUrl = torrent.Url, + Grabs = torrent.TimesCompleted, + PublishDate = torrent.CreatedAt, + Categories = new List { new (torrent.Category.Id, torrent.Category.Name), }, + InfoHash = null, + Seeders = torrent.Seeders, + Peers = torrent.Leechers + torrent.Seeders, + MinimumRatio = 1, + MinimumSeedTime = 172800, + DownloadVolumeFactor = torrent.DownloadVolumeFactor, + UploadVolumeFactor = torrent.UploadVolumeFactor, + }).ToArray(); + } + } + + public class SpeedAppSettingsValidator : AbstractValidator + { + public SpeedAppSettingsValidator() + { + RuleFor(c => c.Email).NotEmpty(); + RuleFor(c => c.Password).NotEmpty(); + } + } + + public class SpeedAppSettings : IProviderConfig + { + private static readonly SpeedAppSettingsValidator Validator = new (); + + public SpeedAppSettings() + { + Email = ""; + Password = ""; + } + + [FieldDefinition(1, Label = "Email", HelpText = "Site email")] + public string Email { get; set; } + + [FieldDefinition(1, Label = "Password", HelpText = "Site Password", Privacy = PrivacyLevel.Password, Type = FieldType.Password)] + public string Password { get; set; } + + [FieldDefinition(0, Label = "Api Key", Hidden = HiddenType.Hidden)] + public string ApiKey { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } + + public class SpeedAppCategory + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + } + + public class SpeedAppCountry + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("flag_image")] + public string FlagImage { get; set; } + } + + public class SpeedAppUploadedBy + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("username")] + public string Username { get; set; } + + [JsonProperty("email")] + public string Email { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("class")] + public int Class { get; set; } + + [JsonProperty("avatar")] + public string Avatar { get; set; } + + [JsonProperty("uploaded")] + public int Uploaded { get; set; } + + [JsonProperty("downloaded")] + public int Downloaded { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("country")] + public SpeedAppCountry Country { get; set; } + + [JsonProperty("passkey")] + public string Passkey { get; set; } + + [JsonProperty("invites")] + public int Invites { get; set; } + + [JsonProperty("timezone")] + public string Timezone { get; set; } + + [JsonProperty("hit_and_run_count")] + public int HitAndRunCount { get; set; } + + [JsonProperty("snatch_count")] + public int SnatchCount { get; set; } + + [JsonProperty("need_seed")] + public int NeedSeed { get; set; } + + [JsonProperty("average_seed_time")] + public int AverageSeedTime { get; set; } + + [JsonProperty("free_leech_tokens")] + public int FreeLeechTokens { get; set; } + + [JsonProperty("double_upload_tokens")] + public int DoubleUploadTokens { get; set; } + } + + public class SpeedAppTag + { + [JsonProperty("translated_name")] + public string TranslatedName { get; set; } + + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("match_list")] + public List MatchList { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + } + + public class SpeedAppTorrent + { + [JsonProperty("download_volume_factor")] + public float DownloadVolumeFactor { get; set; } + + [JsonProperty("upload_volume_factor")] + public float UploadVolumeFactor { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("category")] + public SpeedAppCategory Category { get; set; } + + [JsonProperty("size")] + public long Size { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("times_completed")] + public int TimesCompleted { get; set; } + + [JsonProperty("leechers")] + public int Leechers { get; set; } + + [JsonProperty("seeders")] + public int Seeders { get; set; } + + [JsonProperty("uploaded_by")] + public SpeedAppUploadedBy UploadedBy { get; set; } + + [JsonProperty("short_description")] + public string ShortDescription { get; set; } + + [JsonProperty("poster")] + public string Poster { get; set; } + + [JsonProperty("season")] + public int Season { get; set; } + + [JsonProperty("episode")] + public int Episode { get; set; } + + [JsonProperty("tags")] + public List Tags { get; set; } + + [JsonProperty("imdb_id")] + public string ImdbId { get; set; } + } + + public class SpeedAppAuthenticationRequest + { + [JsonProperty("username")] + public string Email { get; set; } + + [JsonProperty("password")] + public string Password { get; set; } + } + + public class SpeedAppAuthenticationResponse + { + [JsonProperty("token")] + public string Token { get; set; } + } +}