using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Xml; using Jackett.Common.Models; using Jackett.Common.Models.IndexerConfig; using Jackett.Common.Services.Interfaces; using Jackett.Common.Utils; using Newtonsoft.Json.Linq; using NLog; using static Jackett.Common.Models.IndexerConfig.ConfigurationData; namespace Jackett.Common.Indexers { public class EraiRaws : IndexerBase { public override string Id => "erai-raws"; public override string Name => "Erai-Raws"; public override string Description => "Erai-Raws is a team release site for Anime subtitles."; public override string SiteLink { get; protected set; } = "https://www.erai-raws.info/"; public override string[] AlternativeSiteLinks => new[] { "https://www.erai-raws.info/", "https://beta.erai-raws.info/" }; public override string[] LegacySiteLinks => new[] { "https://erairaws.nocensor.space/", "https://erairaws.nocensor.work/", "https://erairaws.nocensor.biz/", "https://erairaws.nocensor.sbs/", "https://erairaws.nocensor.world/", "https://erairaws.nocensor.lol/", "https://erairaws.nocensor.art/", "https://erairaws.mrunblock.guru/", "https://erairaws.mrunblock.life/", "https://erairaws.nocensor.click/", "https://erairaws.mrunblock.bond/", "https://erairaws.nocensor.cloud/" }; public override string Language => "en-US"; public override string Type => "semi-private"; public override TorznabCapabilities TorznabCaps => SetCapabilities(); const string RSS_PATH = "feed/?type=magnet"; public EraiRaws(IIndexerConfigurationService configService, Utils.Clients.WebClient wc, Logger l, IProtectionService ps, ICacheService cs) : base(configService: configService, client: wc, logger: l, p: ps, cacheService: cs, configData: new ConfigurationData()) { var rssKey = new StringConfigurationItem("RSSKey") { Value = "" }; configData.AddDynamic("rssKey", rssKey); configData.AddDynamic("rssKeyHelp", new DisplayInfoConfigurationItem(string.Empty, "Find the RSS Key by accessing Erai-Raws RSS page while you're logged in. Copy the All RSS URL, the RSS Key is the last part. Example: for the URL .../feed/?type=torrent&0879fd62733b8db8535eb1be2333 the RSS Key is 0879fd62733b8db8535eb1be2333")); configData.AddDynamic( "DDoS-Guard", new DisplayInfoConfigurationItem("", "This site may use DDoS-Guard Protection, therefore Jackett requires FlareSolverr to access it.") ); // Add note that download stats are not available configData.AddDynamic( "download-stats-unavailable", new DisplayInfoConfigurationItem("", "

Please note that the following stats are not available for this indexer. Default values are used instead.

") ); configData.AddDynamic("include-subs", new BoolConfigurationItem("Enable appending SubTitles to the Title")); // Config item for title detail parsing configData.AddDynamic("title-detail-parsing", new BoolConfigurationItem("Enable Title Detail Parsing")); configData.AddDynamic( "title-detail-parsing-help", new DisplayInfoConfigurationItem("", "Title Detail Parsing will attempt to determine the season and episode number from the release names and reformat them as a suffix in the format S1E1. If successful, this should provide better matching in applications such as Sonarr.") ); } private TorznabCapabilities SetCapabilities() { var caps = new TorznabCapabilities { TvSearchParams = new List { TvSearchParam.Q } }; caps.Categories.AddCategoryMapping(1, TorznabCatType.TVAnime, "Anime - Sub"); return caps; } private TitleParser titleParser = new TitleParser(); private string RSSKey => ((StringConfigurationItem)configData.GetDynamic("rssKey")).Value; private bool IsTitleDetailParsingEnabled => ((BoolConfigurationItem)configData.GetDynamic("title-detail-parsing")).Value; private bool IsSubsEnabled => ((BoolConfigurationItem)configData.GetDynamic("include-subs")).Value; public string RssFeedUri => SiteLink + RSS_PATH + "&" + RSSKey; 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; } protected override async Task> PerformQuery(TorznabQuery query) { var feedItems = await GetItemsFromFeed(); var eraiRawsReleaseInfo = ConvertFeedItemsToEraiRawsReleaseInfo(feedItems); // Perform basic filter within Jackett var filteredItems = FilterForQuery(query, eraiRawsReleaseInfo); // Convert to release info return ConvertEraiRawsInfoToJackettInfo(filteredItems); } private async Task> GetItemsFromFeed() { // Retrieve RSS feed var result = await RequestWithCookiesAndRetryAsync(RssFeedUri); if (result.IsRedirect) await FollowIfRedirect(result); // Parse as XML document var xmlDocument = new XmlDocument(); xmlDocument.LoadXml(result.ContentString); var nsm = new XmlNamespaceManager(xmlDocument.NameTable); nsm.AddNamespace("erai", "https://www.erai-raws.info/rss-page/"); // Parse to RssFeedItems var xmlNodes = xmlDocument.GetElementsByTagName("item"); List feedItems = new List(); foreach (var n in xmlNodes) { var node = (XmlNode)n; if (RssFeedItem.TryParse(nsm, node, out RssFeedItem item)) { feedItems.Add(item); } else { logger.Warn($"Could not parse {Name} RSS item '{node.OuterXml}'"); } } return feedItems; } private IEnumerable ConvertFeedItemsToEraiRawsReleaseInfo(IEnumerable feedItems) { foreach (var fi in feedItems) { var releaseInfo = new EraiRawsReleaseInfo(fi); // Validate the release if (releaseInfo.PublishDate == null) { logger.Warn($"Failed to parse {Name} RSS feed item '{fi.Title}' due to malformed publish date."); continue; } if (releaseInfo.MagnetLink == null && string.IsNullOrWhiteSpace(releaseInfo.InfoHash)) { logger.Warn($"Failed to parse {Name} RSS feed item '{fi.Title}' due to malformed link URI and no infohash available."); continue; } // If enabled, perform detailed title parsing if (IsTitleDetailParsingEnabled) { releaseInfo.Title = titleParser.Parse(releaseInfo.Title); } yield return releaseInfo; } } private static IEnumerable FilterForQuery(TorznabQuery query, IEnumerable feedItems) { foreach (var fi in feedItems) { if (!query.MatchQueryStringAND(fi.Title)) continue; yield return fi; } } private IEnumerable ConvertEraiRawsInfoToJackettInfo(IEnumerable feedItems) { foreach (var fi in feedItems) { var guid = fi.MagnetLink; if (guid == null) { // Magnet link is not available so generate something unique var builder = new UriBuilder(fi.DetailsLink); if (!string.IsNullOrWhiteSpace(builder.Query)) { builder.Query += "&"; } builder.Query += $"infoHash={fi.InfoHash}"; guid = builder.Uri; } var subs = ""; if (IsSubsEnabled) { subs = string.Concat(" JAPANESE [Subs: ", fi.SubTitles, "]"); } yield return new ReleaseInfo { Title = string.Concat(fi.Title, " - ", fi.Quality, subs), Guid = guid, MagnetUri = fi.MagnetLink, InfoHash = fi.InfoHash, Details = fi.DetailsLink, PublishDate = fi.PublishDate.Value.ToLocalTime().DateTime, Category = MapTrackerCatToNewznab("1"), // Download stats are not available through scraping so set some mock values. Size = fi.Size, Files = 1, Seeders = 1, Peers = 2, DownloadVolumeFactor = 0, UploadVolumeFactor = 1 }; } } private static string PrefixOrDefault(string prefix, string value, string def = "") { if (string.IsNullOrWhiteSpace(value)) { return def; } else { return string.Concat(prefix, value); } } /// /// Raw RSS feed item containing the data as received. /// private class RssFeedItem { public static bool TryParse(XmlNamespaceManager nsm, XmlNode rssItem, out RssFeedItem item) { var title = rssItem.SelectSingleNode("title")?.InnerText; var link = rssItem.SelectSingleNode("link")?.InnerText; var publishDate = rssItem.SelectSingleNode("pubDate")?.InnerText; var infoHash = rssItem.SelectSingleNode("erai:infohash", nsm)?.InnerText; var size = rssItem.SelectSingleNode("erai:size", nsm)?.InnerText; var description = rssItem.SelectSingleNode("description")?.InnerText; var quality = rssItem.SelectSingleNode("erai:resolution", nsm)?.InnerText; var subs = rssItem.SelectSingleNode("erai:subtitles", nsm)?.InnerText; item = new RssFeedItem { Title = title, Link = link, InfoHash = infoHash, PublishDate = publishDate, Size = size, Description = description, Quality = quality, SubTitles = subs }; return item.IsValid(); } private RssFeedItem() { // Nothing to do } public string Title { get; set; } public string Link { get; private set; } public string InfoHash { get; private set; } public string PublishDate { get; private set; } public string Size { get; private set; } public string Description { get; private set; } public string Quality { get; private set; } public string SubTitles { get; private set; } /// /// Check there is enough information to process the item. /// private bool IsValid() { var missingBothHashAndLink = string.IsNullOrWhiteSpace(Link) && string.IsNullOrWhiteSpace(InfoHash); return !(missingBothHashAndLink || string.IsNullOrWhiteSpace(Title) || string.IsNullOrWhiteSpace(PublishDate) || string.IsNullOrWhiteSpace(Size) || string.IsNullOrWhiteSpace(Quality)); } } /// /// Details of an EraiRaws release /// private class EraiRawsReleaseInfo { public EraiRawsReleaseInfo(RssFeedItem feedItem) { Title = StripTitle(feedItem.Title); Quality = feedItem.Quality; Size = ParseUtil.GetBytes(feedItem.Size); DetailsLink = ParseDetailsLink(feedItem.Description); InfoHash = feedItem.InfoHash; SubTitles = feedItem.SubTitles.Replace("[", " ").Replace("]", " ").ToUpper(); if (Uri.TryCreate(feedItem.Link, UriKind.Absolute, out Uri magnetUri)) MagnetLink = magnetUri; if (DateTimeOffset.TryParse(feedItem.PublishDate, out DateTimeOffset publishDate)) PublishDate = publishDate; } private string StripTitle(string rawTitle) { var prefixStripped = Regex.Replace(rawTitle, "^\\[.+?\\]", ""); var suffixStripped = Regex.Replace(prefixStripped, " \\[.+\\]", ""); return suffixStripped.Trim(); } private Uri ParseDetailsLink(string description) { var match = Regex.Match(description, "href=\"(.+?)\""); if (match.Success) { var detailsLinkText = match.Groups[1].Value; if (Uri.TryCreate(detailsLinkText, UriKind.Absolute, out Uri detailsLink)) { return detailsLink; } } return null; } public string Quality { get; } public string SubTitles { get; } public string Title { get; set; } public Uri MagnetLink { get; } public string InfoHash { get; } public Uri DetailsLink { get; set; } public DateTimeOffset? PublishDate { get; } public long Size { get; } } public class TitleParser { private readonly Dictionary DETAIL_SEARCH_SEASON = new Dictionary { { " Season (?[0-9]+)", "" }, // "Season 2" { " (?[0-9]+)(st|nd|rd|th) Season", "" }, // "2nd Season" { " Part (?[0-9]+) - ", " - " }, // " Part 2 - <episode>" { " (?<detail>[0-9]+) - ", " - " } // "<title> 2 - <episode>" }; private readonly Dictionary<string, string> DETAIL_SEARCH_EPISODE = new Dictionary<string, string> { { " - (?<detail>[0-9]+)$", " - " }, // "<title> - <episode>" <end_of_title> { " - (?<detail>[0-9]+) ", " - " } // "<title> - <episode> ..." }; public string Parse(string title) { var results = SearchTitleForDetails(title, new Dictionary<string, Dictionary<string, string>> { { "episode", DETAIL_SEARCH_EPISODE }, { "season", DETAIL_SEARCH_SEASON } }); var seasonEpisodeIdentifier = string.Concat( PrefixOrDefault("S", results.details["season"]).Trim(), PrefixOrDefault("E", results.details["episode"]).Trim() ); // If title still contains the hyphen, insert the identifier after it. Otherwise put it at the end. int strangeHyphenPosition = results.strippedTitle.LastIndexOf("-"); if (strangeHyphenPosition > -1) { return string.Concat( results.strippedTitle.Substring(0, strangeHyphenPosition).Trim(), " - ", seasonEpisodeIdentifier, " ", results.strippedTitle.Substring(strangeHyphenPosition + 1).Trim() ).Trim(); } return string.Concat( results.strippedTitle.Trim(), " ", seasonEpisodeIdentifier ).Trim(); } private static (string strippedTitle, Dictionary<string, string> details) SearchTitleForDetails(string title, Dictionary<string, Dictionary<string, string>> definition) { Dictionary<string, string> details = new Dictionary<string, string>(); foreach (var search in definition) { var searchResult = SearchTitleForDetail(title, search.Value); details.Add(search.Key, searchResult.detail); title = searchResult.strippedTitle; } return (title, details); } private static (string strippedTitle, string detail) SearchTitleForDetail(string title, Dictionary<string, string> searchReplacePatterns) { foreach (var srp in searchReplacePatterns) { var match = Regex.Match(title, srp.Key, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(0.5)); if (match.Success) { string detail = match.Groups["detail"].Value; var strippedTitle = Regex.Replace(title, srp.Key, srp.Value, RegexOptions.IgnoreCase); return (strippedTitle, detail); } } // Nothing found so return null return (title, ""); } } } }