Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/Domain/Entities/ArrInstance.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Krautwatch.Domain.Enums;

namespace Krautwatch.Domain.Entities;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This is the opposite direction to <c>Krautwatch:ApiKey</c>, which is the key `*arr` apps use to call
/// <i>us</i>. Here <see cref="ApiKey"/> is <i>their</i> key, held so we can authenticate to them.
/// <para>
/// 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.
/// </para>
/// </remarks>
public class ArrInstance
{
public Guid Id { get; set; } = Guid.NewGuid();

/// <summary>Operator-facing label, so several instances of the same kind stay distinguishable.</summary>
public string Name { get; set; } = string.Empty;

public ArrKind Kind { get; set; }

/// <summary>
/// Absolute base URL, e.g. <c>http://sonarr:8989</c>. May include a path when the instance sits
/// behind a reverse proxy on a subpath.
/// </summary>
public string BaseUrl { get; set; } = string.Empty;

/// <summary>
/// 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.
/// </summary>
public string ApiKey { get; set; } = string.Empty;

/// <summary>Lets an instance be kept but skipped, rather than deleted and re-entered.</summary>
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; }

/// <summary>App version on success, or an actionable reason on failure.</summary>
public string? LastTestMessage { get; set; }
}
15 changes: 15 additions & 0 deletions src/Domain/Enums/ArrKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Krautwatch.Domain.Enums;

/// <summary>
/// Which `*arr` application an instance is, since the two drive different content and expose
/// different API shapes (Sonarr: series; Radarr: movies).
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
public enum ArrKind
{
Sonarr = 0,
Radarr = 1,
}
61 changes: 61 additions & 0 deletions src/Domain/Interfaces/IArr.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using Krautwatch.Domain.Entities;

namespace Krautwatch.Domain.Interfaces;

/// <summary>Persistence for configured Sonarr/Radarr instances.</summary>
public interface IArrInstanceRepository
{
Task<IReadOnlyList<ArrInstance>> GetAllAsync(CancellationToken ct = default);
Task<ArrInstance?> GetByIdAsync(Guid id, CancellationToken ct = default);

/// <summary>Enabled instances only — the work-list for outbound calls such as #6's monitored-series poll.</summary>
Task<IReadOnlyList<ArrInstance>> 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);
}

/// <summary>
/// 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.
/// </summary>
public interface IArrClient
{
Task<ArrConnectionResult> TestConnectionAsync(ArrInstance instance, CancellationToken ct = default);
}

/// <summary>
/// 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.
/// </summary>
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,

/// <summary>DNS/TCP/timeout — wrong host or port, or the instance is down.</summary>
Unreachable = 1,

/// <summary>TLS handshake or certificate rejection — common with self-signed certs.</summary>
TlsFailure = 2,

/// <summary>401/403 — reached it, but the API key is wrong.</summary>
Unauthorized = 3,

/// <summary>404 — reached a server but not the API; usually a reverse-proxy subpath left off the base URL.</summary>
ApiNotFound = 4,

/// <summary>200, but the response is not an `*arr` system-status payload — usually the wrong port entirely.</summary>
NotAnArrInstance = 5,

Unexpected = 99,
}
1 change: 1 addition & 0 deletions src/Infrastructure/InfrastructureServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public static IServiceCollection AddInfrastructure(
services.AddScoped<IProxyRepository, ProxyRepository>();

// Auth — local credential store + password hashing behind Domain ports (#48).
services.AddScoped<IArrInstanceRepository, ArrInstanceRepository>();
services.AddScoped<ILocalCredentialStore, LocalCredentialStore>();
services.AddSingleton<IPasswordHasher, IdentityPasswordHasher>();

Expand Down
22 changes: 22 additions & 0 deletions src/Infrastructure/Persistence/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<AppSettings> Settings => Set<AppSettings>();
public DbSet<Proxy> Proxies => Set<Proxy>();
public DbSet<AdminAccount> AdminAccounts => Set<AdminAccount>();
public DbSet<ArrInstance> ArrInstances => Set<ArrInstance>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
Expand Down Expand Up @@ -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<ArrInstance>(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<ArrKind>(v))
.HasMaxLength(20);

e.HasIndex(x => x.BaseUrl).IsUnique();
});

// --------------------------------------------------------
// TickerQ — job scheduler tables (TimeTickers, CronTickers, etc.)
// UseModelCustomizerForMigrations() is the alternative but we use
Expand Down
50 changes: 50 additions & 0 deletions src/Infrastructure/Persistence/ArrInstanceRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Krautwatch.Domain.Entities;
using Krautwatch.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;

namespace Krautwatch.Infrastructure.Persistence;

/// <summary>EF-backed store for configured Sonarr/Radarr instances.</summary>
/// <remarks>
/// Listings group by kind then name. Note that <c>Kind</c> 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.
/// </remarks>
public class ArrInstanceRepository(AppDbContext db) : IArrInstanceRepository
{
public async Task<IReadOnlyList<ArrInstance>> GetAllAsync(CancellationToken ct = default) =>
await db.ArrInstances
.OrderBy(i => i.Kind)
.ThenBy(i => i.Name)
.AsNoTracking()
.ToListAsync(ct);

public Task<ArrInstance?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
db.ArrInstances.FirstOrDefaultAsync(i => i.Id == id, ct);

public async Task<IReadOnlyList<ArrInstance>> 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);
}
}
Loading