using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using AngleSharp.Html.Parser; using Jackett.Common.Models; using Jackett.Common.Models.IndexerConfig.Bespoke; using Jackett.Common.Services.Interfaces; using Jackett.Common.Utils; using Jackett.Common.Utils.Clients; using Newtonsoft.Json.Linq; using NLog; namespace Jackett.Common.Indexers { [ExcludeFromCodeCoverage] public class TVStore : IndexerBase { public override string Id => "tvstore"; public override string Name => "TV Store"; public override string Description => "TV Store is a HUNGARIAN Private Torrent Tracker for TV"; public override string SiteLink { get; protected set; } = "https://tvstore.me/"; public override string Language => "hu-HU"; public override string Type => "private"; public override TorznabCapabilities TorznabCaps => SetCapabilities(); private readonly Dictionary _imdbLookup = new Dictionary(); // _imdbLookup[internalId] = imdbId private readonly Dictionary _internalLookup = new Dictionary(); // _internalLookup[imdbId] = internalId private readonly Regex _seriesInfoMatch = new Regex( @"catl\[\d+\]=(?\d+).*catIM\[\k]='(?\d+)'", RegexOptions.Compiled); private readonly Regex _seriesInfoSearchRegex = new Regex( @"S(?\d{1,3})(?:E(?\d{1,3}))?$", RegexOptions.IgnoreCase); public TVStore(IIndexerConfigurationService configService, WebClient wc, Logger l, IProtectionService ps, ICacheService cs) : base(configService: configService, client: wc, logger: l, p: ps, cacheService: cs, configData: new ConfigurationDataTVstore()) { } private TorznabCapabilities SetCapabilities() { var caps = new TorznabCapabilities { TvSearchParams = new List { TvSearchParam.Q, TvSearchParam.Season, TvSearchParam.Ep, TvSearchParam.ImdbId }, MovieSearchParams = new List { MovieSearchParam.Q, MovieSearchParam.ImdbId } }; caps.Categories.AddCategoryMapping(1, TorznabCatType.TV); caps.Categories.AddCategoryMapping(2, TorznabCatType.TVHD); caps.Categories.AddCategoryMapping(3, TorznabCatType.TVSD); return caps; } private string LoginUrl => SiteLink + "takelogin.php"; private string LoginPageUrl => SiteLink + "login.php?returnto=%2F"; private string SearchUrl => SiteLink + "torrent/br_process.php"; private string DownloadUrl => SiteLink + "torrent/download.php"; private string BrowseUrl => SiteLink + "torrent/browse.php"; private new ConfigurationDataTVstore configData => (ConfigurationDataTVstore)base.configData; public override async Task ApplyConfiguration(JToken configJson) { LoadValuesFromJson(configJson); var loginPage = await RequestWithCookiesAsync(LoginPageUrl, string.Empty); var pairs = new Dictionary { {"username", configData.Username.Value}, {"password", configData.Password.Value}, {"back", "%2F"}, {"logout", "1"} }; var result = await RequestLoginAndFollowRedirect(LoginUrl, pairs, loginPage.Cookies, true, referer: SiteLink); await ConfigureIfOK( result.Cookies, result.ContentString?.Contains("Főoldal") == true, () => throw new ExceptionWithConfigData("Error while trying to login.", configData)); return IndexerConfigurationStatus.RequiresTesting; } /// /// Calculate the Upload Factor for the torrents /// /// The calculated factor /// Date time. /// Determine if torrent type is season pack or single episode private static double UploadFactorCalculator(DateTime dateTime, bool isSeasonPack) { var dd = (DateTime.Now - dateTime).Days; /* In case of season Packs */ if (isSeasonPack) { if (dd >= 90) return 4; if (dd >= 30) return 2; if (dd >= 14) return 1.5; } else /* In case of single episodes */ { if (dd >= 60) return 2; if (dd >= 30) return 1.5; } return 1; } /// /// Parses the torrents from the content /// /// The parsed torrents. /// The result of the query /// Number of the already found torrents.(used for limit) /// The limit to the number of torrents to download /// Current position in parsed results private async Task> ParseTorrentsAsync(WebResult results, int alreadyFound, int limit, int previouslyParsedOnPage) { var releases = new List(); var queryParams = new NameValueCollection { {"func", "getToggle"}, {"w", "F"}, {"pg", "0"} }; try { /* Content Looks like this * 2\15\2\1\1727\207244\1x08 \[WebDL-720p - Eng - AJP69]\gb\2018-03-09 08:11:53\dráma, romantika, orvosi \0\0\1\191170047\1\0\Anonymous\50\0\0\\0\4\0\174\0\ * 1\ 0\0\1\1727\207243\1x08 \[WebDL-1080p - Eng - AJP69]\gb\2018-03-09 08:11:49\dráma, romantika, orvosi \0\0\1\305729738\1\0\Anonymous\50\0\0\\0\8\0\102\0\0\0\0\1\\\ * First 3 items per page are total results, results per page, and results this page * There is also a tail of ~4 items after the results for some reason. Looks like \1\\\ */ var parameters = results.ContentString.Split('\\'); var torrentsThisPage = int.Parse(parameters[2]); var maxTorrents = Math.Min(torrentsThisPage, limit - alreadyFound); var rows = parameters.Skip(3) //Skip pages info .Select((str, index) => (index, str)) //Index each string for grouping .GroupBy(n => n.index / 27) // each torrent is divided into 27 parts .Skip(previouslyParsedOnPage).Take(maxTorrents)// only parse the rows we want //Convert above query into a List(27) in prep for parsing .Select(entry => entry.Select(item => item.str).ToList()); foreach (var row in rows) { var torrentId = row[(int)TorrentParts.TorrentId]; var downloadLink = new Uri(DownloadUrl + "?id=" + torrentId); // the genre field is a html string, and we just want the text var parser = new HtmlParser(); using var dom = parser.ParseDocument(row[(int)TorrentParts.Genre]); var genres = dom.QuerySelector("*").TextContent.Replace("\xA0", ""); var description = ""; if (!string.IsNullOrWhiteSpace(genres)) description = genres; var imdbId = _imdbLookup.TryGetValue(int.Parse(row[(int)TorrentParts.InternalId]), out var imdb) ? (long?)imdb : null; var files = int.Parse(row[(int)TorrentParts.Files]); var size = long.Parse(row[(int)TorrentParts.SizeBytes]); var seeders = int.Parse(row[(int)TorrentParts.Seeders]); var leechers = int.Parse(row[(int)TorrentParts.Leechers]); var grabs = int.Parse(row[(int)TorrentParts.Grabs]); var publishDate = DateTime.Parse(row[(int)TorrentParts.PublishDate]); var isSeasonPack = row[(int)TorrentParts.EpisodeInfo].Contains("évad"); queryParams["id"] = torrentId; queryParams["now"] = DateTimeUtil.DateTimeToUnixTimestamp(DateTime.UtcNow) .ToString(CultureInfo.InvariantCulture); var filesList = (await RequestWithCookiesAndRetryAsync(SearchUrl + "?" + queryParams.GetQueryString())) .ContentString; var firstFileName = filesList.Split( new[] { @"\\" }, StringSplitOptions.None)[1]; // Delete the file extension. Many first files are either mkv or nfo. // Cannot confirm these are the only extensions, so generic remove all 3 char extensions at end of section. firstFileName = Regex.Replace(firstFileName, @"\.\w{3}$", string.Empty); if (isSeasonPack) firstFileName = Regex.Replace( firstFileName, @"(?<=S\d+)E\d{2,3}", string.Empty, RegexOptions.IgnoreCase); var category = new[] { TvCategoryParser.ParseTvShowQuality(firstFileName) }; var release = new ReleaseInfo { Title = firstFileName, Link = downloadLink, Guid = downloadLink, PublishDate = publishDate, Files = files, Size = size, Category = category, Seeders = seeders, Peers = leechers + seeders, Grabs = grabs, Description = description, MinimumRatio = 1, MinimumSeedTime = 172800, // 48 hours DownloadVolumeFactor = 0, UploadVolumeFactor = UploadFactorCalculator(publishDate, isSeasonPack), Imdb = imdbId }; if (release.Genres == null) release.Genres = new List(); release.Genres = release.Genres.Union(genres.Split(',')).ToList(); releases.Add(release); } } catch (Exception ex) { OnParseError(results.ContentString, ex); } return releases; } /// /// Map internally used series info to its corresponding IMDB number. /// Saves this data into 2 dictionaries for easy lookup from one value to the other /// private async Task PopulateImdbMapAsync() { var result = await RequestWithCookiesAndRetryAsync(BrowseUrl); foreach (Match match in _seriesInfoMatch.Matches(result.ContentString)) { var internalId = int.Parse(match.Groups["seriesID"].Value); var imdbId = long.Parse(match.Groups["ImdbId"].Value); _imdbLookup[internalId] = imdbId; _internalLookup[imdbId] = internalId; } } protected override async Task> PerformQuery(TorznabQuery query) { var releases = new List(); if (!_imdbLookup.Any()) await PopulateImdbMapAsync(); var queryParams = new NameValueCollection { {"now", DateTimeUtil.DateTimeToUnixTimestamp(DateTime.UtcNow).ToString(CultureInfo.InvariantCulture)}, {"p", "1"} }; if (query.Limit == 0) query.Limit = 100; if (query.IsImdbQuery) { if (!string.IsNullOrEmpty(query.ImdbIDShort) && _internalLookup.TryGetValue( long.Parse(query.ImdbIDShort), out var internalId)) queryParams.Add("g", internalId.ToString()); else return Enumerable.Empty(); } else { queryParams.Add("g", "0"); if (!string.IsNullOrWhiteSpace(query.SearchTerm)) { var searchString = query.SanitizedSearchTerm; if (query.Season == 0 && string.IsNullOrWhiteSpace(query.Episode)) { //Jackett doesn't check for lowercase s00e00 so do it here. var searchMatch = _seriesInfoSearchRegex.Match(searchString); if (searchMatch.Success) { query.Season = int.Parse(searchMatch.Groups["season"].Value); query.Episode = searchMatch.Groups["episode"].Success ? $"{int.Parse(searchMatch.Groups["episode"].Value):00}" : null; query.SearchTerm = searchString.Remove(searchMatch.Index, searchMatch.Length).Trim(); // strip SnnEnn } } } else if (query.IsTest) query.Limit = 20; // Search string must be converted to Base64 var plainTextBytes = Encoding.UTF8.GetBytes(query.SanitizedSearchTerm); queryParams.Add("c", Convert.ToBase64String(plainTextBytes)); } if (query.Season != 0) { queryParams.Add("s", query.Season.ToString()); if (!string.IsNullOrWhiteSpace(query.Episode)) queryParams.Add("e", query.Episode); } var results = await RequestWithCookiesAndRetryAsync(SearchUrl + "?" + queryParams.GetQueryString()); // Parse page Information from result var content = results.ContentString; var splits = content.Split('\\'); var totalFound = int.Parse(splits[0]); var torrentPerPage = int.Parse(splits[1]); if (totalFound == 0 || query.Offset > totalFound) return Enumerable.Empty(); var startPage = query.Offset / torrentPerPage + 1; var previouslyParsedOnPage = query.Offset % torrentPerPage; var pages = totalFound / torrentPerPage + 1; // First page content is already ready if (startPage == 1) { releases.AddRange(await ParseTorrentsAsync(results, releases.Count, query.Limit, previouslyParsedOnPage)); previouslyParsedOnPage = 0; startPage++; } for (var page = startPage; page <= pages && releases.Count < query.Limit; page++) { queryParams["page"] = page.ToString(); results = await RequestWithCookiesAndRetryAsync(SearchUrl + "?" + queryParams.GetQueryString()); releases.AddRange(await ParseTorrentsAsync(results, releases.Count, query.Limit, previouslyParsedOnPage)); previouslyParsedOnPage = 0; } return releases; } private enum TorrentParts { InternalId = 1, TorrentId = 2, EpisodeInfo = 3, PublishDate = 6, Genre = 7, Files = 10, SizeBytes = 11, Seeders = 20, Leechers = 21, Grabs = 22 } } }