From de189d0c475060f1af029255f75221c3e364c8b4 Mon Sep 17 00:00:00 2001 From: Arne Moerman Date: Tue, 6 Oct 2026 16:54:39 +0200 Subject: [PATCH] first commit --- .dockerignore | 10 + .env.example | 35 +++ .gitea/workflows/build.yml | 44 ++++ .gitignore | 7 + Dockerfile | 24 ++ README.md | 106 ++++++++ SeerrAutoApprove.slnx | 8 + docker-compose.yml | 10 + .../Options/AutoApproveOptions.cs | 67 +++++ src/SeerrAutoApprove/Options/SeerrOptions.cs | 14 ++ .../Processing/AutoApproveWorker.cs | 58 +++++ .../Processing/ProcessingTrigger.cs | 15 ++ .../Processing/RequestProcessor.cs | 138 +++++++++++ src/SeerrAutoApprove/Program.cs | 65 +++++ src/SeerrAutoApprove/Quota/QuotaRules.cs | 67 +++++ src/SeerrAutoApprove/Seerr/SeerrClient.cs | 51 ++++ src/SeerrAutoApprove/Seerr/SeerrModels.cs | 49 ++++ src/SeerrAutoApprove/SeerrAutoApprove.csproj | 18 ++ .../Storage/SqliteApprovalStore.cs | 118 +++++++++ .../appsettings.Development.json | 9 + src/SeerrAutoApprove/appsettings.json | 29 +++ tests/SeerrAutoApprove.Tests/EndpointTests.cs | 77 ++++++ .../SeerrAutoApprove.Tests/QuotaRulesTests.cs | 95 ++++++++ .../RequestProcessorTests.cs | 229 ++++++++++++++++++ .../SeerrAutoApprove.Tests.csproj | 26 ++ .../SeerrClientTests.cs | 109 +++++++++ tests/SeerrAutoApprove.Tests/TestDoubles.cs | 78 ++++++ 27 files changed, 1556 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SeerrAutoApprove.slnx create mode 100644 docker-compose.yml create mode 100644 src/SeerrAutoApprove/Options/AutoApproveOptions.cs create mode 100644 src/SeerrAutoApprove/Options/SeerrOptions.cs create mode 100644 src/SeerrAutoApprove/Processing/AutoApproveWorker.cs create mode 100644 src/SeerrAutoApprove/Processing/ProcessingTrigger.cs create mode 100644 src/SeerrAutoApprove/Processing/RequestProcessor.cs create mode 100644 src/SeerrAutoApprove/Program.cs create mode 100644 src/SeerrAutoApprove/Quota/QuotaRules.cs create mode 100644 src/SeerrAutoApprove/Seerr/SeerrClient.cs create mode 100644 src/SeerrAutoApprove/Seerr/SeerrModels.cs create mode 100644 src/SeerrAutoApprove/SeerrAutoApprove.csproj create mode 100644 src/SeerrAutoApprove/Storage/SqliteApprovalStore.cs create mode 100644 src/SeerrAutoApprove/appsettings.Development.json create mode 100644 src/SeerrAutoApprove/appsettings.json create mode 100644 tests/SeerrAutoApprove.Tests/EndpointTests.cs create mode 100644 tests/SeerrAutoApprove.Tests/QuotaRulesTests.cs create mode 100644 tests/SeerrAutoApprove.Tests/RequestProcessorTests.cs create mode 100644 tests/SeerrAutoApprove.Tests/SeerrAutoApprove.Tests.csproj create mode 100644 tests/SeerrAutoApprove.Tests/SeerrClientTests.cs create mode 100644 tests/SeerrAutoApprove.Tests/TestDoubles.cs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7fa365c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/bin/ +**/obj/ +**/.vs/ +**/.vscode/ +**/*.user +**/data/ +.git/ +.gitea/ +tests/ +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..544aaad --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# --- Seerr connection --- +Seerr__BaseUrl=http://seerr:5055 +Seerr__ApiKey=changeme + +# --- Limits per user per period (-1 = unlimited, 0 = never auto-approve) --- +AutoApprove__MovieLimit=5 +AutoApprove__SeriesLimit=5 +AutoApprove__Movie4kLimit=0 +AutoApprove__Series4kLimit=0 + +# How series count towards the quota: PerSeason | PerRequest +AutoApprove__SeriesCountMode=PerSeason + +# --- Quota period --- +# Rolling = last N days (AutoApprove__RollingDays) +# Calendar = current Day | Week (starts Monday) | Month | Year (AutoApprove__CalendarPeriod) +AutoApprove__PeriodType=Rolling +AutoApprove__RollingDays=30 +AutoApprove__CalendarPeriod=Month + +# true = requests left pending because of the quota are auto-approved once quota is available again +# false = once skipped, a request stays pending for manual approval +AutoApprove__ReevaluateSkippedRequests=true + +# Fallback polling interval in seconds (webhook triggers an immediate run) +AutoApprove__PollIntervalSeconds=300 + +# Log what would be approved without approving anything +AutoApprove__DryRun=false + +# Optional: must match the "Authorization Header" configured on the Seerr webhook +AutoApprove__WebhookSecret= + +# Time zone used for calendar periods +TZ=Europe/Brussels diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..e1d114b --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,44 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Run tests + run: dotnet test --configuration Release + + - name: Generate datetime tag + id: vars + run: echo "tag=$(date +'%Y%m%d%H%M')" >> "$GITHUB_OUTPUT" + + - name: Log in to Gitea Registry + uses: docker/login-action@v3 + with: + registry: git.arnemoerman.be + username: arne + password: ${{ secrets.PAT_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + git.arnemoerman.be/arne/seerr-autoapprove:latest + git.arnemoerman.be/arne/seerr-autoapprove:${{ steps.vars.outputs.tag }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b80bce --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +.vs/ +*.user +TestResults/ +data/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aaec443 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY src/SeerrAutoApprove/SeerrAutoApprove.csproj src/SeerrAutoApprove/ +RUN dotnet restore src/SeerrAutoApprove/SeerrAutoApprove.csproj + +COPY src/ src/ +RUN dotnet publish src/SeerrAutoApprove/SeerrAutoApprove.csproj -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +RUN mkdir -p /data && chown $APP_UID /data + +ENV ASPNETCORE_HTTP_PORTS=8080 \ + AutoApprove__DatabasePath=/data/autoapprove.db + +COPY --from=build /app . + +USER $APP_UID +EXPOSE 8080 +VOLUME ["/data"] + +ENTRYPOINT ["dotnet", "SeerrAutoApprove.dll"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b16c89 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Seerr AutoApprove + +Automatically approves a configurable number of movie and series requests per user in [Seerr](https://github.com/seerr-team/seerr). Requests over the limit stay pending for manual approval. + +## How it works + +- Polls Seerr for pending requests every `PollIntervalSeconds`, and immediately when Seerr calls the `/webhook` endpoint. +- Pending requests are evaluated oldest first. A request is approved when the user still has quota left for that category (movie, series, 4K movie, 4K series). +- Only approvals made by this service count towards the quota. They are tracked in a small SQLite database in `/data`. +- Series requests are never partially approved: if a request contains more seasons than the user has quota left, it stays pending. + +## Setup + +### 1. Get a Seerr API key + +In Seerr go to **Settings → General** and copy the **API Key**. + +### 2. Configure + +Copy the example environment file and edit it: + +```sh +cp .env.example .env +``` + +At minimum set `Seerr__BaseUrl` and `Seerr__ApiKey`. See [Configuration](#configuration) for all options. + +> Tip: start with `AutoApprove__DryRun=true` and check the logs to verify the behaviour before letting it approve anything. + +### 3. Run with Docker Compose + +```yaml +services: + seerr-autoapprove: + image: git.arnemoerman.be/arne/seerr-autoapprove:latest + container_name: seerr-autoapprove + restart: unless-stopped + env_file: .env + ports: + - "8080:8080" # only needed if Seerr is not on the same Docker network + volumes: + - ./data:/data +``` + +```sh +docker compose up -d +docker compose logs -f +``` + +Keep the `/data` volume: it holds the approval history used to calculate quotas. + +### 4. Configure the Seerr webhook (recommended) + +Without the webhook, requests are picked up on the next poll. With it, they are handled right away. + +1. In Seerr go to **Settings → Notifications → Webhook**. +2. Enable the agent. +3. **Webhook URL**: `http://seerr-autoapprove:8080/webhook` (same Docker network) or `http://:8080/webhook`. +4. **Authorization Header**: the same value as `AutoApprove__WebhookSecret` (leave both empty to disable). +5. **Notification types**: enable **Request Pending Approval**. +6. Leave the JSON payload as default — the payload content is ignored, the call only triggers a run. +7. Save and use **Test** — the service log should show a run. + +## Configuration + +All settings are environment variables (see [.env.example](.env.example)). + +| Variable | Default | Description | +|---|---|---| +| `Seerr__BaseUrl` | — | Seerr URL, e.g. `http://seerr:5055` (required) | +| `Seerr__ApiKey` | — | Seerr API key (required) | +| `AutoApprove__MovieLimit` | `5` | Movies per user per period. `-1` = unlimited, `0` = never auto-approve | +| `AutoApprove__SeriesLimit` | `5` | Series per user per period | +| `AutoApprove__Movie4kLimit` | `0` | 4K movies per user per period | +| `AutoApprove__Series4kLimit` | `0` | 4K series per user per period | +| `AutoApprove__SeriesCountMode` | `PerSeason` | `PerSeason`: each requested season counts as 1. `PerRequest`: each series request counts as 1 | +| `AutoApprove__PeriodType` | `Rolling` | `Rolling` or `Calendar` | +| `AutoApprove__RollingDays` | `30` | Window length for `Rolling` | +| `AutoApprove__CalendarPeriod` | `Month` | `Day`, `Week` (starts Monday), `Month` or `Year` for `Calendar` | +| `AutoApprove__ReevaluateSkippedRequests` | `true` | `true`: requests left pending due to the quota are approved once quota is available again. `false`: they stay pending for manual review | +| `AutoApprove__PollIntervalSeconds` | `300` | Fallback poll interval (10–86400) | +| `AutoApprove__DryRun` | `false` | Log what would be approved without approving | +| `AutoApprove__WebhookSecret` | empty | Required `Authorization` header value for `/webhook` | +| `TZ` | container default | Time zone used for calendar periods, e.g. `Europe/Brussels` | + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/health` | Health check | +| `POST` | `/webhook` | Triggers an immediate run (Seerr webhook target) | + +## Development + +Requires the .NET 10 SDK. + +```sh +dotnet test +dotnet run --project src/SeerrAutoApprove +``` + +For local runs, set `Seerr__BaseUrl` and `Seerr__ApiKey` as environment variables or in `src/SeerrAutoApprove/appsettings.Development.json`. + +## CI + +[.gitea/workflows/build.yml](.gitea/workflows/build.yml) runs the tests and pushes `git.arnemoerman.be/arne/seerr-autoapprove:latest` plus a datetime tag on every push to `main`. It needs a `PAT_TOKEN` secret with access to the package registry. diff --git a/SeerrAutoApprove.slnx b/SeerrAutoApprove.slnx new file mode 100644 index 0000000..afa56a9 --- /dev/null +++ b/SeerrAutoApprove.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8fd55f4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + seerr-autoapprove: + image: git.arnemoerman.be/arne/seerr-autoapprove:latest + container_name: seerr-autoapprove + restart: unless-stopped + env_file: .env + ports: + - "8080:8080" # only needed if Seerr is not on the same Docker network + volumes: + - ./data:/data diff --git a/src/SeerrAutoApprove/Options/AutoApproveOptions.cs b/src/SeerrAutoApprove/Options/AutoApproveOptions.cs new file mode 100644 index 0000000..4fea998 --- /dev/null +++ b/src/SeerrAutoApprove/Options/AutoApproveOptions.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; + +namespace SeerrAutoApprove.Options; + +public enum PeriodType +{ + Rolling, + Calendar, +} + +public enum CalendarPeriod +{ + Day, + Week, + Month, + Year, +} + +public enum SeriesCountMode +{ + PerSeason, + PerRequest, +} + +public sealed class AutoApproveOptions +{ + public const string SectionName = "AutoApprove"; + + /// -1 = unlimited, 0 = never auto-approve. + [Range(-1, int.MaxValue)] + public int MovieLimit { get; set; } = 5; + + [Range(-1, int.MaxValue)] + public int SeriesLimit { get; set; } = 5; + + [Range(-1, int.MaxValue)] + public int Movie4kLimit { get; set; } + + [Range(-1, int.MaxValue)] + public int Series4kLimit { get; set; } + + public SeriesCountMode SeriesCountMode { get; set; } = SeriesCountMode.PerSeason; + + public PeriodType PeriodType { get; set; } = PeriodType.Rolling; + + [Range(1, 3650)] + public int RollingDays { get; set; } = 30; + + public CalendarPeriod CalendarPeriod { get; set; } = CalendarPeriod.Month; + + /// + /// When true, requests left pending because the quota was exhausted are auto-approved + /// once quota becomes available again. When false they stay pending for manual review. + /// + public bool ReevaluateSkippedRequests { get; set; } = true; + + [Range(10, 86400)] + public int PollIntervalSeconds { get; set; } = 300; + + public bool DryRun { get; set; } + + /// Expected value of the Authorization header sent by the Seerr webhook. Empty = no auth. + public string? WebhookSecret { get; set; } + + [Required] + public string DatabasePath { get; set; } = "data/autoapprove.db"; +} diff --git a/src/SeerrAutoApprove/Options/SeerrOptions.cs b/src/SeerrAutoApprove/Options/SeerrOptions.cs new file mode 100644 index 0000000..f23e080 --- /dev/null +++ b/src/SeerrAutoApprove/Options/SeerrOptions.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace SeerrAutoApprove.Options; + +public sealed class SeerrOptions +{ + public const string SectionName = "Seerr"; + + [Required, Url] + public string BaseUrl { get; set; } = string.Empty; + + [Required] + public string ApiKey { get; set; } = string.Empty; +} diff --git a/src/SeerrAutoApprove/Processing/AutoApproveWorker.cs b/src/SeerrAutoApprove/Processing/AutoApproveWorker.cs new file mode 100644 index 0000000..a70f3ed --- /dev/null +++ b/src/SeerrAutoApprove/Processing/AutoApproveWorker.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Options; +using SeerrAutoApprove.Options; + +namespace SeerrAutoApprove.Processing; + +public sealed class AutoApproveWorker( + RequestProcessor processor, + ProcessingTrigger trigger, + IOptionsMonitor options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var startup = options.CurrentValue; + logger.LogInformation( + "Started. Limits: movies={MovieLimit}, series={SeriesLimit}, 4K movies={Movie4kLimit}, 4K series={Series4kLimit}; " + + "series count mode={SeriesCountMode}; period={PeriodType}; poll interval={PollInterval}s; dry run={DryRun}", + startup.MovieLimit, startup.SeriesLimit, startup.Movie4kLimit, startup.Series4kLimit, + startup.SeriesCountMode, startup.PeriodType == PeriodType.Rolling ? $"rolling {startup.RollingDays} day(s)" : startup.CalendarPeriod.ToString(), + startup.PollIntervalSeconds, startup.DryRun); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var result = await processor.ProcessAsync(stoppingToken); + if (result.Pending > 0) + { + logger.LogInformation("Run complete: {Approved} approved, {Skipped} left pending", result.Approved, result.Skipped); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Processing pending requests failed"); + } + + using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + waitCts.CancelAfter(TimeSpan.FromSeconds(options.CurrentValue.PollIntervalSeconds)); + try + { + await trigger.WaitAsync(waitCts.Token); + logger.LogDebug("Run triggered by webhook"); + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + // poll interval elapsed + } + catch (OperationCanceledException) + { + break; + } + } + } +} diff --git a/src/SeerrAutoApprove/Processing/ProcessingTrigger.cs b/src/SeerrAutoApprove/Processing/ProcessingTrigger.cs new file mode 100644 index 0000000..86fded3 --- /dev/null +++ b/src/SeerrAutoApprove/Processing/ProcessingTrigger.cs @@ -0,0 +1,15 @@ +using System.Threading.Channels; + +namespace SeerrAutoApprove.Processing; + +/// Coalesces run requests (e.g. from webhooks) into a single pending signal. +public sealed class ProcessingTrigger +{ + private readonly Channel _channel = Channel.CreateBounded( + new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); + + public void Request() => _channel.Writer.TryWrite(true); + + public async Task WaitAsync(CancellationToken cancellationToken) => + await _channel.Reader.ReadAsync(cancellationToken); +} diff --git a/src/SeerrAutoApprove/Processing/RequestProcessor.cs b/src/SeerrAutoApprove/Processing/RequestProcessor.cs new file mode 100644 index 0000000..33295fb --- /dev/null +++ b/src/SeerrAutoApprove/Processing/RequestProcessor.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.Options; +using SeerrAutoApprove.Options; +using SeerrAutoApprove.Quota; +using SeerrAutoApprove.Seerr; +using SeerrAutoApprove.Storage; + +namespace SeerrAutoApprove.Processing; + +public sealed record ProcessingResult(int Pending, int Approved, int Skipped); + +public sealed class RequestProcessor( + ISeerrClient seerr, + IApprovalStore store, + IOptionsMonitor optionsMonitor, + TimeProvider timeProvider, + ILogger logger) +{ + private readonly SemaphoreSlim _lock = new(1, 1); + + public async Task ProcessAsync(CancellationToken cancellationToken) + { + await _lock.WaitAsync(cancellationToken); + try + { + return await ProcessCoreAsync(cancellationToken); + } + finally + { + _lock.Release(); + } + } + + private async Task ProcessCoreAsync(CancellationToken cancellationToken) + { + var options = optionsMonitor.CurrentValue; + var pending = await seerr.GetPendingRequestsAsync(cancellationToken); + if (pending.Count == 0) + { + logger.LogDebug("No pending requests"); + return new ProcessingResult(0, 0, 0); + } + + var periodStart = QuotaRules.GetPeriodStart(options, timeProvider); + var previouslySkipped = options.ReevaluateSkippedRequests + ? new HashSet() + : await store.GetSkippedRequestIdsAsync(cancellationToken); + var usage = new Dictionary<(int UserId, QuotaCategory Category), int>(); + int approved = 0, skipped = 0; + + logger.LogInformation("Evaluating {Count} pending request(s), quota period starts {PeriodStart:u}", pending.Count, periodStart); + + foreach (var request in pending.OrderBy(r => r.CreatedAt).ThenBy(r => r.Id)) + { + if (request.RequestedBy is null) + { + logger.LogWarning("Request #{RequestId} has no requesting user, skipping", request.Id); + skipped++; + continue; + } + + if (previouslySkipped.Contains(request.Id)) + { + skipped++; + continue; + } + + var category = QuotaRules.GetCategory(request); + var limit = QuotaRules.GetLimit(category, options); + var units = QuotaRules.GetUnits(request, options); + var user = request.RequestedBy; + + if (limit == 0) + { + logger.LogDebug("Request #{RequestId} ({Category}) by {User}: auto-approve disabled for category", request.Id, category, user); + skipped++; + continue; + } + + var key = (user.Id, category); + if (!usage.TryGetValue(key, out var used)) + { + used = await store.GetUsedUnitsAsync(user.Id, category, periodStart, cancellationToken); + } + + if (limit > 0 && used + units > limit) + { + logger.LogInformation( + "Request #{RequestId} ({Category}, {Units} unit(s)) by {User}: quota exceeded ({Used}/{Limit}), leaving pending", + request.Id, category, units, user, used, limit); + usage[key] = used; + skipped++; + + if (!options.ReevaluateSkippedRequests && !options.DryRun) + { + await store.MarkSkippedAsync(request.Id, timeProvider.GetUtcNow(), cancellationToken); + } + + continue; + } + + if (options.DryRun) + { + logger.LogInformation( + "[DRY RUN] Would approve request #{RequestId} ({Category}, {Units} unit(s)) by {User} ({Used}/{Limit} used)", + request.Id, category, units, user, used, FormatLimit(limit)); + usage[key] = used + units; + approved++; + continue; + } + + try + { + await seerr.ApproveRequestAsync(request.Id, cancellationToken); + } + catch (HttpRequestException ex) + { + logger.LogWarning(ex, "Failed to approve request #{RequestId}", request.Id); + skipped++; + continue; + } + + await store.RecordApprovalAsync( + new ApprovalRecord(request.Id, user.Id, category, units, timeProvider.GetUtcNow()), + cancellationToken); + + usage[key] = used + units; + approved++; + + logger.LogInformation( + "Approved request #{RequestId} ({Category}, {Units} unit(s)) by {User} ({Used}/{Limit} used)", + request.Id, category, units, user, used + units, FormatLimit(limit)); + } + + return new ProcessingResult(pending.Count, approved, skipped); + } + + private static string FormatLimit(int limit) => limit < 0 ? "unlimited" : limit.ToString(); +} diff --git a/src/SeerrAutoApprove/Program.cs b/src/SeerrAutoApprove/Program.cs new file mode 100644 index 0000000..cf39bc6 --- /dev/null +++ b/src/SeerrAutoApprove/Program.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using SeerrAutoApprove.Options; +using SeerrAutoApprove.Processing; +using SeerrAutoApprove.Seerr; +using SeerrAutoApprove.Storage; + +var builder = WebApplication.CreateBuilder(args); + +builder.Logging.AddSimpleConsole(o => +{ + o.SingleLine = true; + o.TimestampFormat = "yyyy-MM-dd HH:mm:ss "; +}); + +builder.Services.AddOptions() + .BindConfiguration(SeerrOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + +builder.Services.AddOptions() + .BindConfiguration(AutoApproveOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + +builder.Services.AddHttpClient(SeerrClient.HttpClientName, (sp, client) => +{ + var seerr = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(seerr.BaseUrl.TrimEnd('/') + "/api/v1/"); + client.DefaultRequestHeaders.Add("X-Api-Key", seerr.ApiKey); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); + +// Configure in Seerr: Settings > Notifications > Webhook, enable "Request Pending Approval". +app.MapPost("/webhook", (HttpRequest request, ProcessingTrigger trigger, IOptionsMonitor options) => +{ + var secret = options.CurrentValue.WebhookSecret; + if (!string.IsNullOrEmpty(secret)) + { + var header = request.Headers.Authorization.ToString(); + if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(header), Encoding.UTF8.GetBytes(secret))) + { + return Results.Unauthorized(); + } + } + + trigger.Request(); + return Results.Accepted(); +}); + +app.Run(); + +public partial class Program; diff --git a/src/SeerrAutoApprove/Quota/QuotaRules.cs b/src/SeerrAutoApprove/Quota/QuotaRules.cs new file mode 100644 index 0000000..4f0bb69 --- /dev/null +++ b/src/SeerrAutoApprove/Quota/QuotaRules.cs @@ -0,0 +1,67 @@ +using SeerrAutoApprove.Options; +using SeerrAutoApprove.Seerr; + +namespace SeerrAutoApprove.Quota; + +public enum QuotaCategory +{ + Movie, + Series, + Movie4k, + Series4k, +} + +public static class QuotaRules +{ + public static QuotaCategory GetCategory(SeerrRequest request) => (request.IsSeries, request.Is4k) switch + { + (false, false) => QuotaCategory.Movie, + (true, false) => QuotaCategory.Series, + (false, true) => QuotaCategory.Movie4k, + (true, true) => QuotaCategory.Series4k, + }; + + public static int GetLimit(QuotaCategory category, AutoApproveOptions options) => category switch + { + QuotaCategory.Movie => options.MovieLimit, + QuotaCategory.Series => options.SeriesLimit, + QuotaCategory.Movie4k => options.Movie4kLimit, + QuotaCategory.Series4k => options.Series4kLimit, + _ => throw new ArgumentOutOfRangeException(nameof(category)), + }; + + public static int GetUnits(SeerrRequest request, AutoApproveOptions options) + { + if (!request.IsSeries || options.SeriesCountMode == SeriesCountMode.PerRequest) + { + return 1; + } + + return Math.Max(1, request.Seasons.Count); + } + + public static DateTimeOffset GetPeriodStart(AutoApproveOptions options, TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + + if (options.PeriodType == PeriodType.Rolling) + { + return now.AddDays(-options.RollingDays); + } + + var timeZone = timeProvider.LocalTimeZone; + var local = TimeZoneInfo.ConvertTime(now, timeZone).DateTime; + + var startLocal = options.CalendarPeriod switch + { + CalendarPeriod.Day => local.Date, + // ISO week: starts on Monday + CalendarPeriod.Week => local.Date.AddDays(-(((int)local.DayOfWeek + 6) % 7)), + CalendarPeriod.Month => new DateTime(local.Year, local.Month, 1), + CalendarPeriod.Year => new DateTime(local.Year, 1, 1), + _ => throw new ArgumentOutOfRangeException(nameof(options)), + }; + + return new DateTimeOffset(startLocal, timeZone.GetUtcOffset(startLocal)); + } +} diff --git a/src/SeerrAutoApprove/Seerr/SeerrClient.cs b/src/SeerrAutoApprove/Seerr/SeerrClient.cs new file mode 100644 index 0000000..00b9636 --- /dev/null +++ b/src/SeerrAutoApprove/Seerr/SeerrClient.cs @@ -0,0 +1,51 @@ +using System.Net.Http.Json; + +namespace SeerrAutoApprove.Seerr; + +public interface ISeerrClient +{ + Task> GetPendingRequestsAsync(CancellationToken cancellationToken); + Task ApproveRequestAsync(int requestId, CancellationToken cancellationToken); +} + +public sealed class SeerrClient(IHttpClientFactory httpClientFactory) : ISeerrClient +{ + public const string HttpClientName = "seerr"; + private const int PageSize = 100; + + public async Task> GetPendingRequestsAsync(CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient(HttpClientName); + var results = new List(); + var skip = 0; + + while (true) + { + var page = await client.GetFromJsonAsync( + $"request?filter=pending&sort=added&sortDirection=asc&take={PageSize}&skip={skip}", + cancellationToken); + + if (page is null || page.Results.Count == 0) + { + break; + } + + results.AddRange(page.Results); + skip += page.Results.Count; + + if (page.PageInfo is null || page.PageInfo.Page >= page.PageInfo.Pages) + { + break; + } + } + + return results; + } + + public async Task ApproveRequestAsync(int requestId, CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient(HttpClientName); + using var response = await client.PostAsync($"request/{requestId}/approve", content: null, cancellationToken); + response.EnsureSuccessStatusCode(); + } +} diff --git a/src/SeerrAutoApprove/Seerr/SeerrModels.cs b/src/SeerrAutoApprove/Seerr/SeerrModels.cs new file mode 100644 index 0000000..1937c10 --- /dev/null +++ b/src/SeerrAutoApprove/Seerr/SeerrModels.cs @@ -0,0 +1,49 @@ +namespace SeerrAutoApprove.Seerr; + +public sealed record SeerrPageInfo +{ + public int Page { get; init; } + public int Pages { get; init; } + public int Results { get; init; } +} + +public sealed record SeerrRequestPage +{ + public SeerrPageInfo? PageInfo { get; init; } + public List Results { get; init; } = []; +} + +public sealed record SeerrRequest +{ + public int Id { get; init; } + public int Status { get; init; } + public string? Type { get; init; } + public bool Is4k { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public SeerrUser? RequestedBy { get; init; } + public SeerrMedia? Media { get; init; } + public List Seasons { get; init; } = []; + + public bool IsSeries => string.Equals(Type ?? Media?.MediaType, "tv", StringComparison.OrdinalIgnoreCase); +} + +public sealed record SeerrUser +{ + public int Id { get; init; } + public string? DisplayName { get; init; } + public string? Email { get; init; } + + public override string ToString() => DisplayName ?? Email ?? $"user #{Id}"; +} + +public sealed record SeerrMedia +{ + public int? TmdbId { get; init; } + public string? MediaType { get; init; } +} + +public sealed record SeerrSeason +{ + public int Id { get; init; } + public int SeasonNumber { get; init; } +} diff --git a/src/SeerrAutoApprove/SeerrAutoApprove.csproj b/src/SeerrAutoApprove/SeerrAutoApprove.csproj new file mode 100644 index 0000000..33af7be --- /dev/null +++ b/src/SeerrAutoApprove/SeerrAutoApprove.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + diff --git a/src/SeerrAutoApprove/Storage/SqliteApprovalStore.cs b/src/SeerrAutoApprove/Storage/SqliteApprovalStore.cs new file mode 100644 index 0000000..8cd8240 --- /dev/null +++ b/src/SeerrAutoApprove/Storage/SqliteApprovalStore.cs @@ -0,0 +1,118 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Options; +using SeerrAutoApprove.Options; +using SeerrAutoApprove.Quota; + +namespace SeerrAutoApprove.Storage; + +public sealed record ApprovalRecord(int RequestId, int UserId, QuotaCategory Category, int Units, DateTimeOffset ApprovedAt); + +public interface IApprovalStore +{ + Task GetUsedUnitsAsync(int userId, QuotaCategory category, DateTimeOffset since, CancellationToken cancellationToken); + Task RecordApprovalAsync(ApprovalRecord record, CancellationToken cancellationToken); + Task> GetSkippedRequestIdsAsync(CancellationToken cancellationToken); + Task MarkSkippedAsync(int requestId, DateTimeOffset skippedAt, CancellationToken cancellationToken); +} + +public sealed class SqliteApprovalStore : IApprovalStore +{ + private readonly string _connectionString; + + public SqliteApprovalStore(IOptions options) + { + var path = Path.GetFullPath(options.Value.DatabasePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + _connectionString = new SqliteConnectionStringBuilder + { + DataSource = path, + Mode = SqliteOpenMode.ReadWriteCreate, + Pooling = false, + }.ToString(); + + using var connection = Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE TABLE IF NOT EXISTS approvals ( + request_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + category TEXT NOT NULL, + units INTEGER NOT NULL, + approved_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_approvals_user_category_time ON approvals (user_id, category, approved_at); + CREATE TABLE IF NOT EXISTS skipped_requests ( + request_id INTEGER PRIMARY KEY, + skipped_at INTEGER NOT NULL + ); + """; + command.ExecuteNonQuery(); + } + + public async Task GetUsedUnitsAsync(int userId, QuotaCategory category, DateTimeOffset since, CancellationToken cancellationToken) + { + await using var connection = Open(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COALESCE(SUM(units), 0) FROM approvals + WHERE user_id = $userId AND category = $category AND approved_at >= $since + """; + command.Parameters.AddWithValue("$userId", userId); + command.Parameters.AddWithValue("$category", category.ToString()); + command.Parameters.AddWithValue("$since", since.ToUnixTimeMilliseconds()); + + var result = await command.ExecuteScalarAsync(cancellationToken); + return Convert.ToInt32(result); + } + + public async Task RecordApprovalAsync(ApprovalRecord record, CancellationToken cancellationToken) + { + await using var connection = Open(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT OR REPLACE INTO approvals (request_id, user_id, category, units, approved_at) + VALUES ($requestId, $userId, $category, $units, $approvedAt); + DELETE FROM skipped_requests WHERE request_id = $requestId; + """; + command.Parameters.AddWithValue("$requestId", record.RequestId); + command.Parameters.AddWithValue("$userId", record.UserId); + command.Parameters.AddWithValue("$category", record.Category.ToString()); + command.Parameters.AddWithValue("$units", record.Units); + command.Parameters.AddWithValue("$approvedAt", record.ApprovedAt.ToUnixTimeMilliseconds()); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async Task> GetSkippedRequestIdsAsync(CancellationToken cancellationToken) + { + await using var connection = Open(); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT request_id FROM skipped_requests"; + + var ids = new HashSet(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + ids.Add(reader.GetInt32(0)); + } + + return ids; + } + + public async Task MarkSkippedAsync(int requestId, DateTimeOffset skippedAt, CancellationToken cancellationToken) + { + await using var connection = Open(); + await using var command = connection.CreateCommand(); + command.CommandText = "INSERT OR IGNORE INTO skipped_requests (request_id, skipped_at) VALUES ($requestId, $skippedAt)"; + command.Parameters.AddWithValue("$requestId", requestId); + command.Parameters.AddWithValue("$skippedAt", skippedAt.ToUnixTimeMilliseconds()); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private SqliteConnection Open() + { + var connection = new SqliteConnection(_connectionString); + connection.Open(); + return connection; + } +} diff --git a/src/SeerrAutoApprove/appsettings.Development.json b/src/SeerrAutoApprove/appsettings.Development.json new file mode 100644 index 0000000..10ca3b9 --- /dev/null +++ b/src/SeerrAutoApprove/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Warning", + "System.Net.Http.HttpClient": "Warning" + } + } +} diff --git a/src/SeerrAutoApprove/appsettings.json b/src/SeerrAutoApprove/appsettings.json new file mode 100644 index 0000000..8891a02 --- /dev/null +++ b/src/SeerrAutoApprove/appsettings.json @@ -0,0 +1,29 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "System.Net.Http.HttpClient": "Warning" + } + }, + "AllowedHosts": "*", + "Seerr": { + "BaseUrl": "", + "ApiKey": "" + }, + "AutoApprove": { + "MovieLimit": 5, + "SeriesLimit": 5, + "Movie4kLimit": 0, + "Series4kLimit": 0, + "SeriesCountMode": "PerSeason", + "PeriodType": "Rolling", + "RollingDays": 30, + "CalendarPeriod": "Month", + "ReevaluateSkippedRequests": true, + "PollIntervalSeconds": 300, + "DryRun": false, + "WebhookSecret": "", + "DatabasePath": "data/autoapprove.db" + } +} diff --git a/tests/SeerrAutoApprove.Tests/EndpointTests.cs b/tests/SeerrAutoApprove.Tests/EndpointTests.cs new file mode 100644 index 0000000..82fc865 --- /dev/null +++ b/tests/SeerrAutoApprove.Tests/EndpointTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SeerrAutoApprove.Seerr; + +namespace SeerrAutoApprove.Tests; + +public sealed class EndpointTests : IDisposable +{ + private readonly TempDatabase _db = new(); + + public void Dispose() => _db.Dispose(); + + private WebApplicationFactory CreateFactory(string? webhookSecret) => + new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + ["Seerr:BaseUrl"] = "http://seerr.invalid", + ["Seerr:ApiKey"] = "test-key", + ["AutoApprove:DatabasePath"] = _db.Path, + ["AutoApprove:WebhookSecret"] = webhookSecret, + })); + builder.ConfigureServices(services => services.Replace(ServiceDescriptor.Singleton())); + }); + + [Fact] + public async Task Health_returns_ok() + { + await using var factory = CreateFactory(null); + + var response = await factory.CreateClient().GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Webhook_without_secret_is_accepted() + { + await using var factory = CreateFactory(null); + + var response = await factory.CreateClient().PostAsync("/webhook", new StringContent("{}")); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + } + + [Fact] + public async Task Webhook_with_secret_rejects_missing_or_wrong_header() + { + await using var factory = CreateFactory("s3cret"); + var client = factory.CreateClient(); + + var missing = await client.PostAsync("/webhook", new StringContent("{}")); + + using var wrong = new HttpRequestMessage(HttpMethod.Post, "/webhook") { Content = new StringContent("{}") }; + wrong.Headers.TryAddWithoutValidation("Authorization", "nope"); + var wrongResponse = await client.SendAsync(wrong); + + Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, wrongResponse.StatusCode); + } + + [Fact] + public async Task Webhook_with_correct_secret_is_accepted() + { + await using var factory = CreateFactory("s3cret"); + using var request = new HttpRequestMessage(HttpMethod.Post, "/webhook") { Content = new StringContent("{}") }; + request.Headers.TryAddWithoutValidation("Authorization", "s3cret"); + + var response = await factory.CreateClient().SendAsync(request); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + } +} diff --git a/tests/SeerrAutoApprove.Tests/QuotaRulesTests.cs b/tests/SeerrAutoApprove.Tests/QuotaRulesTests.cs new file mode 100644 index 0000000..3fea974 --- /dev/null +++ b/tests/SeerrAutoApprove.Tests/QuotaRulesTests.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Time.Testing; +using SeerrAutoApprove.Options; +using SeerrAutoApprove.Quota; + +namespace SeerrAutoApprove.Tests; + +public sealed class QuotaRulesTests +{ + // Thursday + private static readonly DateTimeOffset Now = new(2026, 10, 15, 12, 30, 0, TimeSpan.Zero); + + private static FakeTimeProvider Time(TimeZoneInfo? zone = null) + { + var time = new FakeTimeProvider(Now); + time.SetLocalTimeZone(zone ?? TimeZoneInfo.Utc); + return time; + } + + [Fact] + public void Rolling_period_starts_n_days_ago() + { + var start = QuotaRules.GetPeriodStart(new AutoApproveOptions { PeriodType = PeriodType.Rolling, RollingDays = 7 }, Time()); + + Assert.Equal(Now.AddDays(-7), start); + } + + [Theory] + [InlineData(CalendarPeriod.Day, 2026, 10, 15)] + [InlineData(CalendarPeriod.Week, 2026, 10, 12)] + [InlineData(CalendarPeriod.Month, 2026, 10, 1)] + [InlineData(CalendarPeriod.Year, 2026, 1, 1)] + public void Calendar_period_starts_at_beginning_of_period(CalendarPeriod period, int year, int month, int day) + { + var options = new AutoApproveOptions { PeriodType = PeriodType.Calendar, CalendarPeriod = period }; + + var start = QuotaRules.GetPeriodStart(options, Time()); + + Assert.Equal(new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero), start); + } + + [Fact] + public void Calendar_period_respects_local_time_zone() + { + var zone = TimeZoneInfo.CreateCustomTimeZone("UTC+2", TimeSpan.FromHours(2), "UTC+2", "UTC+2"); + var options = new AutoApproveOptions { PeriodType = PeriodType.Calendar, CalendarPeriod = CalendarPeriod.Month }; + + var start = QuotaRules.GetPeriodStart(options, Time(zone)); + + Assert.Equal(new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.FromHours(2)), start); + } + + [Fact] + public void Week_starts_on_monday_when_today_is_sunday() + { + var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 18, 10, 0, 0, TimeSpan.Zero)); + time.SetLocalTimeZone(TimeZoneInfo.Utc); + var options = new AutoApproveOptions { PeriodType = PeriodType.Calendar, CalendarPeriod = CalendarPeriod.Week }; + + Assert.Equal(new DateTimeOffset(2026, 10, 12, 0, 0, 0, TimeSpan.Zero), QuotaRules.GetPeriodStart(options, time)); + } + + [Fact] + public void Categorizes_requests() + { + Assert.Equal(QuotaCategory.Movie, QuotaRules.GetCategory(Requests.Movie(1))); + Assert.Equal(QuotaCategory.Movie4k, QuotaRules.GetCategory(Requests.Movie(1, is4k: true))); + Assert.Equal(QuotaCategory.Series, QuotaRules.GetCategory(Requests.Series(1, 1))); + Assert.Equal(QuotaCategory.Series4k, QuotaRules.GetCategory(Requests.Series(1, 1, is4k: true))); + } + + [Fact] + public void Falls_back_to_media_type_when_request_type_missing() + { + var request = Requests.Movie(1) with { Type = null, Media = new Seerr.SeerrMedia { MediaType = "tv" } }; + + Assert.Equal(QuotaCategory.Series, QuotaRules.GetCategory(request)); + } + + [Theory] + [InlineData(SeriesCountMode.PerSeason, 3, 3)] + [InlineData(SeriesCountMode.PerSeason, 0, 1)] + [InlineData(SeriesCountMode.PerRequest, 3, 1)] + public void Series_units_follow_count_mode(SeriesCountMode mode, int seasons, int expected) + { + var units = QuotaRules.GetUnits(Requests.Series(1, seasons), new AutoApproveOptions { SeriesCountMode = mode }); + + Assert.Equal(expected, units); + } + + [Fact] + public void Movies_always_count_as_one() + { + Assert.Equal(1, QuotaRules.GetUnits(Requests.Movie(1), new AutoApproveOptions())); + } +} diff --git a/tests/SeerrAutoApprove.Tests/RequestProcessorTests.cs b/tests/SeerrAutoApprove.Tests/RequestProcessorTests.cs new file mode 100644 index 0000000..f091993 --- /dev/null +++ b/tests/SeerrAutoApprove.Tests/RequestProcessorTests.cs @@ -0,0 +1,229 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using SeerrAutoApprove.Options; +using SeerrAutoApprove.Processing; +using SeerrAutoApprove.Quota; +using SeerrAutoApprove.Storage; + +namespace SeerrAutoApprove.Tests; + +public sealed class RequestProcessorTests : IDisposable +{ + private readonly TempDatabase _db = new(); + private readonly SqliteApprovalStore _store; + private readonly FakeSeerrClient _seerr = new(); + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 10, 15, 12, 0, 0, TimeSpan.Zero)); + + public RequestProcessorTests() + { + _store = _db.CreateStore(); + } + + public void Dispose() => _db.Dispose(); + + private RequestProcessor CreateProcessor(AutoApproveOptions options) => + new(_seerr, _store, new StaticOptionsMonitor(options), _time, NullLogger.Instance); + + [Fact] + public async Task Approves_movies_up_to_limit_and_leaves_rest_pending() + { + var requests = Enumerable.Range(0, 4).Select(i => Requests.Movie(userId: 1, createdAt: _time.GetUtcNow().AddMinutes(i))).ToList(); + _seerr.Pending.AddRange(requests); + + var result = await CreateProcessor(new AutoApproveOptions { MovieLimit = 2 }).ProcessAsync(CancellationToken.None); + + Assert.Equal([requests[0].Id, requests[1].Id], _seerr.Approved); + Assert.Equal(2, result.Approved); + Assert.Equal(2, result.Skipped); + } + + [Fact] + public async Task Approves_oldest_requests_first() + { + var newer = Requests.Movie(1, createdAt: _time.GetUtcNow()); + var older = Requests.Movie(1, createdAt: _time.GetUtcNow().AddHours(-1)); + _seerr.Pending.AddRange([newer, older]); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = 1 }).ProcessAsync(CancellationToken.None); + + Assert.Equal([older.Id], _seerr.Approved); + } + + [Fact] + public async Task Quotas_are_tracked_per_user() + { + var a1 = Requests.Movie(1); + var a2 = Requests.Movie(1); + var b1 = Requests.Movie(2); + _seerr.Pending.AddRange([a1, a2, b1]); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = 1 }).ProcessAsync(CancellationToken.None); + + Assert.Equal([a1.Id, b1.Id], _seerr.Approved); + } + + [Fact] + public async Task Movie_and_series_quotas_are_independent() + { + var movie = Requests.Movie(1); + var series = Requests.Series(1, seasons: 1); + _seerr.Pending.AddRange([movie, series]); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, SeriesLimit = 1 }).ProcessAsync(CancellationToken.None); + + Assert.Equal([movie.Id, series.Id], _seerr.Approved); + } + + [Fact] + public async Task Series_count_per_season_by_default() + { + var threeSeasons = Requests.Series(1, seasons: 3); + var twoSeasons = Requests.Series(1, seasons: 2); + _seerr.Pending.AddRange([threeSeasons, twoSeasons]); + + await CreateProcessor(new AutoApproveOptions { SeriesLimit = 2 }).ProcessAsync(CancellationToken.None); + + Assert.Equal([twoSeasons.Id], _seerr.Approved); + } + + [Fact] + public async Task Series_count_per_request_when_configured() + { + var threeSeasons = Requests.Series(1, seasons: 3); + var twoSeasons = Requests.Series(1, seasons: 2); + _seerr.Pending.AddRange([threeSeasons, twoSeasons]); + + await CreateProcessor(new AutoApproveOptions { SeriesLimit = 2, SeriesCountMode = SeriesCountMode.PerRequest }) + .ProcessAsync(CancellationToken.None); + + Assert.Equal([threeSeasons.Id, twoSeasons.Id], _seerr.Approved); + } + + [Fact] + public async Task FourK_requests_use_separate_limits() + { + var movie = Requests.Movie(1); + var movie4k = Requests.Movie(1, is4k: true); + var series4k = Requests.Series(1, seasons: 1, is4k: true); + _seerr.Pending.AddRange([movie, movie4k, series4k]); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, Movie4kLimit = 1, Series4kLimit = 0 }) + .ProcessAsync(CancellationToken.None); + + Assert.Equal([movie.Id, movie4k.Id], _seerr.Approved); + } + + [Fact] + public async Task Limit_zero_never_approves() + { + _seerr.Pending.Add(Requests.Movie(1)); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = 0 }).ProcessAsync(CancellationToken.None); + + Assert.Empty(_seerr.Approved); + } + + [Fact] + public async Task Limit_minus_one_is_unlimited() + { + _seerr.Pending.AddRange(Enumerable.Range(0, 25).Select(_ => Requests.Movie(1))); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = -1 }).ProcessAsync(CancellationToken.None); + + Assert.Equal(25, _seerr.Approved.Count); + } + + [Fact] + public async Task Previous_approvals_in_period_count_towards_quota() + { + await _store.RecordApprovalAsync(new ApprovalRecord(1, 1, QuotaCategory.Movie, 1, _time.GetUtcNow().AddDays(-5)), CancellationToken.None); + _seerr.Pending.AddRange([Requests.Movie(1), Requests.Movie(1)]); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = 2, RollingDays = 30 }).ProcessAsync(CancellationToken.None); + + Assert.Single(_seerr.Approved); + } + + [Fact] + public async Task Approvals_outside_rolling_window_do_not_count() + { + await _store.RecordApprovalAsync(new ApprovalRecord(1, 1, QuotaCategory.Movie, 1, _time.GetUtcNow().AddDays(-31)), CancellationToken.None); + _seerr.Pending.Add(Requests.Movie(1)); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, RollingDays = 30 }).ProcessAsync(CancellationToken.None); + + Assert.Single(_seerr.Approved); + } + + [Fact] + public async Task Approvals_in_previous_calendar_period_do_not_count() + { + // now = 2026-10-15; previous approval in September + await _store.RecordApprovalAsync(new ApprovalRecord(1, 1, QuotaCategory.Movie, 1, new DateTimeOffset(2026, 9, 30, 23, 0, 0, TimeSpan.Zero)), CancellationToken.None); + _seerr.Pending.Add(Requests.Movie(1)); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, PeriodType = PeriodType.Calendar, CalendarPeriod = CalendarPeriod.Month }) + .ProcessAsync(CancellationToken.None); + + Assert.Single(_seerr.Approved); + } + + [Fact] + public async Task Dry_run_does_not_approve_or_record() + { + _seerr.Pending.Add(Requests.Movie(1)); + + var result = await CreateProcessor(new AutoApproveOptions { MovieLimit = 1, DryRun = true }).ProcessAsync(CancellationToken.None); + + Assert.Equal(1, result.Approved); + Assert.Empty(_seerr.Approved); + Assert.Equal(0, await _store.GetUsedUnitsAsync(1, QuotaCategory.Movie, DateTimeOffset.MinValue, CancellationToken.None)); + } + + [Fact] + public async Task Failed_approval_is_not_recorded() + { + var failing = Requests.Movie(1); + var ok = Requests.Movie(1); + _seerr.Pending.AddRange([failing, ok]); + _seerr.FailingApprovals.Add(failing.Id); + + await CreateProcessor(new AutoApproveOptions { MovieLimit = 1 }).ProcessAsync(CancellationToken.None); + + Assert.Equal([ok.Id], _seerr.Approved); + } + + [Fact] + public async Task Skipped_requests_are_approved_later_when_reevaluation_enabled() + { + var first = Requests.Movie(1); + var second = Requests.Movie(1); + _seerr.Pending.AddRange([first, second]); + var processor = CreateProcessor(new AutoApproveOptions { MovieLimit = 1, RollingDays = 7, ReevaluateSkippedRequests = true }); + + await processor.ProcessAsync(CancellationToken.None); + Assert.Equal([first.Id], _seerr.Approved); + + _time.Advance(TimeSpan.FromDays(8)); + await processor.ProcessAsync(CancellationToken.None); + + Assert.Equal([first.Id, second.Id], _seerr.Approved); + } + + [Fact] + public async Task Skipped_requests_stay_pending_when_reevaluation_disabled() + { + var first = Requests.Movie(1); + var second = Requests.Movie(1); + _seerr.Pending.AddRange([first, second]); + var processor = CreateProcessor(new AutoApproveOptions { MovieLimit = 1, RollingDays = 7, ReevaluateSkippedRequests = false }); + + await processor.ProcessAsync(CancellationToken.None); + _time.Advance(TimeSpan.FromDays(8)); + var third = Requests.Movie(1); + _seerr.Pending.Add(third); + await processor.ProcessAsync(CancellationToken.None); + + Assert.Equal([first.Id, third.Id], _seerr.Approved); + } +} diff --git a/tests/SeerrAutoApprove.Tests/SeerrAutoApprove.Tests.csproj b/tests/SeerrAutoApprove.Tests/SeerrAutoApprove.Tests.csproj new file mode 100644 index 0000000..25dd413 --- /dev/null +++ b/tests/SeerrAutoApprove.Tests/SeerrAutoApprove.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/SeerrAutoApprove.Tests/SeerrClientTests.cs b/tests/SeerrAutoApprove.Tests/SeerrClientTests.cs new file mode 100644 index 0000000..edb7034 --- /dev/null +++ b/tests/SeerrAutoApprove.Tests/SeerrClientTests.cs @@ -0,0 +1,109 @@ +using System.Net; +using System.Text; +using SeerrAutoApprove.Seerr; + +namespace SeerrAutoApprove.Tests; + +public sealed class SeerrClientTests +{ + private sealed class StubHandler(Func respond) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(respond(request)); + } + } + + private sealed class StubFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => + new(handler, disposeHandler: false) { BaseAddress = new Uri("http://seerr.local/api/v1/") }; + } + + private static HttpResponseMessage Json(string json) => + new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + + [Fact] + public async Task Parses_pending_requests_across_pages() + { + const string page1 = """ + { + "pageInfo": { "pages": 2, "pageSize": 1, "results": 2, "page": 1 }, + "results": [ + { + "id": 11, "status": 1, "type": "movie", "is4k": false, + "createdAt": "2026-10-01T10:00:00.000Z", + "requestedBy": { "id": 3, "displayName": "Alice", "email": "alice@example.com" }, + "media": { "id": 5, "tmdbId": 603, "mediaType": "movie" }, + "seasons": [] + } + ] + } + """; + const string page2 = """ + { + "pageInfo": { "pages": 2, "pageSize": 1, "results": 2, "page": 2 }, + "results": [ + { + "id": 12, "status": 1, "type": "tv", "is4k": true, + "createdAt": "2026-10-02T10:00:00.000Z", + "requestedBy": { "id": 4, "displayName": "Bob" }, + "media": { "id": 6, "tmdbId": 1399, "mediaType": "tv" }, + "seasons": [ { "id": 1, "seasonNumber": 1 }, { "id": 2, "seasonNumber": 2 } ] + } + ] + } + """; + + var handler = new StubHandler(req => Json(req.RequestUri!.Query.Contains("skip=0") ? page1 : page2)); + var client = new SeerrClient(new StubFactory(handler)); + + var requests = await client.GetPendingRequestsAsync(CancellationToken.None); + + Assert.Equal(2, requests.Count); + Assert.Equal(11, requests[0].Id); + Assert.False(requests[0].IsSeries); + Assert.Equal(3, requests[0].RequestedBy!.Id); + Assert.Equal(12, requests[1].Id); + Assert.True(requests[1].IsSeries); + Assert.True(requests[1].Is4k); + Assert.Equal(2, requests[1].Seasons.Count); + Assert.All(handler.Requests, r => Assert.Contains("filter=pending", r.RequestUri!.Query)); + Assert.Equal("/api/v1/request", handler.Requests[0].RequestUri!.AbsolutePath); + } + + [Fact] + public async Task Returns_empty_list_when_nothing_pending() + { + var handler = new StubHandler(_ => Json("""{ "pageInfo": { "pages": 0, "page": 1, "results": 0 }, "results": [] }""")); + + var requests = await new SeerrClient(new StubFactory(handler)).GetPendingRequestsAsync(CancellationToken.None); + + Assert.Empty(requests); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Approve_posts_to_approve_endpoint() + { + var handler = new StubHandler(_ => Json("{}")); + + await new SeerrClient(new StubFactory(handler)).ApproveRequestAsync(42, CancellationToken.None); + + var request = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("/api/v1/request/42/approve", request.RequestUri!.AbsolutePath); + } + + [Fact] + public async Task Approve_throws_on_error_status() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.Conflict)); + + await Assert.ThrowsAsync(() => + new SeerrClient(new StubFactory(handler)).ApproveRequestAsync(42, CancellationToken.None)); + } +} diff --git a/tests/SeerrAutoApprove.Tests/TestDoubles.cs b/tests/SeerrAutoApprove.Tests/TestDoubles.cs new file mode 100644 index 0000000..10c2e86 --- /dev/null +++ b/tests/SeerrAutoApprove.Tests/TestDoubles.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Options; +using SeerrAutoApprove.Options; +using SeerrAutoApprove.Seerr; +using SeerrAutoApprove.Storage; + +namespace SeerrAutoApprove.Tests; + +internal sealed class FakeSeerrClient : ISeerrClient +{ + public List Pending { get; } = []; + public List Approved { get; } = []; + public HashSet FailingApprovals { get; } = []; + + public Task> GetPendingRequestsAsync(CancellationToken cancellationToken) => + Task.FromResult>(Pending.ToList()); + + public Task ApproveRequestAsync(int requestId, CancellationToken cancellationToken) + { + if (FailingApprovals.Contains(requestId)) + { + throw new HttpRequestException("approve failed"); + } + + Approved.Add(requestId); + Pending.RemoveAll(r => r.Id == requestId); + return Task.CompletedTask; + } +} + +internal sealed class TempDatabase : IDisposable +{ + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"seerr-autoapprove-{Guid.NewGuid():N}", "test.db"); + + public SqliteApprovalStore CreateStore() => + new(Microsoft.Extensions.Options.Options.Create(new AutoApproveOptions { DatabasePath = Path })); + + public void Dispose() + { + var dir = System.IO.Path.GetDirectoryName(Path)!; + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } +} + +internal sealed class StaticOptionsMonitor(T value) : IOptionsMonitor +{ + public T CurrentValue => value; + public T Get(string? name) => value; + public IDisposable? OnChange(Action listener) => null; +} + +internal static class Requests +{ + private static int _nextId = 1000; + + public static SeerrRequest Movie(int userId, bool is4k = false, DateTimeOffset? createdAt = null, int? id = null) => new() + { + Id = id ?? Interlocked.Increment(ref _nextId), + Status = 1, + Type = "movie", + Is4k = is4k, + CreatedAt = createdAt ?? DateTimeOffset.UnixEpoch, + RequestedBy = new SeerrUser { Id = userId, DisplayName = $"user{userId}" }, + }; + + public static SeerrRequest Series(int userId, int seasons, bool is4k = false, DateTimeOffset? createdAt = null, int? id = null) => new() + { + Id = id ?? Interlocked.Increment(ref _nextId), + Status = 1, + Type = "tv", + Is4k = is4k, + CreatedAt = createdAt ?? DateTimeOffset.UnixEpoch, + RequestedBy = new SeerrUser { Id = userId, DisplayName = $"user{userId}" }, + Seasons = Enumerable.Range(1, seasons).Select(n => new SeerrSeason { Id = n, SeasonNumber = n }).ToList(), + }; +}