feat(indexer): add MyAnonamouse indexer for books and audiobooks

This commit is contained in:
admin 2025-12-18 14:34:50 -06:00
parent 6328e72c96
commit 2bf1fe4367
6 changed files with 583 additions and 0 deletions

View file

@ -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<MyAnonamouseSettings>
{
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<MediaType> 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);
}
}
}

View file

@ -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<MyAnonamouseTorrent> 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; }
}
}

View file

@ -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<ReleaseInfo> ParseResponse(IndexerResponse indexerResponse)
{
var torrentInfos = new List<ReleaseInfo>();
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<MyAnonamouseResponse>(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<Dictionary<string, string>>(item.AuthorInfo);
var author = authorInfo?.Take(5).Select(v => v.Value).Join(", ");
if (author.IsNotNullOrWhiteSpace())
{
title += " by " + author;
}
}
catch
{
}
}
var flags = new List<string>();
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<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; }
}
}

View file

@ -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<string>();
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<IndexerRequest> 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<string, string> { { "mam_id", Settings.MamId } });
}
yield return new IndexerRequest(requestBuilder.Build());
}
public Func<IDictionary<string, string>> GetCookies { get; set; }
public Action<IDictionary<string, string>, DateTime?> CookiesUpdater { get; set; }
}
internal static class NameValueCollectionExtensions
{
public static string ToQueryString(this NameValueCollection nvc)
{
var items = new List<string>();
foreach (var key in nvc.AllKeys)
{
items.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(nvc[key])}");
}
return string.Join("&", items);
}
}
}

View file

@ -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<MyAnonamouseSettings>
{
public MyAnonamouseSettingsValidator()
{
RuleFor(c => c.BaseUrl).ValidRootUrl();
RuleFor(c => c.MamId).NotEmpty();
RuleFor(c => c.SeedCriteria).SetValidator(_ => new SeedCriteriaSettingsValidator());
}
}
public class MyAnonamouseSettings : PropertywiseEquatable<MyAnonamouseSettings>, 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<int>();
FailDownloads = Array.Empty<int>();
RequiredFlags = Array.Empty<int>();
}
[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<int> MultiLanguages { get; set; }
[FieldDefinition(10, Type = FieldType.Select, SelectOptions = typeof(FailDownloads), Label = "IndexerSettingsFailDownloads", HelpText = "IndexerSettingsFailDownloadsHelpText", Advanced = true)]
public IEnumerable<int> 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<int> 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
}
}

View file

@ -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.",