From 2bf1fe4367c9f34e85941908d39f6f6e6abc5597 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 18 Dec 2025 14:34:50 -0600 Subject: [PATCH] feat(indexer): add MyAnonamouse indexer for books and audiobooks --- .../Indexers/MyAnonamouse/MyAnonamouse.cs | 35 ++++ .../Indexers/MyAnonamouse/MyAnonamouseApi.cs | 59 ++++++ .../MyAnonamouse/MyAnonamouseParser.cs | 188 +++++++++++++++++ .../MyAnonamouseRequestGenerator.cs | 190 ++++++++++++++++++ .../MyAnonamouse/MyAnonamouseSettings.cs | 101 ++++++++++ src/NzbDrone.Core/Localization/Core/en.json | 10 + 6 files changed, 583 insertions(+) create mode 100644 src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouse.cs create mode 100644 src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseApi.cs create mode 100644 src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseParser.cs create mode 100644 src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseRequestGenerator.cs create mode 100644 src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseSettings.cs diff --git a/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouse.cs b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouse.cs new file mode 100644 index 0000000000..d15b375b85 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouse.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaTypes; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Indexers.MyAnonamouse +{ + public class MyAnonamouse : HttpIndexerBase + { + public override string Name => "MyAnonamouse"; + public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override bool SupportsRss => true; + public override bool SupportsSearch => true; + public override int PageSize => 100; + + public override IEnumerable SupportedMediaTypes => new[] { MediaType.Book, MediaType.Audiobook }; + + public MyAnonamouse(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, indexerStatusService, configService, parsingService, logger) + { + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new MyAnonamouseRequestGenerator(Settings, _logger); + } + + public override IParseIndexerResponse GetParser() + { + return new MyAnonamouseParser(Settings); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseApi.cs b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseApi.cs new file mode 100644 index 0000000000..acd25674ed --- /dev/null +++ b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseApi.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Indexers.MyAnonamouse +{ + public class MyAnonamouseResponse + { + public string Error { get; set; } + public IReadOnlyCollection Data { get; set; } + public string Message { get; set; } + } + + public class MyAnonamouseTorrent + { + public int Id { get; set; } + public string Title { get; set; } + + [JsonProperty(PropertyName = "author_info")] + public string AuthorInfo { get; set; } + + public string Description { get; set; } + + [JsonProperty(PropertyName = "lang_code")] + public string LanguageCode { get; set; } + + public string Filetype { get; set; } + public bool Vip { get; set; } + public bool Free { get; set; } + + [JsonProperty(PropertyName = "personal_freeleech")] + public bool PersonalFreeLeech { get; set; } + + [JsonProperty(PropertyName = "fl_vip")] + public bool FreeVip { get; set; } + + public string Category { get; set; } + public string Added { get; set; } + + [JsonProperty(PropertyName = "times_completed")] + public int Grabs { get; set; } + + public int Seeders { get; set; } + public int Leechers { get; set; } + public int NumFiles { get; set; } + public string Size { get; set; } + } + + public class MyAnonamouseBuyFreeleechResponse + { + public bool Success { get; set; } + public string Error { get; set; } + } + + public class MyAnonamouseUserDataResponse + { + [JsonProperty(PropertyName = "classname")] + public string UserClass { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseParser.cs b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseParser.cs new file mode 100644 index 0000000000..d480008f91 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseParser.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using Newtonsoft.Json; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers.Exceptions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Indexers.MyAnonamouse +{ + public class MyAnonamouseParser : IParseIndexerResponse + { + private readonly MyAnonamouseSettings _settings; + + public MyAnonamouseParser(MyAnonamouseSettings settings) + { + _settings = settings; + } + + public IList ParseResponse(IndexerResponse indexerResponse) + { + var torrentInfos = new List(); + + if (indexerResponse.HttpResponse.StatusCode == HttpStatusCode.Forbidden) + { + throw new IndexerException(indexerResponse, "[403 Forbidden] - mam_id expired or invalid"); + } + + if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new IndexerException(indexerResponse, + "Unexpected response status {0} code from API request", + indexerResponse.HttpResponse.StatusCode); + } + + var jsonResponse = JsonConvert.DeserializeObject(indexerResponse.Content); + + if (jsonResponse.Error.IsNotNullOrWhiteSpace() && jsonResponse.Error.StartsWithIgnoreCase("Nothing returned, out of")) + { + return torrentInfos; + } + + if (jsonResponse.Data == null) + { + throw new IndexerException(indexerResponse, + "Unexpected response content: {0}", + jsonResponse.Message ?? "Check the logs for more information"); + } + + foreach (var item in jsonResponse.Data) + { + var id = item.Id; + var title = item.Title; + + if (item.AuthorInfo != null) + { + try + { + var authorInfo = JsonConvert.DeserializeObject>(item.AuthorInfo); + var author = authorInfo?.Take(5).Select(v => v.Value).Join(", "); + + if (author.IsNotNullOrWhiteSpace()) + { + title += " by " + author; + } + } + catch + { + } + } + + var flags = new List(); + + if (item.LanguageCode.IsNotNullOrWhiteSpace()) + { + flags.Add(item.LanguageCode); + } + + if (item.Filetype.IsNotNullOrWhiteSpace()) + { + flags.Add(item.Filetype.ToUpper()); + } + + if (flags.Count > 0) + { + title += " [" + flags.Join(" / ") + "]"; + } + + if (item.Vip) + { + title += " [VIP]"; + } + + var isFreeLeech = item.Free || item.PersonalFreeLeech || item.FreeVip; + + torrentInfos.Add(new TorrentInfo + { + Guid = $"MyAnonamouse-{id}", + Title = title, + Size = ParseSize(item.Size), + DownloadUrl = GetDownloadUrl(id), + InfoUrl = GetInfoUrl(id), + Seeders = item.Seeders, + Peers = item.Leechers + item.Seeders, + PublishDate = ParseDate(item.Added), + IndexerFlags = GetIndexerFlags(isFreeLeech) + }); + } + + return torrentInfos; + } + + private static IndexerFlags GetIndexerFlags(bool isFreeLeech) + { + IndexerFlags flags = 0; + + if (isFreeLeech) + { + flags |= IndexerFlags.G_Freeleech; + } + + return flags; + } + + private static long ParseSize(string sizeString) + { + if (sizeString.IsNullOrWhiteSpace()) + { + return 0; + } + + if (long.TryParse(sizeString, out var size)) + { + return size; + } + + var parts = sizeString.Trim().Split(' '); + if (parts.Length != 2 || !double.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) + { + return 0; + } + + var unit = parts[1].ToUpperInvariant(); + return unit switch + { + "B" => (long)value, + "KB" => (long)(value * 1024), + "MB" => (long)(value * 1024 * 1024), + "GB" => (long)(value * 1024 * 1024 * 1024), + "TB" => (long)(value * 1024 * 1024 * 1024 * 1024), + _ => 0 + }; + } + + private static DateTime ParseDate(string dateString) + { + if (DateTime.TryParseExact(dateString, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + { + return date.ToLocalTime(); + } + + return DateTime.UtcNow; + } + + private string GetDownloadUrl(int torrentId) + { + var url = new HttpUri(_settings.BaseUrl) + .CombinePath("/tor/download.php") + .AddQueryParam("tid", torrentId); + + return url.FullUri; + } + + private string GetInfoUrl(int torrentId) + { + var url = new HttpUri(_settings.BaseUrl) + .CombinePath("/t/") + .CombinePath(torrentId.ToString()); + + return url.FullUri; + } + + public Action, DateTime?> CookiesUpdater { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseRequestGenerator.cs b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseRequestGenerator.cs new file mode 100644 index 0000000000..0a340829c7 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseRequestGenerator.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Text.RegularExpressions; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.MyAnonamouse +{ + public class MyAnonamouseRequestGenerator : IIndexerRequestGenerator + { + private static readonly Regex SanitizeSearchQueryRegex = new ("[^\\w]+", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public MyAnonamouseSettings Settings { get; set; } + + private readonly Logger _logger; + + public MyAnonamouseRequestGenerator(MyAnonamouseSettings settings, Logger logger) + { + Settings = settings; + _logger = logger; + } + + public virtual IndexerPageableRequestChain GetRecentRequests() + { + var pageableRequests = new IndexerPageableRequestChain(); + pageableRequests.Add(GetPagedRequests(string.Empty)); + return pageableRequests; + } + + public virtual IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria) + { + return new IndexerPageableRequestChain(); + } + + public IndexerPageableRequestChain GetSearchRequests(BookSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + var searchTerm = BuildSearchTerm(searchCriteria); + + if (searchTerm.IsNotNullOrWhiteSpace()) + { + pageableRequests.Add(GetPagedRequests(searchTerm)); + } + + return pageableRequests; + } + + public IndexerPageableRequestChain GetSearchRequests(AudiobookSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + var searchTerm = BuildSearchTerm(searchCriteria); + + if (searchTerm.IsNotNullOrWhiteSpace()) + { + pageableRequests.Add(GetPagedRequests(searchTerm)); + } + + return pageableRequests; + } + + private string BuildSearchTerm(SearchCriteriaBase searchCriteria) + { + var terms = new List(); + + if (searchCriteria is BookSearchCriteria bookCriteria) + { + if (bookCriteria.Author.IsNotNullOrWhiteSpace()) + { + terms.Add(bookCriteria.Author); + } + + if (bookCriteria.Title.IsNotNullOrWhiteSpace()) + { + terms.Add(bookCriteria.Title); + } + } + else if (searchCriteria is AudiobookSearchCriteria audiobookCriteria) + { + if (audiobookCriteria.Author.IsNotNullOrWhiteSpace()) + { + terms.Add(audiobookCriteria.Author); + } + + if (audiobookCriteria.Title.IsNotNullOrWhiteSpace()) + { + terms.Add(audiobookCriteria.Title); + } + } + + return string.Join(" ", terms); + } + + private IEnumerable GetPagedRequests(string term) + { + var sanitizedTerm = SanitizeSearchQueryRegex.Replace(term, " ").Trim(); + + if (term.IsNotNullOrWhiteSpace() && sanitizedTerm.IsNullOrWhiteSpace()) + { + _logger.Debug("Search term is empty after sanitization, skipping. Original: '{0}'", term); + yield break; + } + + var searchType = Settings.SearchType switch + { + (int)MyAnonamouseSearchType.Active => "active", + (int)MyAnonamouseSearchType.Freeleech => "fl", + (int)MyAnonamouseSearchType.FreeleechOrVip => "fl-VIP", + (int)MyAnonamouseSearchType.Vip => "VIP", + (int)MyAnonamouseSearchType.NotVip => "nVIP", + _ => "all" + }; + + var parameters = new NameValueCollection + { + { "tor[text]", sanitizedTerm }, + { "tor[searchType]", searchType }, + { "tor[srchIn][title]", "true" }, + { "tor[srchIn][author]", "true" }, + { "tor[srchIn][narrator]", "true" }, + { "tor[searchIn]", "torrents" }, + { "tor[sortType]", "default" }, + { "tor[perpage]", "100" }, + { "tor[startNumber]", "0" }, + { "thumbnails", "1" }, + { "description", "1" } + }; + + if (Settings.SearchInDescription) + { + parameters.Set("tor[srchIn][description]", "true"); + } + + if (Settings.SearchInSeries) + { + parameters.Set("tor[srchIn][series]", "true"); + } + + if (Settings.SearchInFilenames) + { + parameters.Set("tor[srchIn][filenames]", "true"); + } + + parameters.Set("tor[cat][]", "0"); + + var searchUrl = Settings.BaseUrl.TrimEnd('/') + "/tor/js/loadSearchJSONbasic.php"; + + if (parameters.Count > 0) + { + searchUrl += "?" + parameters.ToQueryString(); + } + + var requestBuilder = new HttpRequestBuilder(searchUrl) + .Accept(HttpAccept.Json); + + var cookies = GetCookies?.Invoke(); + + if (cookies != null && cookies.TryGetValue("mam_id", out var mamId) && mamId.IsNotNullOrWhiteSpace()) + { + requestBuilder.SetCookies(cookies); + } + else if (Settings.MamId.IsNotNullOrWhiteSpace()) + { + requestBuilder.SetCookies(new Dictionary { { "mam_id", Settings.MamId } }); + } + + yield return new IndexerRequest(requestBuilder.Build()); + } + + public Func> GetCookies { get; set; } + public Action, DateTime?> CookiesUpdater { get; set; } + } + + internal static class NameValueCollectionExtensions + { + public static string ToQueryString(this NameValueCollection nvc) + { + var items = new List(); + + foreach (var key in nvc.AllKeys) + { + items.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(nvc[key])}"); + } + + return string.Join("&", items); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseSettings.cs b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseSettings.cs new file mode 100644 index 0000000000..7081a99c3b --- /dev/null +++ b/src/NzbDrone.Core/Indexers/MyAnonamouse/MyAnonamouseSettings.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using Equ; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.MyAnonamouse +{ + public class MyAnonamouseSettingsValidator : AbstractValidator + { + public MyAnonamouseSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + RuleFor(c => c.MamId).NotEmpty(); + RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator()); + } + } + + public class MyAnonamouseSettings : PropertywiseEquatable, ITorrentIndexerSettings + { + private static readonly MyAnonamouseSettingsValidator Validator = new (); + + public MyAnonamouseSettings() + { + BaseUrl = "https://www.myanonamouse.net"; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; + SearchType = (int)MyAnonamouseSearchType.All; + SearchInDescription = false; + SearchInSeries = false; + SearchInFilenames = false; + MultiLanguages = Array.Empty(); + FailDownloads = Array.Empty(); + RequiredFlags = Array.Empty(); + } + + [FieldDefinition(0, Label = "IndexerSettingsApiUrl", Advanced = true, HelpText = "IndexerSettingsApiUrlHelpText")] + public string BaseUrl { get; set; } + + [FieldDefinition(1, Label = "IndexerMyAnonamouseMamId", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey, HelpText = "IndexerMyAnonamouseMamIdHelpText")] + public string MamId { get; set; } + + [FieldDefinition(2, Label = "IndexerMyAnonamouseSearchType", Type = FieldType.Select, SelectOptions = typeof(MyAnonamouseSearchType), HelpText = "IndexerMyAnonamouseSearchTypeHelpText")] + public int SearchType { get; set; } + + [FieldDefinition(3, Label = "IndexerMyAnonamouseSearchInDescription", Type = FieldType.Checkbox, HelpText = "IndexerMyAnonamouseSearchInDescriptionHelpText", Advanced = true)] + public bool SearchInDescription { get; set; } + + [FieldDefinition(4, Label = "IndexerMyAnonamouseSearchInSeries", Type = FieldType.Checkbox, HelpText = "IndexerMyAnonamouseSearchInSeriesHelpText", Advanced = true)] + public bool SearchInSeries { get; set; } + + [FieldDefinition(5, Label = "IndexerMyAnonamouseSearchInFilenames", Type = FieldType.Checkbox, HelpText = "IndexerMyAnonamouseSearchInFilenamesHelpText", Advanced = true)] + public bool SearchInFilenames { get; set; } + + [FieldDefinition(6, Type = FieldType.Number, Label = "IndexerSettingsMinimumSeeders", HelpText = "IndexerSettingsMinimumSeedersHelpText", Advanced = true)] + public int MinimumSeeders { get; set; } + + [FieldDefinition(7)] + public SeedCriteriaSettings SeedCriteria { get; set; } = new (); + + [FieldDefinition(8, Type = FieldType.Checkbox, Label = "IndexerSettingsRejectBlocklistedTorrentHashes", HelpText = "IndexerSettingsRejectBlocklistedTorrentHashesHelpText", Advanced = true)] + public bool RejectBlocklistedTorrentHashesWhileGrabbing { get; set; } + + [FieldDefinition(9, Type = FieldType.Select, SelectOptions = typeof(RealLanguageFieldConverter), Label = "IndexerSettingsMultiLanguageRelease", HelpText = "IndexerSettingsMultiLanguageReleaseHelpText", Advanced = true)] + public IEnumerable MultiLanguages { get; set; } + + [FieldDefinition(10, Type = FieldType.Select, SelectOptions = typeof(FailDownloads), Label = "IndexerSettingsFailDownloads", HelpText = "IndexerSettingsFailDownloadsHelpText", Advanced = true)] + public IEnumerable FailDownloads { get; set; } + + [FieldDefinition(11, Type = FieldType.Select, SelectOptions = typeof(IndexerFlags), Label = "IndexerSettingsRequiredFlags", HelpText = "IndexerSettingsRequiredFlagsHelpText", HelpLink = "https://wiki.servarr.com/radarr/settings#indexer-flags", Advanced = true)] + public IEnumerable RequiredFlags { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } + + public enum MyAnonamouseSearchType + { + [FieldOption("All Torrents")] + All = 0, + + [FieldOption("Only Active")] + Active = 1, + + [FieldOption("Freeleech")] + Freeleech = 2, + + [FieldOption("Freeleech or VIP")] + FreeleechOrVip = 3, + + [FieldOption("VIP")] + Vip = 4, + + [FieldOption("Not VIP")] + NotVip = 5 + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 2b3bcda732..4739ac87ec 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -879,6 +879,16 @@ "IndexerJackettAll": "Indexers using the unsupported Jackett 'all' endpoint: {indexerNames}", "IndexerLongTermStatusCheckAllClientMessage": "All indexers are unavailable due to failures for more than 6 hours", "IndexerLongTermStatusCheckSingleClientMessage": "Indexers unavailable due to failures for more than 6 hours: {indexerNames}", + "IndexerMyAnonamouseMamId": "Mam Id", + "IndexerMyAnonamouseMamIdHelpText": "Mam Session Id (found in Preferences > Security)", + "IndexerMyAnonamouseSearchInDescription": "Search in Description", + "IndexerMyAnonamouseSearchInDescriptionHelpText": "Include torrent description in search", + "IndexerMyAnonamouseSearchInFilenames": "Search in Filenames", + "IndexerMyAnonamouseSearchInFilenamesHelpText": "Include filenames in search", + "IndexerMyAnonamouseSearchInSeries": "Search in Series", + "IndexerMyAnonamouseSearchInSeriesHelpText": "Include series information in search", + "IndexerMyAnonamouseSearchType": "Search Type", + "IndexerMyAnonamouseSearchTypeHelpText": "Specify the desired search type", "IndexerNewznabSettingsAdditionalParametersHelpText": "Additional Newznab parameters", "IndexerNewznabSettingsCategoriesHelpText": "Drop down list, at least one category must be selected.", "IndexerNyaaSettingsAdditionalParametersHelpText": "Please note if you change the category you will have to add required/restricted rules about the subgroups to avoid foreign language releases.",