using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; using System.Text.Json.Serialization; using FluentValidation; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.Annotations; 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; using NzbDrone.Core.Validation; using static Newtonsoft.Json.Formatting; namespace NzbDrone.Core.Indexers.Definitions { public class BeyondHD : TorrentIndexerBase { public override string Name => "BeyondHD"; public override string[] IndexerUrls => new[] { "https://beyond-hd.me/" }; public override string Description => "BeyondHD (BHD) is a Private Torrent Tracker for HD MOVIES / TV"; public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override bool SupportsPagination => true; public override int PageSize => 100; public override IndexerCapabilities Capabilities => SetCapabilities(); public BeyondHD(IIndexerHttpClient httpClient, IEventAggregator eventAggregator, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) : base(httpClient, eventAggregator, indexerStatusService, configService, logger) { } public override IIndexerRequestGenerator GetRequestGenerator() { return new BeyondHDRequestGenerator(Settings, Capabilities); } public override IParseIndexerResponse GetParser() { return new BeyondHDParser(Settings, Capabilities.Categories); } protected override IList CleanupReleases(IEnumerable releases, SearchCriteriaBase searchCriteria) { var cleanReleases = base.CleanupReleases(releases, searchCriteria); return FilterReleasesByQuery(cleanReleases, searchCriteria).ToList(); } private static IndexerCapabilities SetCapabilities() { var caps = new IndexerCapabilities { TvSearchParams = new List { TvSearchParam.Q, TvSearchParam.Season, TvSearchParam.Ep, TvSearchParam.ImdbId }, MovieSearchParams = new List { MovieSearchParam.Q, MovieSearchParam.ImdbId, MovieSearchParam.TmdbId }, Flags = new List { IndexerFlag.Internal, IndexerFlag.Exclusive, } }; caps.Categories.AddCategoryMapping(1, NewznabStandardCategory.Movies, "Movies"); caps.Categories.AddCategoryMapping(2, NewznabStandardCategory.TV, "TV"); return caps; } } public class BeyondHDRequestGenerator : IIndexerRequestGenerator { private readonly BeyondHDSettings _settings; private readonly IndexerCapabilities _capabilities; public BeyondHDRequestGenerator(BeyondHDSettings settings, IndexerCapabilities capabilities) { _settings = settings; _capabilities = capabilities; } private IEnumerable GetPagedRequests(SearchCriteriaBase searchCriteria, string searchTerm, string imdbId = null, int tmdbId = 0) { var body = new Dictionary { { "action", "search" }, { "rsskey", _settings.RssKey } }; if (_settings.FreeleechOnly) { body.Add("freeleech", 1); } if (_settings.LimitedOnly) { body.Add("limited", 1); } if (_settings.RefundOnly) { body.Add("refund", 1); } if (_settings.RewindOnly) { body.Add("rewind", 1); } if (imdbId.IsNotNullOrWhiteSpace()) { body.Add("imdb_id", imdbId); } else if (tmdbId > 0) { body.Add("tmdb_id", tmdbId); } if (searchTerm.IsNotNullOrWhiteSpace()) { body.Add("search", searchTerm.Trim()); } var cats = _capabilities.Categories.MapTorznabCapsToTrackers(searchCriteria.Categories); if (cats.Count > 0) { body.Add("categories", cats.Select(int.Parse).ToArray()); } if (_settings.SearchTypes.Any()) { var searchTypes = _settings.SearchTypes .Cast() .Select(x => x.GetAttribute()?.Label) .Where(x => x != null) .ToHashSet(); if (searchTypes.Any()) { body.Add("types", searchTypes.ToArray()); } } if (searchCriteria.Limit is > 0 && searchCriteria.Offset is > 0) { var page = (int)(searchCriteria.Offset / searchCriteria.Limit) + 1; body.Add("page", page); } var searchUrl = $"{_settings.BaseUrl}api/torrents/{_settings.ApiKey}"; var request = new HttpRequest(searchUrl, HttpAccept.Json) { Headers = { ContentType = "application/json" }, Method = HttpMethod.Post }; request.SetContent(body.ToJson()); request.ContentSummary = body.ToJson(None); yield return new IndexerRequest(request); } public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); pageableRequests.Add(GetPagedRequests(searchCriteria, searchCriteria.SanitizedSearchTerm, searchCriteria.FullImdbId, searchCriteria.TmdbId.GetValueOrDefault())); return pageableRequests; } public IndexerPageableRequestChain GetSearchRequests(MusicSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); pageableRequests.Add(GetPagedRequests(searchCriteria, searchCriteria.SanitizedSearchTerm)); return pageableRequests; } public IndexerPageableRequestChain GetSearchRequests(TvSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); var searchTerm = searchCriteria.SanitizedTvSearchString; if (searchCriteria.Season is > 0 && searchCriteria.Episode.IsNotNullOrWhiteSpace() && DateTime.TryParseExact($"{searchCriteria.Season} {searchCriteria.Episode}", "yyyy MM/dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var showDate)) { searchTerm = $"{searchCriteria.SanitizedSearchTerm} {showDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}"; } pageableRequests.Add(GetPagedRequests(searchCriteria, searchTerm, searchCriteria.FullImdbId)); return pageableRequests; } public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); pageableRequests.Add(GetPagedRequests(searchCriteria, searchCriteria.SanitizedSearchTerm)); return pageableRequests; } public IndexerPageableRequestChain GetSearchRequests(BasicSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); pageableRequests.Add(GetPagedRequests(searchCriteria, searchCriteria.SanitizedSearchTerm)); return pageableRequests; } public Func> GetCookies { get; set; } public Action, DateTime?> CookiesUpdater { get; set; } } public class BeyondHDParser : IParseIndexerResponse { private readonly BeyondHDSettings _settings; private readonly IndexerCapabilitiesCategories _categories; public BeyondHDParser(BeyondHDSettings settings, IndexerCapabilitiesCategories categories) { _settings = settings; _categories = categories; } public IList ParseResponse(IndexerResponse indexerResponse) { var indexerHttpResponse = indexerResponse.HttpResponse; if (indexerHttpResponse.StatusCode != HttpStatusCode.OK) { throw new IndexerException(indexerResponse, $"Unexpected response status {indexerHttpResponse.StatusCode} code from indexer request"); } if (indexerHttpResponse.Headers.ContentType.Contains("text/html")) { throw new IndexerException(indexerResponse, $"Indexer responded with HTML content. {(indexerHttpResponse.Content.ContainsIgnoreCase("site maintenance") ? "Site is under maintenance." : "Site is likely blocked or unavailable.")}"); } if (!indexerHttpResponse.Headers.ContentType.Contains(HttpAccept.Json.Value)) { throw new IndexerException(indexerResponse, $"Unexpected response header {indexerHttpResponse.Headers.ContentType} from indexer request, expected {HttpAccept.Json.Value}"); } if (indexerResponse.Content.ContainsIgnoreCase("Invalid API Key")) { throw new IndexerAuthException("API Key invalid or not authorized"); } var jsonResponse = STJson.Deserialize(indexerResponse.Content); if (jsonResponse.StatusCode == 0) { throw new IndexerException(indexerResponse, $"Indexer Error: {jsonResponse.StatusMessage}"); } var releaseInfos = new List(); foreach (var row in jsonResponse.Results) { // Skip invalid results when freeleech or limited filtering is set if ((_settings.FreeleechOnly && !row.Freeleech) || (_settings.LimitedOnly && !row.Limited)) { continue; } var details = row.InfoUrl; var link = row.DownloadLink; var release = new TorrentInfo { Title = row.Name, DownloadUrl = link, InfoHash = row.InfoHash, InfoUrl = details, Guid = details, Categories = _categories.MapTrackerCatDescToNewznab(row.Category), PublishDate = DateTime.Parse(row.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal), IndexerFlags = GetIndexerFlags(row), Size = row.Size, Grabs = row.Grabs, Seeders = row.Seeders, ImdbId = ParseUtil.GetImdbId(row.ImdbId).GetValueOrDefault(), Peers = row.Leechers + row.Seeders, DownloadVolumeFactor = row.Freeleech || row.Limited ? 0 : row.Promo75 ? 0.25 : row.Promo50 ? 0.5 : row.Promo25 ? 0.75 : 1, UploadVolumeFactor = 1, MinimumRatio = 1, MinimumSeedTime = 172800, // 120 hours Languages = row.Audios?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList() ?? new List(), Subs = row.Subtitles?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList() ?? new List(), }; // BHD can return crazy values for tmdb if (row.TmdbId.IsNotNullOrWhiteSpace()) { var tmdbId = row.TmdbId.Split("/").ElementAtOrDefault(1); release.TmdbId = tmdbId != null && ParseUtil.TryCoerceInt(tmdbId, out var tmdbResult) ? tmdbResult : 0; } releaseInfos.Add(release); } // order by date return releaseInfos .OrderByDescending(o => o.PublishDate) .ToArray(); } private static HashSet GetIndexerFlags(BeyondHDTorrent item) { var flags = new HashSet(); if (item.Internal) { flags.Add(IndexerFlag.Internal); } if (item.Exclusive) { flags.Add(IndexerFlag.Exclusive); } return flags; } public Action, DateTime?> CookiesUpdater { get; set; } } public class BeyondHDSettingsValidator : NoAuthSettingsValidator { public BeyondHDSettingsValidator() { RuleFor(c => c.ApiKey).NotEmpty().Length(32); RuleFor(c => c.RssKey).NotEmpty().Length(32); } } public class BeyondHDSettings : NoAuthTorrentBaseSettings { private static readonly BeyondHDSettingsValidator Validator = new (); public BeyondHDSettings() { FreeleechOnly = false; LimitedOnly = false; RefundOnly = false; RewindOnly = false; SearchTypes = Array.Empty(); } [FieldDefinition(2, Label = "ApiKey", HelpText = "IndexerBeyondHDSettingsApiKeyHelpText", Privacy = PrivacyLevel.ApiKey)] public string ApiKey { get; set; } [FieldDefinition(3, Label = "IndexerSettingsRssKey", HelpText = "IndexerBeyondHDSettingsRssKeyHelpText", Privacy = PrivacyLevel.ApiKey)] public string RssKey { get; set; } [FieldDefinition(4, Label = "IndexerSettingsFreeleechOnly", Type = FieldType.Checkbox, HelpText = "IndexerBeyondHDSettingsFreeleechOnlyHelpText")] public bool FreeleechOnly { get; set; } [FieldDefinition(5, Label = "IndexerBeyondHDSettingsLimitedOnly", Type = FieldType.Checkbox, HelpText = "IndexerBeyondHDSettingsLimitedOnlyHelpText")] public bool LimitedOnly { get; set; } [FieldDefinition(6, Label = "IndexerBeyondHDSettingsRefundOnly", Type = FieldType.Checkbox, HelpText = "IndexerBeyondHDSettingsRefundOnlyHelpText")] public bool RefundOnly { get; set; } [FieldDefinition(7, Label = "IndexerBeyondHDSettingsRewindOnly", Type = FieldType.Checkbox, HelpText = "IndexerBeyondHDSettingsRewindOnlyHelpText")] public bool RewindOnly { get; set; } [FieldDefinition(8, Label = "IndexerBeyondHDSettingsSearchTypes", Type = FieldType.Select, SelectOptions = typeof(BeyondHDSearchType), HelpText = "IndexerBeyondHDSettingsSearchTypesHelpText", Advanced = true)] public IEnumerable SearchTypes { get; set; } public override NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } public enum BeyondHDSearchType { [FieldOption(Label = "UHD 100")] TypeUhd100, [FieldOption(Label = "UHD 66")] TypeUhd66, [FieldOption(Label = "UHD 50")] TypeUhd50, [FieldOption(Label = "UHD Remux")] TypeUhdRemux, [FieldOption(Label = "BD 50")] TypeBd50, [FieldOption(Label = "BD 25")] TypeBd25, [FieldOption(Label = "BD Remux")] TypeBdRemux, [FieldOption(Label = "2160p")] Type2160P, [FieldOption(Label = "1080p")] Type1080P, [FieldOption(Label = "1080i")] Type1080I, [FieldOption(Label = "720p")] Type720P, [FieldOption(Label = "576p")] Type576P, [FieldOption(Label = "540p")] Type540P, [FieldOption(Label = "DVD 9")] TypeDvd9, [FieldOption(Label = "DVD 5")] TypeDvd5, [FieldOption(Label = "DVD Remux")] TypeDvdRemux, [FieldOption(Label = "480p")] Type480P, [FieldOption(Label = "Other")] TypeOther, } public class BeyondHDResponse { [JsonPropertyName("status_code")] public int StatusCode { get; set; } [JsonPropertyName("status_message")] public string StatusMessage { get; set; } public List Results { get; set; } } public class BeyondHDTorrent { public string Name { get; set; } [JsonPropertyName("info_hash")] public string InfoHash { get; set; } public string Category { get; set; } public string Type { get; set; } public long Size { get; set; } [JsonPropertyName("times_completed")] public int Grabs { get; set; } public int Seeders { get; set; } public int Leechers { get; set; } public string Audios { get; set; } public string Subtitles { get; set; } [JsonPropertyName("created_at")] public string CreatedAt { get; set; } [JsonPropertyName("download_url")] public string DownloadLink { get; set; } [JsonPropertyName("url")] public string InfoUrl { get; set; } [JsonPropertyName("imdb_id")] public string ImdbId { get; set; } [JsonPropertyName("tmdb_id")] public string TmdbId { get; set; } public bool Freeleech { get; set; } public bool Promo25 { get; set; } public bool Promo50 { get; set; } public bool Promo75 { get; set; } public bool Limited { get; set; } public bool Exclusive { get; set; } public bool Internal { get; set; } } }