This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/.vs/
|
||||
**/.vscode/
|
||||
**/*.user
|
||||
**/data/
|
||||
.git/
|
||||
.gitea/
|
||||
tests/
|
||||
.env
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
*.user
|
||||
TestResults/
|
||||
data/
|
||||
.env
|
||||
+24
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user