From 5e1cfc6eab26e17fce07eff4625eb51bbde9715f Mon Sep 17 00:00:00 2001 From: director-mouse <340716031+director-mouse@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:53:26 +0100 Subject: [PATCH 1/2] fix(MyAnonaMouse): persist refreshed mam_id cookies Save rotated mam_id values from successful account-status responses in indexer settings so the existing refresh service persists them. This prevents requests from continually reusing the original session cookie until it expires. --- .../Indexers/MyAnonaMouse/MyAnonaMouse.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/NzbDrone.Core/Indexers/MyAnonaMouse/MyAnonaMouse.cs b/src/NzbDrone.Core/Indexers/MyAnonaMouse/MyAnonaMouse.cs index 94438a16..be0de59a 100644 --- a/src/NzbDrone.Core/Indexers/MyAnonaMouse/MyAnonaMouse.cs +++ b/src/NzbDrone.Core/Indexers/MyAnonaMouse/MyAnonaMouse.cs @@ -242,6 +242,21 @@ protected override async Task TestConnection() } } + // Requests always send Settings.MamId, so adopt MAM's rotated cookie or the original expires after + // ~30 days. MyAnonaMouseAccountStatusService saves Settings after each successful refresh. + private void PersistRefreshedMamId(HttpResponse response) + { + if (!response.GetCookies().TryGetValue("mam_id", out var refreshedMamId) || + string.IsNullOrWhiteSpace(refreshedMamId) || + string.Equals(refreshedMamId, Settings.MamId, StringComparison.Ordinal)) + { + return; + } + + _logger.Info("MAM issued a refreshed mam_id for indexer '{0}', persisting it", Definition?.Name); + Settings.MamId = refreshedMamId; + } + public async Task RefreshAccountStatus() { var request = new HttpRequestBuilder($"{Settings.BaseUrl.TrimEnd('/')}/jsonLoad.php?snatch_summary&pretty") @@ -263,6 +278,8 @@ public async Task RefreshAccountStatus() throw new InvalidOperationException($"Failed to fetch MAM user data: HTTP {(int)response.StatusCode} {response.StatusCode}"); } + PersistRefreshedMamId(response); + var userData = JsonConvert.DeserializeObject(response.Content); var className = userData?.ClassName; From a8b36e9e6ddcba45aedeca723be8d571cd4ffa84 Mon Sep 17 00:00:00 2001 From: director-mouse <340716031+director-mouse@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:58:44 +0100 Subject: [PATCH 2/2] test(MyAnonaMouse): cover mam_id rotation and persistence Verify that a rotated mam_id is sent on the next account-status request and saved by the refresh service, that missing or blank cookies keep the current value, and that failed refreshes save nothing. --- .../Indexers/MyAnonaMouseWedgeFixture.cs | 121 +++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/src/Chaptarr.Core.Test/Indexers/MyAnonaMouseWedgeFixture.cs b/src/Chaptarr.Core.Test/Indexers/MyAnonaMouseWedgeFixture.cs index 9f21f606..7b586340 100644 --- a/src/Chaptarr.Core.Test/Indexers/MyAnonaMouseWedgeFixture.cs +++ b/src/Chaptarr.Core.Test/Indexers/MyAnonaMouseWedgeFixture.cs @@ -73,12 +73,30 @@ protected override Task> FetchPage(IndexerRequest request, IP private class IndexerFactoryProxy : DispatchProxy { public IIndexer Indexer { get; set; } + public List PersistedMamIds { get; } = new List(); protected override object Invoke(MethodInfo targetMethod, object[] args) { - if (targetMethod?.Name == nameof(IIndexerFactory.GetAvailableProviders)) + switch (targetMethod?.Name) { - return new List { Indexer }; + case nameof(IIndexerFactory.GetAvailableProviders): + return new List { Indexer }; + case nameof(IIndexerFactory.Update): + PersistedMamIds.Add(((MyAnonaMouseSettings)((IndexerDefinition)args[0]).Settings).MamId); + return null; + default: + throw new NotImplementedException(targetMethod?.Name); + } + } + } + + private class SlotGuardProxy : DispatchProxy + { + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + if (targetMethod?.Name == nameof(IMamUnsatisfiedSlotGuard.Reconcile)) + { + return null; } throw new NotImplementedException(targetMethod?.Name); @@ -354,6 +372,88 @@ public async Task account_status_should_accept_an_explicit_zero_count() }); } + [Test] + public async Task account_status_should_send_a_rotated_mam_id_on_the_next_request() + { + var sentMamIds = new List(); + var client = DispatchProxy.Create(); + var clientState = (IndexerHttpClientProxy)(object)client; + clientState.Responses.Enqueue(request => + { + sentMamIds.Add(request.Cookies["mam_id"]); + return ValidSummaryResponse(request, "mam_id=rotated-token; expires=Sat, 24 Oct 2026 03:00:00 GMT; Max-Age=1296000; path=/"); + }); + clientState.Responses.Enqueue(request => + { + sentMamIds.Add(request.Cookies["mam_id"]); + return ValidSummaryResponse(request); + }); + var settings = new MyAnonaMouseSettings { MamId = "original-token" }; + var indexer = CreateIndexer(settings, client); + + await indexer.RefreshAccountStatus(); + await indexer.RefreshAccountStatus(); + + Assert.Multiple(() => + { + Assert.That(sentMamIds, Is.EqualTo(new[] { "original-token", "rotated-token" })); + Assert.That(settings.MamId, Is.EqualTo("rotated-token")); + }); + } + + [Test] + public void account_status_refresh_should_persist_a_rotated_mam_id() + { + var client = CreateClient(request => ValidSummaryResponse(request, "mam_id=rotated-token; path=/")); + var indexer = CreateIndexer(new MyAnonaMouseSettings { MamId = "original-token" }, client); + var factory = DispatchProxy.Create(); + var factoryState = (IndexerFactoryProxy)(object)factory; + factoryState.Indexer = indexer; + + new MyAnonaMouseAccountStatusService(factory, CreateSlotGuard(), LogManager.GetCurrentClassLogger()) + .Execute(new RefreshMyAnonaMouseAccountStatusCommand()); + + Assert.That(factoryState.PersistedMamIds, Is.EqualTo(new[] { "rotated-token" })); + } + + [TestCase(null)] + [TestCase("mam_id=; path=/")] + [TestCase("mam_id= ; path=/")] + [TestCase("mam_ssl=1; path=/")] + public async Task account_status_should_keep_the_current_mam_id_without_a_usable_replacement(string setCookie) + { + var client = CreateClient(request => ValidSummaryResponse(request, setCookie)); + var settings = new MyAnonaMouseSettings { MamId = "original-token" }; + var indexer = CreateIndexer(settings, client); + + await indexer.RefreshAccountStatus(); + + Assert.That(settings.MamId, Is.EqualTo("original-token")); + } + + [TestCase("http-failure")] + [TestCase("invalid-summary")] + public void failed_account_status_refresh_should_not_persist_a_rotated_mam_id(string scenario) + { + var client = CreateClient(request => + { + var response = scenario == "http-failure" + ? new HttpResponse(request, new HttpHeader { ContentType = "application/json" }, "", HttpStatusCode.Forbidden) + : JsonResponse(request, "{\"classname\":\"User\"}"); + response.Headers.Add("Set-Cookie", "mam_id=rotated-token; path=/"); + return response; + }); + var indexer = CreateIndexer(new MyAnonaMouseSettings { MamId = "original-token" }, client); + var factory = DispatchProxy.Create(); + var factoryState = (IndexerFactoryProxy)(object)factory; + factoryState.Indexer = indexer; + + new MyAnonaMouseAccountStatusService(factory, CreateSlotGuard(), LogManager.GetCurrentClassLogger()) + .Execute(new RefreshMyAnonaMouseAccountStatusCommand()); + + Assert.That(factoryState.PersistedMamIds, Is.Empty); + } + [TestCase("malformed")] [TestCase("missing-count")] [TestCase("http-failure")] @@ -550,6 +650,23 @@ private static HttpResponse JsonResponse(HttpRequest request, string content) return new HttpResponse(request, new HttpHeader { ContentType = "application/json" }, content, HttpStatusCode.OK); } + private static HttpResponse ValidSummaryResponse(HttpRequest request, string setCookie = null) + { + var created = DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeSeconds(); + var response = JsonResponse(request, $"{{\"classname\":\"User\",\"created\":{created},\"unsat\":{{\"count\":3,\"limit\":50}}}}"); + if (setCookie != null) + { + response.Headers.Add("Set-Cookie", setCookie); + } + + return response; + } + + private static IMamUnsatisfiedSlotGuard CreateSlotGuard() + { + return DispatchProxy.Create(); + } + private static string EligibleAudiobookUrl() { return "https://www.myanonamouse.net/tor/download.php?tid=42&canUseToken=true&isAudiobook=true";