diff --git a/src/Domain/Entities/ArrInstance.cs b/src/Domain/Entities/ArrInstance.cs
new file mode 100644
index 0000000..2475fb0
--- /dev/null
+++ b/src/Domain/Entities/ArrInstance.cs
@@ -0,0 +1,49 @@
+using Krautwatch.Domain.Enums;
+
+namespace Krautwatch.Domain.Entities;
+
+///
+/// A configured Sonarr or Radarr instance that Krautwatch calls **outbound** — to test connectivity and
+/// (per #6) to fetch the monitored-series list that becomes the crawl work-list.
+///
+///
+/// This is the opposite direction to Krautwatch:ApiKey, which is the key `*arr` apps use to call
+/// us. Here is their key, held so we can authenticate to them.
+///
+/// The last-test fields are a cache of the most recent connectivity check so the UI can show state
+/// without re-probing every instance on each page load.
+///
+///
+public class ArrInstance
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ /// Operator-facing label, so several instances of the same kind stay distinguishable.
+ public string Name { get; set; } = string.Empty;
+
+ public ArrKind Kind { get; set; }
+
+ ///
+ /// Absolute base URL, e.g. http://sonarr:8989. May include a path when the instance sits
+ /// behind a reverse proxy on a subpath.
+ ///
+ public string BaseUrl { get; set; } = string.Empty;
+
+ ///
+ /// The instance's API key. A credential: never returned by read models — query DTOs carry a masked
+ /// form only, so the UI cannot be used to harvest keys.
+ ///
+ public string ApiKey { get; set; } = string.Empty;
+
+ /// Lets an instance be kept but skipped, rather than deleted and re-entered.
+ public bool Enabled { get; set; } = true;
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ // ── Cached outcome of the last connectivity test ──────────
+ public DateTimeOffset? LastTestedAt { get; set; }
+ public bool? LastTestOk { get; set; }
+
+ /// App version on success, or an actionable reason on failure.
+ public string? LastTestMessage { get; set; }
+}
diff --git a/src/Domain/Enums/ArrKind.cs b/src/Domain/Enums/ArrKind.cs
new file mode 100644
index 0000000..40230e0
--- /dev/null
+++ b/src/Domain/Enums/ArrKind.cs
@@ -0,0 +1,15 @@
+namespace Krautwatch.Domain.Enums;
+
+///
+/// Which `*arr` application an instance is, since the two drive different content and expose
+/// different API shapes (Sonarr: series; Radarr: movies).
+///
+///
+/// Prowlarr is deliberately absent: it is configured *pointing at* Krautwatch as an indexer, so there
+/// is nothing for us to call outbound and no instance record to keep (DR-010).
+///
+public enum ArrKind
+{
+ Sonarr = 0,
+ Radarr = 1,
+}
diff --git a/src/Domain/Interfaces/IArr.cs b/src/Domain/Interfaces/IArr.cs
new file mode 100644
index 0000000..cb9fce2
--- /dev/null
+++ b/src/Domain/Interfaces/IArr.cs
@@ -0,0 +1,61 @@
+using Krautwatch.Domain.Entities;
+
+namespace Krautwatch.Domain.Interfaces;
+
+/// Persistence for configured Sonarr/Radarr instances.
+public interface IArrInstanceRepository
+{
+ Task> GetAllAsync(CancellationToken ct = default);
+ Task GetByIdAsync(Guid id, CancellationToken ct = default);
+
+ /// Enabled instances only — the work-list for outbound calls such as #6's monitored-series poll.
+ Task> GetEnabledAsync(CancellationToken ct = default);
+
+ Task AddAsync(ArrInstance instance, CancellationToken ct = default);
+ Task UpdateAsync(ArrInstance instance, CancellationToken ct = default);
+ Task DeleteAsync(Guid id, CancellationToken ct = default);
+}
+
+///
+/// Outbound HTTP boundary to a Sonarr/Radarr instance. Starts with connectivity checking; #6 extends the
+/// same port with the monitored-series fetch, which is why it is a port rather than a UI helper.
+///
+public interface IArrClient
+{
+ Task TestConnectionAsync(ArrInstance instance, CancellationToken ct = default);
+}
+
+///
+/// Outcome of a connectivity test. Failure modes are distinguished deliberately: "it doesn't work" is
+/// the single most common self-hosting complaint, and the operator can only act on a specific cause.
+///
+public record ArrConnectionResult(bool Ok, ArrConnectionFailure Failure, string Message)
+{
+ public static ArrConnectionResult Success(string appName, string version) =>
+ new(true, ArrConnectionFailure.None, $"{appName} {version}");
+
+ public static ArrConnectionResult Fail(ArrConnectionFailure failure, string message) =>
+ new(false, failure, message);
+}
+
+public enum ArrConnectionFailure
+{
+ None = 0,
+
+ /// DNS/TCP/timeout — wrong host or port, or the instance is down.
+ Unreachable = 1,
+
+ /// TLS handshake or certificate rejection — common with self-signed certs.
+ TlsFailure = 2,
+
+ /// 401/403 — reached it, but the API key is wrong.
+ Unauthorized = 3,
+
+ /// 404 — reached a server but not the API; usually a reverse-proxy subpath left off the base URL.
+ ApiNotFound = 4,
+
+ /// 200, but the response is not an `*arr` system-status payload — usually the wrong port entirely.
+ NotAnArrInstance = 5,
+
+ Unexpected = 99,
+}
diff --git a/src/Infrastructure/InfrastructureServiceExtensions.cs b/src/Infrastructure/InfrastructureServiceExtensions.cs
index 245fdc6..af8d9bd 100644
--- a/src/Infrastructure/InfrastructureServiceExtensions.cs
+++ b/src/Infrastructure/InfrastructureServiceExtensions.cs
@@ -60,6 +60,7 @@ public static IServiceCollection AddInfrastructure(
services.AddScoped();
// Auth — local credential store + password hashing behind Domain ports (#48).
+ services.AddScoped();
services.AddScoped();
services.AddSingleton();
diff --git a/src/Infrastructure/Persistence/AppDbContext.cs b/src/Infrastructure/Persistence/AppDbContext.cs
index ff03c87..e6309f6 100644
--- a/src/Infrastructure/Persistence/AppDbContext.cs
+++ b/src/Infrastructure/Persistence/AppDbContext.cs
@@ -15,6 +15,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op
public DbSet Settings => Set();
public DbSet Proxies => Set();
public DbSet AdminAccounts => Set();
+ public DbSet ArrInstances => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -217,6 +218,27 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
e.Property(x => x.PasswordHash).IsRequired().HasMaxLength(500);
});
+ // --------------------------------------------------------
+ // ArrInstance — configured Sonarr/Radarr instances we call OUTBOUND (#4).
+ // BaseUrl is uniquely indexed: #5 bootstraps instances from env vars and matches them by base
+ // URL, so duplicates have to be impossible at the schema level rather than by convention.
+ // --------------------------------------------------------
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+ e.Property(x => x.Name).IsRequired().HasMaxLength(100);
+ e.Property(x => x.BaseUrl).IsRequired().HasMaxLength(500);
+ e.Property(x => x.ApiKey).IsRequired().HasMaxLength(200);
+ e.Property(x => x.LastTestMessage).HasMaxLength(500);
+
+ // Stored as text like the other enums, so the column stays readable in the database.
+ e.Property(x => x.Kind)
+ .HasConversion(v => v.ToString(), v => Enum.Parse(v))
+ .HasMaxLength(20);
+
+ e.HasIndex(x => x.BaseUrl).IsUnique();
+ });
+
// --------------------------------------------------------
// TickerQ — job scheduler tables (TimeTickers, CronTickers, etc.)
// UseModelCustomizerForMigrations() is the alternative but we use
diff --git a/src/Infrastructure/Persistence/ArrInstanceRepository.cs b/src/Infrastructure/Persistence/ArrInstanceRepository.cs
new file mode 100644
index 0000000..9b0d4ec
--- /dev/null
+++ b/src/Infrastructure/Persistence/ArrInstanceRepository.cs
@@ -0,0 +1,50 @@
+using Krautwatch.Domain.Entities;
+using Krautwatch.Domain.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace Krautwatch.Infrastructure.Persistence;
+
+/// EF-backed store for configured Sonarr/Radarr instances.
+///
+/// Listings group by kind then name. Note that Kind is persisted as text, so SQL orders it
+/// alphabetically by name (Radarr before Sonarr) rather than by enum value — stable and fine for
+/// display, but not the enum's declaration order.
+///
+public class ArrInstanceRepository(AppDbContext db) : IArrInstanceRepository
+{
+ public async Task> GetAllAsync(CancellationToken ct = default) =>
+ await db.ArrInstances
+ .OrderBy(i => i.Kind)
+ .ThenBy(i => i.Name)
+ .AsNoTracking()
+ .ToListAsync(ct);
+
+ public Task GetByIdAsync(Guid id, CancellationToken ct = default) =>
+ db.ArrInstances.FirstOrDefaultAsync(i => i.Id == id, ct);
+
+ public async Task> GetEnabledAsync(CancellationToken ct = default) =>
+ await db.ArrInstances
+ .Where(i => i.Enabled)
+ .OrderBy(i => i.Kind)
+ .ThenBy(i => i.Name)
+ .AsNoTracking()
+ .ToListAsync(ct);
+
+ public async Task AddAsync(ArrInstance instance, CancellationToken ct = default)
+ {
+ db.ArrInstances.Add(instance);
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task UpdateAsync(ArrInstance instance, CancellationToken ct = default)
+ {
+ db.ArrInstances.Update(instance);
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
+ {
+ // ExecuteDelete so removal doesn't need the entity loaded, and is a no-op if it's already gone.
+ await db.ArrInstances.Where(i => i.Id == id).ExecuteDeleteAsync(ct);
+ }
+}
diff --git a/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.Designer.cs b/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.Designer.cs
new file mode 100644
index 0000000..2f0ced9
--- /dev/null
+++ b/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.Designer.cs
@@ -0,0 +1,479 @@
+//
+using System;
+using Krautwatch.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Krautwatch.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260727080445_AddArrInstances")]
+ partial class AddArrInstances
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.AdminAccount", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.ToTable("AdminAccounts");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.AppSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CatalogProviderKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CatalogRefreshIntervalHours")
+ .HasColumnType("integer");
+
+ b.Property("DownloadDirectory")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("MaxConcurrentDownloads")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Settings");
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ CatalogProviderKey = "mediathekview",
+ CatalogRefreshIntervalHours = 6,
+ DownloadDirectory = "/downloads",
+ MaxConcurrentDownloads = 2
+ });
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.ArrInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApiKey")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BaseUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("LastTestMessage")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("LastTestOk")
+ .HasColumnType("boolean");
+
+ b.Property("LastTestedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BaseUrl")
+ .IsUnique();
+
+ b.ToTable("ArrInstances");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.Channel", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ProviderKey")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.DownloadJob", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CompletedAt")
+ .HasColumnType("text");
+
+ b.Property("ContentLengthBytes")
+ .HasColumnType("bigint");
+
+ b.Property("CreatedAt")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EpisodeId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("FileSizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("GeoRestricted")
+ .HasColumnType("boolean");
+
+ b.Property("OutputPath")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("ProgressPercent")
+ .HasColumnType("double precision");
+
+ b.Property("Quality")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("StartedAt")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("StreamType")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("StreamUrl")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("TempPath")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("WorkerId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("DownloadJobs");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.Episode", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AbsoluteEpisodeNumber")
+ .HasColumnType("integer");
+
+ b.Property("AvailableUntil")
+ .HasColumnType("text");
+
+ b.Property("BroadcastDate")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Description")
+ .HasMaxLength(5000)
+ .HasColumnType("character varying(5000)");
+
+ b.Property("Duration")
+ .HasColumnType("double precision");
+
+ b.Property("EpisodeNumber")
+ .HasColumnType("integer");
+
+ b.Property("GeoRestricted")
+ .HasColumnType("boolean");
+
+ b.Property("SeasonNumber")
+ .HasColumnType("integer");
+
+ b.Property("ShowId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BroadcastDate");
+
+ b.HasIndex("ContentType");
+
+ b.HasIndex("ShowId");
+
+ b.HasIndex("ShowId", "SeasonNumber", "EpisodeNumber");
+
+ b.ToTable("Episodes");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.EpisodeStream", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("EpisodeId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Format")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("Quality")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.ToTable("EpisodeStreams");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.Proxy", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("AnonymityLevel")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Country")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("CreatedAt")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("LastProbeOk")
+ .HasColumnType("boolean");
+
+ b.Property("LastProbedAt")
+ .HasColumnType("text");
+
+ b.Property("Latency")
+ .HasColumnType("double precision");
+
+ b.Property("Port")
+ .HasColumnType("integer");
+
+ b.Property("Protocol")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("ResponseTime")
+ .HasColumnType("integer");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("SourceLastChecked")
+ .HasColumnType("text");
+
+ b.Property("Speed")
+ .HasColumnType("integer");
+
+ b.Property("UpTime")
+ .HasColumnType("double precision");
+
+ b.Property("UpdatedAt")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("VerifiedEgressCountry")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Country");
+
+ b.ToTable("Proxies");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.Show", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ChannelId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SeriesType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("TvdbId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId");
+
+ b.ToTable("Shows");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.DownloadJob", b =>
+ {
+ b.HasOne("Krautwatch.Domain.Entities.Episode", "Episode")
+ .WithMany()
+ .HasForeignKey("EpisodeId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Episode");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.Episode", b =>
+ {
+ b.HasOne("Krautwatch.Domain.Entities.Show", "Show")
+ .WithMany("Episodes")
+ .HasForeignKey("ShowId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Show");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.EpisodeStream", b =>
+ {
+ b.HasOne("Krautwatch.Domain.Entities.Episode", null)
+ .WithMany("Streams")
+ .HasForeignKey("EpisodeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.Show", b =>
+ {
+ b.HasOne("Krautwatch.Domain.Entities.Channel", "Channel")
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Channel");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.Episode", b =>
+ {
+ b.Navigation("Streams");
+ });
+
+ modelBuilder.Entity("Krautwatch.Domain.Entities.Show", b =>
+ {
+ b.Navigation("Episodes");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.cs b/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.cs
new file mode 100644
index 0000000..6d9925e
--- /dev/null
+++ b/src/Infrastructure/Persistence/Migrations/20260727080445_AddArrInstances.cs
@@ -0,0 +1,48 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Krautwatch.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddArrInstances : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "ArrInstances",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ Kind = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ BaseUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false),
+ ApiKey = table.Column(type: "character varying(200)", maxLength: 200, nullable: false),
+ Enabled = table.Column(type: "boolean", nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ LastTestedAt = table.Column(type: "timestamp with time zone", nullable: true),
+ LastTestOk = table.Column(type: "boolean", nullable: true),
+ LastTestMessage = table.Column(type: "character varying(500)", maxLength: 500, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ArrInstances", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ArrInstances_BaseUrl",
+ table: "ArrInstances",
+ column: "BaseUrl",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ArrInstances");
+ }
+ }
+}
diff --git a/src/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs
index ccf850d..81942ab 100644
--- a/src/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs
@@ -86,6 +86,56 @@ protected override void BuildModel(ModelBuilder modelBuilder)
});
});
+ modelBuilder.Entity("Krautwatch.Domain.Entities.ArrInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApiKey")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BaseUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("LastTestMessage")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("LastTestOk")
+ .HasColumnType("boolean");
+
+ b.Property("LastTestedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BaseUrl")
+ .IsUnique();
+
+ b.ToTable("ArrInstances");
+ });
+
modelBuilder.Entity("Krautwatch.Domain.Entities.Channel", b =>
{
b.Property("Id")
diff --git a/tests/Infrastructure.Tests/ArrInstanceRepositoryTests.cs b/tests/Infrastructure.Tests/ArrInstanceRepositoryTests.cs
new file mode 100644
index 0000000..0214a4a
--- /dev/null
+++ b/tests/Infrastructure.Tests/ArrInstanceRepositoryTests.cs
@@ -0,0 +1,143 @@
+using Krautwatch.Domain.Entities;
+using Krautwatch.Domain.Enums;
+using Krautwatch.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+using Xunit;
+
+namespace Krautwatch.Infrastructure.Tests;
+
+[Collection(PostgresCollection.Name)]
+public class ArrInstanceRepositoryTests(PostgresFixture postgres) : IAsyncLifetime
+{
+ private DbContextOptions _options = null!;
+
+ public async ValueTask InitializeAsync() => _options = await postgres.CreateDatabaseAsync();
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ private ArrInstanceRepository Repo() => new(new AppDbContext(_options));
+
+ private static ArrInstance Instance(
+ string name = "Sonarr",
+ ArrKind kind = ArrKind.Sonarr,
+ string baseUrl = "http://sonarr:8989",
+ bool enabled = true) => new()
+ {
+ Name = name,
+ Kind = kind,
+ BaseUrl = baseUrl,
+ ApiKey = "key-abc123",
+ Enabled = enabled,
+ };
+
+ [Fact]
+ public async Task Adds_then_reads_back_an_instance()
+ {
+ var instance = Instance();
+ await Repo().AddAsync(instance, TestContext.Current.CancellationToken);
+
+ var found = await Repo().GetByIdAsync(instance.Id, TestContext.Current.CancellationToken);
+
+ found.ShouldNotBeNull();
+ found.Name.ShouldBe("Sonarr");
+ found.Kind.ShouldBe(ArrKind.Sonarr);
+ found.BaseUrl.ShouldBe("http://sonarr:8989");
+ found.ApiKey.ShouldBe("key-abc123");
+ found.Enabled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Kind_round_trips_as_text()
+ {
+ // Stored as text rather than an int so the column stays readable in the database.
+ await Repo().AddAsync(Instance(kind: ArrKind.Radarr, baseUrl: "http://radarr:7878"),
+ TestContext.Current.CancellationToken);
+
+ var all = await Repo().GetAllAsync(TestContext.Current.CancellationToken);
+
+ all.ShouldHaveSingleItem().Kind.ShouldBe(ArrKind.Radarr);
+ }
+
+ [Fact]
+ public async Task Duplicate_base_urls_are_rejected_by_the_schema()
+ {
+ // #5 bootstraps instances from env vars and matches on base URL, so duplicates must be
+ // impossible at the schema level rather than prevented by convention.
+ await Repo().AddAsync(Instance(baseUrl: "http://sonarr:8989"), TestContext.Current.CancellationToken);
+
+ await Should.ThrowAsync(async () =>
+ await Repo().AddAsync(
+ Instance(name: "Duplicate", baseUrl: "http://sonarr:8989"),
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task GetEnabled_skips_disabled_instances()
+ {
+ await Repo().AddAsync(Instance(name: "On", baseUrl: "http://on:8989"),
+ TestContext.Current.CancellationToken);
+ await Repo().AddAsync(Instance(name: "Off", baseUrl: "http://off:8989", enabled: false),
+ TestContext.Current.CancellationToken);
+
+ var enabled = await Repo().GetEnabledAsync(TestContext.Current.CancellationToken);
+
+ enabled.ShouldHaveSingleItem().Name.ShouldBe("On");
+ (await Repo().GetAllAsync(TestContext.Current.CancellationToken)).Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task Updates_the_cached_test_outcome()
+ {
+ var instance = Instance();
+ await Repo().AddAsync(instance, TestContext.Current.CancellationToken);
+
+ var loaded = await Repo().GetByIdAsync(instance.Id, TestContext.Current.CancellationToken);
+ loaded!.LastTestOk = true;
+ loaded.LastTestMessage = "Sonarr 4.0.10";
+ loaded.LastTestedAt = DateTimeOffset.UtcNow;
+ await Repo().UpdateAsync(loaded, TestContext.Current.CancellationToken);
+
+ var reread = await Repo().GetByIdAsync(instance.Id, TestContext.Current.CancellationToken);
+ reread!.LastTestOk.ShouldBe(true);
+ reread.LastTestMessage.ShouldBe("Sonarr 4.0.10");
+ reread.LastTestedAt.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Deletes_an_instance()
+ {
+ var instance = Instance();
+ await Repo().AddAsync(instance, TestContext.Current.CancellationToken);
+
+ await Repo().DeleteAsync(instance.Id, TestContext.Current.CancellationToken);
+
+ (await Repo().GetByIdAsync(instance.Id, TestContext.Current.CancellationToken)).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Deleting_something_absent_is_a_no_op()
+ {
+ await Repo().DeleteAsync(Guid.NewGuid(), TestContext.Current.CancellationToken);
+
+ (await Repo().GetAllAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Orders_by_kind_then_name()
+ {
+ await Repo().AddAsync(Instance(name: "Zeta", ArrKind.Sonarr, "http://z:8989"),
+ TestContext.Current.CancellationToken);
+ await Repo().AddAsync(Instance(name: "Movies", ArrKind.Radarr, "http://m:7878"),
+ TestContext.Current.CancellationToken);
+ await Repo().AddAsync(Instance(name: "Alpha", ArrKind.Sonarr, "http://a:8989"),
+ TestContext.Current.CancellationToken);
+
+ var all = await Repo().GetAllAsync(TestContext.Current.CancellationToken);
+
+ // Grouped by kind, then alphabetical within a kind. Kind is persisted as TEXT, so SQL sorts it
+ // alphabetically ("Radarr" < "Sonarr") rather than by enum value — asserted here so the
+ // behaviour is pinned rather than assumed.
+ all.Select(i => i.Name).ShouldBe(["Movies", "Alpha", "Zeta"]);
+ }
+}