using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Net; using System.Text; using System.Text.RegularExpressions; using Newtonsoft.Json; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers.Exceptions; using NzbDrone.Core.Indexers.Settings; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Indexers.Definitions { public class SubsPlease : TorrentIndexerBase { public override string Name => "SubsPlease"; public override string[] IndexerUrls => new[] { "https://subsplease.org/", "https://subsplease.mrunblock.bond/", "https://subsplease.nocensor.click/" }; public override string[] LegacyUrls => new[] { "https://subsplease.nocensor.space/" }; public override string Language => "en-US"; public override string Description => "SubsPlease - A better HorribleSubs/Erai replacement"; public override Encoding Encoding => Encoding.UTF8; public override IndexerPrivacy Privacy => IndexerPrivacy.Public; public override IndexerCapabilities Capabilities => SetCapabilities(); public SubsPlease(IIndexerHttpClient httpClient, IEventAggregator eventAggregator, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) : base(httpClient, eventAggregator, indexerStatusService, configService, logger) { } public override IIndexerRequestGenerator GetRequestGenerator() { return new SubsPleaseRequestGenerator(Settings); } public override IParseIndexerResponse GetParser() { return new SubsPleaseParser(Settings); } private IndexerCapabilities SetCapabilities() { var caps = new IndexerCapabilities { TvSearchParams = new List { TvSearchParam.Q, TvSearchParam.Season, TvSearchParam.Ep }, MovieSearchParams = new List { MovieSearchParam.Q } }; caps.Categories.AddCategoryMapping(1, NewznabStandardCategory.TVAnime); caps.Categories.AddCategoryMapping(2, NewznabStandardCategory.MoviesOther); return caps; } } public class SubsPleaseRequestGenerator : IIndexerRequestGenerator { private readonly NoAuthTorrentBaseSettings _settings; public SubsPleaseRequestGenerator(NoAuthTorrentBaseSettings settings) { _settings = settings; } private IEnumerable GetSearchRequests(string term) { var searchUrl = $"{_settings.BaseUrl.TrimEnd('/')}/api/?"; var searchTerm = Regex.Replace(term, "\\[?SubsPlease\\]?\\s*", string.Empty, RegexOptions.IgnoreCase).Trim(); // If the search terms contain a resolution, remove it from the query sent to the API var resMatch = Regex.Match(searchTerm, "\\d{3,4}[p|P]"); if (resMatch.Success) { searchTerm = searchTerm.Replace(resMatch.Value, string.Empty); } var queryParameters = new NameValueCollection { { "f", "search" }, { "tz", "UTC" }, { "s", searchTerm } }; var request = new IndexerRequest(searchUrl + queryParameters.GetQueryString(), HttpAccept.Json); yield return request; } private IEnumerable GetRssRequest() { var searchUrl = $"{_settings.BaseUrl.TrimEnd('/')}/api/?"; var queryParameters = new NameValueCollection { { "f", "latest" }, { "tz", "UTC" } }; var request = new IndexerRequest(searchUrl + queryParameters.GetQueryString(), HttpAccept.Json); yield return request; } public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria) { return new IndexerPageableRequestChain(); } public IndexerPageableRequestChain GetSearchRequests(MusicSearchCriteria searchCriteria) { return new IndexerPageableRequestChain(); } public IndexerPageableRequestChain GetSearchRequests(TvSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); pageableRequests.Add(searchCriteria.IsRssSearch ? GetRssRequest() : GetSearchRequests(searchCriteria.SanitizedTvSearchString)); return pageableRequests; } public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); return pageableRequests; } public IndexerPageableRequestChain GetSearchRequests(BasicSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); pageableRequests.Add(searchCriteria.IsRssSearch ? GetRssRequest() : GetSearchRequests(searchCriteria.SanitizedSearchTerm)); return pageableRequests; } public Func> GetCookies { get; set; } public Action, DateTime?> CookiesUpdater { get; set; } } public class SubsPleaseParser : IParseIndexerResponse { private static readonly Regex RegexSize = new (@"\&xl=(?\d+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly NoAuthTorrentBaseSettings _settings; public SubsPleaseParser(NoAuthTorrentBaseSettings settings) { _settings = settings; } public IList ParseResponse(IndexerResponse indexerResponse) { var torrentInfos = new List(); if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK) { throw new IndexerException(indexerResponse, $"Unexpected response status {indexerResponse.HttpResponse.StatusCode} code from indexer request"); } // When there are no results, the API returns an empty array or empty response instead of an object if (string.IsNullOrWhiteSpace(indexerResponse.Content) || indexerResponse.Content == "[]") { return torrentInfos; } var jsonResponse = new HttpResponse>(indexerResponse.HttpResponse); foreach (var value in jsonResponse.Resource.Values) { foreach (var d in value.Downloads) { var release = new TorrentInfo { InfoUrl = _settings.BaseUrl + $"shows/{value.Page}/", PublishDate = value.ReleaseDate.LocalDateTime, Files = 1, Categories = new List { NewznabStandardCategory.TVAnime }, Seeders = 1, Peers = 2, MinimumRatio = 1, MinimumSeedTime = 172800, // 48 hours DownloadVolumeFactor = 0, UploadVolumeFactor = 1 }; if (value.Episode.ToLowerInvariant() == "movie") { release.Categories.Add(NewznabStandardCategory.MoviesOther); } // Ex: [SubsPlease] Shingeki no Kyojin (The Final Season) - 64 (1080p) release.Title += $"[SubsPlease] {value.Show} - {value.Episode} ({d.Resolution}p)"; release.MagnetUrl = d.Magnet; release.DownloadUrl = null; release.Guid = d.Magnet; release.Size = GetReleaseSize(d); torrentInfos.Add(release); } } return torrentInfos.ToArray(); } private static long GetReleaseSize(SubPleaseDownloadInfo info) { if (info.Magnet.IsNotNullOrWhiteSpace()) { var sizeMatch = RegexSize.Match(info.Magnet); if (sizeMatch.Success && long.TryParse(sizeMatch.Groups["size"].Value, out var releaseSize) && releaseSize > 0) { return releaseSize; } } // The API doesn't tell us file size, so give an estimate based on resolution return info.Resolution switch { "1080" => 1.3.Gigabytes(), "720" => 700.Megabytes(), "480" => 350.Megabytes(), _ => 1.Gigabytes() }; } public Action, DateTime?> CookiesUpdater { get; set; } } public class SubPleaseRelease { public string Time { get; set; } [JsonProperty("release_date")] public DateTimeOffset ReleaseDate { get; set; } public string Show { get; set; } public string Episode { get; set; } public SubPleaseDownloadInfo[] Downloads { get; set; } public string Xdcc { get; set; } public string ImageUrl { get; set; } public string Page { get; set; } } public class SubPleaseDownloadInfo { [JsonProperty("res")] public string Resolution { get; set; } public string Magnet { get; set; } } }