using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Net; using System.Threading.Tasks; using Jackett.Common.Models; using Jackett.Common.Models.IndexerConfig; using Jackett.Common.Services.Interfaces; using Jackett.Common.Utils; using Jackett.Common.Utils.Clients; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NLog; using WebClient = Jackett.Common.Utils.Clients.WebClient; namespace Jackett.Common.Indexers.Abstract { [ExcludeFromCodeCoverage] public abstract class SpeedAppTracker : BaseWebIndexer { private readonly Dictionary _apiHeaders = new Dictionary { {"Accept", "application/json"}, {"Content-Type", "application/json"} }; // API DOC: https://speedapp.io/api/doc private string LoginUrl => SiteLink + "api/login"; private string SearchUrl => SiteLink + "api/torrent"; private string _token; private new ConfigurationDataBasicLoginWithEmail configData => (ConfigurationDataBasicLoginWithEmail)base.configData; protected SpeedAppTracker(string link, string id, string name, string description, IIndexerConfigurationService configService, WebClient client, Logger logger, IProtectionService p, ICacheService cs, TorznabCapabilities caps) : base(id: id, name: name, description: description, link: link, caps: caps, configService: configService, client: client, logger: logger, p: p, cacheService: cs, configData: new ConfigurationDataBasicLoginWithEmail()) { } public override async Task ApplyConfiguration(JToken configJson) { LoadValuesFromJson(configJson); await RenewalTokenAsync(); var releases = await PerformQuery(new TorznabQuery()); await ConfigureIfOK(string.Empty, releases.Any(), () => throw new Exception("Could not find releases.")); return IndexerConfigurationStatus.Completed; } private async Task RenewalTokenAsync() { if (configData.Email.Value == null || configData.Password.Value == null) throw new Exception("Please, check the indexer configuration."); var body = new Dictionary { { "username", configData.Email.Value.Trim() }, { "password", configData.Password.Value.Trim() } }; var jsonData = JsonConvert.SerializeObject(body); var result = await RequestWithCookiesAsync( LoginUrl, method: RequestType.POST, headers: _apiHeaders, rawbody: jsonData); var json = JObject.Parse(result.ContentString); _token = json.Value("token"); if (_token == null) throw new Exception(json.Value("message")); } protected override async Task> PerformQuery(TorznabQuery query) { var releases = new List(); //var categoryMapping = MapTorznabCapsToTrackers(query).Distinct().ToList(); var qc = new List> // NameValueCollection don't support cat[]=19&cat[]=6 { {"itemsPerPage", "100"}, {"sort", "torrent.createdAt"}, {"direction", "desc"} }; foreach (var cat in MapTorznabCapsToTrackers(query)) qc.Add("categories[]", cat); if (query.IsImdbQuery) qc.Add("imdbId", query.ImdbID); else qc.Add("search", query.GetQueryString()); if (string.IsNullOrWhiteSpace(_token)) // fist time login await RenewalTokenAsync(); var searchUrl = SearchUrl + "?" + qc.GetQueryString(); var response = await RequestWithCookiesAsync(searchUrl, headers: GetSearchHeaders()); if (response.Status == HttpStatusCode.Unauthorized) { await RenewalTokenAsync(); // re-login response = await RequestWithCookiesAsync(searchUrl, headers: GetSearchHeaders()); } else if (response.Status != HttpStatusCode.OK) throw new Exception($"Unknown error in search: {response.ContentString}"); try { var rows = JArray.Parse(response.ContentString); foreach (var row in rows) { var id = row.Value("id"); var link = new Uri($"{SiteLink}api/torrent/{id}/download"); var urlStr = row.Value("url"); var details = new Uri(urlStr); var publishDate = DateTime.Parse(row.Value("created_at"), CultureInfo.InvariantCulture); var cat = row.Value("category").Value("id"); // "description" field in API has too much HTML code var description = row.Value("short_description"); var posterStr = row.Value("poster"); var poster = Uri.TryCreate(posterStr, UriKind.Absolute, out var posterUri) ? posterUri : null; var dlVolumeFactor = row.Value("download_volume_factor"); var ulVolumeFactor = row.Value("upload_volume_factor"); // fix for Retroflix var title = row.Value("name"); if (title.ToUpper().StartsWith("[REQUESTED] ")) title = title.Substring(12); var release = new ReleaseInfo { Title = title, Link = link, Details = details, Guid = details, Category = MapTrackerCatToNewznab(cat), PublishDate = publishDate, Description = description, Poster = poster, Size = row.Value("size"), Grabs = row.Value("times_completed"), Seeders = row.Value("seeders"), Peers = row.Value("leechers") + row.Value("seeders"), DownloadVolumeFactor = dlVolumeFactor, UploadVolumeFactor = ulVolumeFactor, MinimumRatio = 1, MinimumSeedTime = 172800 // 48 hours }; releases.Add(release); } } catch (Exception ex) { OnParseError(response.ContentString, ex); } return releases; } public override async Task Download(Uri link) { var response = await RequestWithCookiesAsync(link.ToString(), headers: GetSearchHeaders()); if (response.Status == HttpStatusCode.Unauthorized) { await RenewalTokenAsync(); response = await RequestWithCookiesAsync(link.ToString(), headers: GetSearchHeaders()); } else if (response.Status != HttpStatusCode.OK) throw new Exception($"Unknown error in download: {response.ContentBytes}"); return response.ContentBytes; } private Dictionary GetSearchHeaders() => new Dictionary { {"Authorization", $"Bearer {_token}"} }; } }