first commit
Build and Push Docker Image / build-and-push (push) Successful in 1m45s

This commit is contained in:
Arne Moerman
2026-10-06 16:54:39 +02:00
commit de189d0c47
27 changed files with 1556 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
**/bin/
**/obj/
**/.vs/
**/.vscode/
**/*.user
**/data/
.git/
.gitea/
tests/
.env
+35
View File
@@ -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
+44
View File
@@ -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 }}
+7
View File
@@ -0,0 +1,7 @@
bin/
obj/
.vs/
*.user
TestResults/
data/
.env
+24
View File
@@ -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"]
+106
View File
@@ -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://<host>: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.
+8
View File
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/SeerrAutoApprove/SeerrAutoApprove.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SeerrAutoApprove.Tests/SeerrAutoApprove.Tests.csproj" />
</Folder>
</Solution>
+10
View File
@@ -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
@@ -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";
/// <summary>-1 = unlimited, 0 = never auto-approve.</summary>
[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;
/// <summary>
/// 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.
/// </summary>
public bool ReevaluateSkippedRequests { get; set; } = true;
[Range(10, 86400)]
public int PollIntervalSeconds { get; set; } = 300;
public bool DryRun { get; set; }
/// <summary>Expected value of the Authorization header sent by the Seerr webhook. Empty = no auth.</summary>
public string? WebhookSecret { get; set; }
[Required]
public string DatabasePath { get; set; } = "data/autoapprove.db";
}
@@ -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;
}
@@ -0,0 +1,58 @@
using Microsoft.Extensions.Options;
using SeerrAutoApprove.Options;
namespace SeerrAutoApprove.Processing;
public sealed class AutoApproveWorker(
RequestProcessor processor,
ProcessingTrigger trigger,
IOptionsMonitor<AutoApproveOptions> options,
ILogger<AutoApproveWorker> 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;
}
}
}
}
@@ -0,0 +1,15 @@
using System.Threading.Channels;
namespace SeerrAutoApprove.Processing;
/// <summary>Coalesces run requests (e.g. from webhooks) into a single pending signal.</summary>
public sealed class ProcessingTrigger
{
private readonly Channel<bool> _channel = Channel.CreateBounded<bool>(
new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
public void Request() => _channel.Writer.TryWrite(true);
public async Task WaitAsync(CancellationToken cancellationToken) =>
await _channel.Reader.ReadAsync(cancellationToken);
}
@@ -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<AutoApproveOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<RequestProcessor> logger)
{
private readonly SemaphoreSlim _lock = new(1, 1);
public async Task<ProcessingResult> ProcessAsync(CancellationToken cancellationToken)
{
await _lock.WaitAsync(cancellationToken);
try
{
return await ProcessCoreAsync(cancellationToken);
}
finally
{
_lock.Release();
}
}
private async Task<ProcessingResult> 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<int>()
: 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();
}
+65
View File
@@ -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<SeerrOptions>()
.BindConfiguration(SeerrOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddOptions<AutoApproveOptions>()
.BindConfiguration(AutoApproveOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddHttpClient(SeerrClient.HttpClientName, (sp, client) =>
{
var seerr = sp.GetRequiredService<IOptions<SeerrOptions>>().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<ISeerrClient, SeerrClient>();
builder.Services.AddSingleton<IApprovalStore, SqliteApprovalStore>();
builder.Services.AddSingleton<RequestProcessor>();
builder.Services.AddSingleton<ProcessingTrigger>();
builder.Services.AddHostedService<AutoApproveWorker>();
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<AutoApproveOptions> 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;
+67
View File
@@ -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));
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Net.Http.Json;
namespace SeerrAutoApprove.Seerr;
public interface ISeerrClient
{
Task<IReadOnlyList<SeerrRequest>> 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<IReadOnlyList<SeerrRequest>> GetPendingRequestsAsync(CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient(HttpClientName);
var results = new List<SeerrRequest>();
var skip = 0;
while (true)
{
var page = await client.GetFromJsonAsync<SeerrRequestPage>(
$"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();
}
}
+49
View File
@@ -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<SeerrRequest> 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<SeerrSeason> 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; }
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SeerrAutoApprove.Tests" />
</ItemGroup>
</Project>
@@ -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<int> GetUsedUnitsAsync(int userId, QuotaCategory category, DateTimeOffset since, CancellationToken cancellationToken);
Task RecordApprovalAsync(ApprovalRecord record, CancellationToken cancellationToken);
Task<IReadOnlySet<int>> GetSkippedRequestIdsAsync(CancellationToken cancellationToken);
Task MarkSkippedAsync(int requestId, DateTimeOffset skippedAt, CancellationToken cancellationToken);
}
public sealed class SqliteApprovalStore : IApprovalStore
{
private readonly string _connectionString;
public SqliteApprovalStore(IOptions<AutoApproveOptions> 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<int> 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<IReadOnlySet<int>> 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<int>();
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;
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
}
+29
View File
@@ -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"
}
}
@@ -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<Program> CreateFactory(string? webhookSecret) =>
new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Seerr:BaseUrl"] = "http://seerr.invalid",
["Seerr:ApiKey"] = "test-key",
["AutoApprove:DatabasePath"] = _db.Path,
["AutoApprove:WebhookSecret"] = webhookSecret,
}));
builder.ConfigureServices(services => services.Replace(ServiceDescriptor.Singleton<ISeerrClient, FakeSeerrClient>()));
});
[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);
}
}
@@ -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()));
}
}
@@ -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<AutoApproveOptions>(options), _time, NullLogger<RequestProcessor>.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);
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SeerrAutoApprove\SeerrAutoApprove.csproj" />
</ItemGroup>
</Project>
@@ -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<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = [];
protected override Task<HttpResponseMessage> 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<HttpRequestException>(() =>
new SeerrClient(new StubFactory(handler)).ApproveRequestAsync(42, CancellationToken.None));
}
}
@@ -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<SeerrRequest> Pending { get; } = [];
public List<int> Approved { get; } = [];
public HashSet<int> FailingApprovals { get; } = [];
public Task<IReadOnlyList<SeerrRequest>> GetPendingRequestsAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<SeerrRequest>>(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>(T value) : IOptionsMonitor<T>
{
public T CurrentValue => value;
public T Get(string? name) => value;
public IDisposable? OnChange(Action<T, string?> 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(),
};
}