diff --git a/README.md b/README.md index 33047503a..215d594d2 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ A third-party Golang SDK for Jackett is available from [webtor-io/go-jackett](ht * SkyTorrentsClone2 (*.to) * Solid Torrents * sosulki + * SubsPlease * sukebei-Pantsu * sukebei.Nyaa.si * The Pirate Bay (TPB) diff --git a/src/Jackett.Common/Indexers/SubsPlease.cs b/src/Jackett.Common/Indexers/SubsPlease.cs new file mode 100644 index 000000000..3b8834f95 --- /dev/null +++ b/src/Jackett.Common/Indexers/SubsPlease.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Jackett.Common.Models; +using Jackett.Common.Models.IndexerConfig; +using Jackett.Common.Services.Interfaces; +using Jackett.Common.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NLog; + +namespace Jackett.Common.Indexers +{ + [ExcludeFromCodeCoverage] + public class SubsPlease : BaseWebIndexer + { + private string ApiEndpoint => SiteLink + "/api/?"; + + public SubsPlease(IIndexerConfigurationService configService, Utils.Clients.WebClient wc, Logger l, IProtectionService ps, ICacheService cs) + : base(id: "subsplease", + name: "SubsPlease", + description: "SubsPlease - A better HorribleSubs/Erai replacement", + link: "https://subsplease.org/", + caps: new TorznabCapabilities + { + TvSearchParams = new List + { + TvSearchParam.Q, TvSearchParam.Season, TvSearchParam.Ep + } + }, + configService: configService, + client: wc, + logger: l, + p: ps, + cacheService: cs, + configData: new ConfigurationData()) + { + Encoding = Encoding.UTF8; + Language = "en-us"; + Type = "public"; + + // Configure the category mappings + AddCategoryMapping(1, TorznabCatType.TVAnime, "Anime"); + } + + public override async Task ApplyConfiguration(JToken configJson) + { + LoadValuesFromJson(configJson); + var releases = await PerformQuery(new TorznabQuery()); + + await ConfigureIfOK(string.Empty, releases.Any(), () => + throw new Exception("Could not find releases from this URL")); + + return IndexerConfigurationStatus.Completed; + } + + // If the search string is empty use the latest releases + protected override async Task> PerformQuery(TorznabQuery query) + => query.IsTest || string.IsNullOrWhiteSpace(query.SearchTerm) + ? await FetchNewReleases() + : await PerformSearch(query); + + private async Task> PerformSearch(TorznabQuery query) + { + // If the search terms contain [SubsPlease] or SubsPlease, remove them from the query sent to the API + // It's ok if this results in an empty search term + string searchTerm = Regex.Replace(query.SearchTerm, "\\[?SubsPlease\\]?\\s*", string.Empty, RegexOptions.IgnoreCase).Trim(); + + var queryParameters = new NameValueCollection + { + { "f", "search" }, + { "tz", "America/New_York" }, + { "s", searchTerm } + }; + var response = await RequestWithCookiesAndRetryAsync(ApiEndpoint + queryParameters.GetQueryString()); + if (response.Status != HttpStatusCode.OK) + throw new WebException($"SubsPlease search returned unexpected result. Expected 200 OK but got {response.Status}.", WebExceptionStatus.ProtocolError); + + var results = ParseApiResults(response.ContentString); + return results.Where(release => query.MatchQueryStringAND(release.Title)); + } + + private async Task> FetchNewReleases() + { + var queryParameters = new NameValueCollection + { + { "f", "latest" }, + { "tz", "America/New_York" } + }; + var response = await RequestWithCookiesAndRetryAsync(ApiEndpoint + queryParameters.GetQueryString()); + if (response.Status != HttpStatusCode.OK) + throw new WebException($"SubsPlease search returned unexpected result. Expected 200 OK but got {response.Status}.", WebExceptionStatus.ProtocolError); + + return ParseApiResults(response.ContentString); + } + + private List ParseApiResults(string json) + { + var releaseInfo = new List(); + + // When there are no results, the API returns an empty array instead of an object + if (json == "[]") + return releaseInfo; + + var releases = JsonConvert.DeserializeObject>(json); + foreach (var keyValue in releases) + { + Release r = keyValue.Value; + var baseRelease = new ReleaseInfo + { + Details = new Uri(SiteLink + $"shows/{r.Page}/"), + PublishDate = r.Release_Date.DateTime, + Files = 1, + Category = new List { TorznabCatType.TVAnime.ID }, + Size = 0, + Seeders = 1, + Peers = 2, + MinimumRatio = 1, + MinimumSeedTime = 172800, // 48 hours + DownloadVolumeFactor = 0, + UploadVolumeFactor = 1 + }; + foreach (var d in r.Downloads) + { + var release = (ReleaseInfo)baseRelease.Clone(); + //Ex: [SubsPlease] Shingeki no Kyojin (The Final Season) - 64 (1080p) + release.Title += $"[SubsPlease] {r.Show} - {r.Episode} ({d.Res}p)"; + release.MagnetUri = new Uri(d.Magnet); + release.Link = null; + release.Guid = new Uri(d.Magnet); + releaseInfo.Add(release); + } + } + + return releaseInfo; + } + + public class Release + { + public string Time { get; set; } + public DateTimeOffset Release_Date { get; set; } + public string Show { get; set; } + public string Episode { get; set; } + public DownloadInfo[] Downloads { get; set; } + public string Xdcc { get; set; } + public string ImageUrl { get; set; } + public string Page { get; set; } + } + + public class DownloadInfo + { + public string Res { get; set; } + public string Magnet { get; set; } + } + } +}