diff --git a/ErsatzTV.Application/Channels/Commands/ChannelValidations.cs b/ErsatzTV.Application/Channels/Commands/ChannelValidations.cs
new file mode 100644
index 000000000..2aaf5745f
--- /dev/null
+++ b/ErsatzTV.Application/Channels/Commands/ChannelValidations.cs
@@ -0,0 +1,60 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Application.Channels;
+
+///
+/// Validation rules shared by and
+/// . These were previously enforced only by the Blazor page
+/// (ChannelEditViewModelValidator); porting them into the handlers makes them apply to
+/// the REST API as well. Failures map to HTTP 422.
+///
+internal static class ChannelValidations
+{
+ ///
+ /// A channel must belong to a non-empty group; the value is used as the M3U
+ /// group-title. Mirrors the Blazor rule RuleFor(x => x.Group).NotEmpty().
+ ///
+ internal static Validation ValidateGroup(string group)
+ {
+ // Use explicit returns (not a ternary): BaseError has an implicit string conversion, so a
+ // ternary would collapse both branches to BaseError and always produce a Fail.
+ if (string.IsNullOrWhiteSpace(group))
+ {
+ return BaseError.New("Channel group is required");
+ }
+
+ return group;
+ }
+
+ /// A disabled channel may not be shown in the EPG.
+ internal static Validation ValidateShowInEpg(bool isEnabled, bool showInEpg)
+ {
+ if (!isEnabled && showInEpg)
+ {
+ return BaseError.New("Disabled channels cannot be shown in EPG");
+ }
+
+ return showInEpg;
+ }
+
+ ///
+ /// A logo path that is an absolute URI must be a valid external (http/https) url.
+ /// Relative/local logo paths and empty values are allowed.
+ ///
+ internal static Validation ValidateLogo(string logoPath)
+ {
+ if (string.IsNullOrWhiteSpace(logoPath))
+ {
+ return string.Empty;
+ }
+
+ bool isAbsoluteUri = Uri.TryCreate(logoPath, UriKind.Absolute, out _);
+ if (isAbsoluteUri && !Artwork.IsExternalUrl(logoPath))
+ {
+ return BaseError.New("External logo url is invalid");
+ }
+
+ return logoPath;
+ }
+}
diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
index dac4b9b56..98909e1be 100644
--- a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
+++ b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
+using static ErsatzTV.Application.Channels.ChannelValidations;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
@@ -36,18 +37,23 @@ public class CreateChannelHandler(
return new CreateChannelResult(channel.Id);
}
- private static async Task> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) =>
- (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
+ private static async Task> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken)
+ {
+ Validation channelValidation = (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
- await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
+ await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
+ ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
+ ValidateLogo(request.Logo?.Path))
.Apply((
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId,
+ _,
+ _,
_) =>
{
var artwork = new List();
@@ -125,6 +131,11 @@ public class CreateChannelHandler(
return channel;
});
+ // combine the page-only Group rule with the channel validation (keeps tuple arity within
+ // LanguageExt's supported applicative range while still accumulating all errors)
+ return (ValidateGroup(request.Group), channelValidation).Apply((_, channel) => channel);
+ }
+
private static Validation ValidateName(CreateChannel createChannel) =>
createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
@@ -149,16 +160,20 @@ public class CreateChannelHandler(
});
}
- private static Task> FFmpegProfileMustExist(
+ private static async Task> FFmpegProfileMustExist(
TvContext dbContext,
CreateChannel createChannel,
- CancellationToken cancellationToken) =>
- dbContext.FFmpegProfiles
- .CountAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken)
- .Map(Optional)
- .Filter(c => c > 0)
- .MapT(_ => createChannel.FFmpegProfileId)
- .Map(o => o.ToValidation($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."));
+ CancellationToken cancellationToken)
+ {
+ bool exists = await dbContext.FFmpegProfiles
+ .AnyAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken);
+ if (exists)
+ {
+ return createChannel.FFmpegProfileId;
+ }
+
+ return BaseError.New($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.");
+ }
private static async Task>> WatermarkMustExist(
TvContext dbContext,
@@ -170,12 +185,14 @@ public class CreateChannelHandler(
return Option.None;
}
- return await dbContext.ChannelWatermarks
- .CountAsync(w => w.Id == createChannel.WatermarkId, cancellationToken)
- .Map(Optional)
- .Filter(c => c > 0)
- .MapT(_ => Optional(createChannel.WatermarkId))
- .Map(o => o.ToValidation($"Watermark {createChannel.WatermarkId} does not exist."));
+ bool exists = await dbContext.ChannelWatermarks
+ .AnyAsync(w => w.Id == createChannel.WatermarkId, cancellationToken);
+ if (exists)
+ {
+ return Optional(createChannel.WatermarkId);
+ }
+
+ return BaseError.New($"Watermark {createChannel.WatermarkId} does not exist.");
}
private static async Task>> FillerPresetMustExist(
@@ -188,14 +205,15 @@ public class CreateChannelHandler(
return Option.None;
}
- return await dbContext.FillerPresets
+ bool exists = await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
- .CountAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken)
- .Map(Optional)
- .Filter(c => c > 0)
- .MapT(_ => Optional(createChannel.FallbackFillerId))
- .Map(o => o.ToValidation(
- $"Fallback filler {createChannel.FallbackFillerId} does not exist."));
+ .AnyAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken);
+ if (exists)
+ {
+ return Optional(createChannel.FallbackFillerId);
+ }
+
+ return BaseError.New($"Fallback filler {createChannel.FallbackFillerId} does not exist.");
}
private static async Task> MirrorSourceMustBeValid(
diff --git a/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs
index a91ca1706..0363cecab 100644
--- a/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs
+++ b/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs
@@ -1,6 +1,7 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
+using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -31,8 +32,17 @@ public class DeleteChannelHandler : IRequestHandler> Handle(DeleteChannel request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
- Validation validation = await ChannelMustExist(dbContext, request, cancellationToken);
- return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
+ Option maybeChannel = await dbContext.Channels
+ .SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
+
+ return await maybeChannel.Match(
+ Some: async channel =>
+ {
+ await DoDeletion(dbContext, channel, cancellationToken);
+ return Right(Unit.Default);
+ },
+ None: () => Task.FromResult(
+ Left(new NotFoundError($"Channel {request.ChannelId} does not exist."))));
}
private async Task DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
@@ -54,14 +64,4 @@ public class DeleteChannelHandler : IRequestHandler> ChannelMustExist(
- TvContext dbContext,
- DeleteChannel deleteChannel,
- CancellationToken cancellationToken)
- {
- Option maybeChannel = await dbContext.Channels
- .SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId, cancellationToken);
- return maybeChannel.ToValidation($"Channel {deleteChannel.ChannelId} does not exist.");
- }
}
diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
index d3a7f35b5..bf3039a4e 100644
--- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
+++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
@@ -4,10 +4,13 @@ using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Domain.Filler;
+using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
+using static ErsatzTV.Application.Channels.ChannelValidations;
using static ErsatzTV.Application.Channels.Mapper;
using Channel = ErsatzTV.Core.Domain.Channel;
@@ -24,8 +27,23 @@ public class UpdateChannelHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
- Validation validation = await Validate(dbContext, request, cancellationToken);
- return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
+
+ Option maybeChannel = await dbContext.Channels
+ .Include(c => c.Artwork)
+ .Include(c => c.Watermark)
+ .Include(c => c.Playouts)
+ .SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
+
+ return await maybeChannel.Match(
+ Some: async channel =>
+ {
+ Validation validation =
+ await Validate(dbContext, request, channel, cancellationToken);
+ return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
+ },
+ None: () => Task.FromResult(
+ Left(
+ new NotFoundError($"Channel {request.ChannelId} does not exist."))));
}
private async Task ApplyUpdateRequest(
@@ -162,23 +180,82 @@ public class UpdateChannelHandler(
private static async Task> Validate(
TvContext dbContext,
UpdateChannel request,
- CancellationToken cancellationToken) =>
- (await ChannelMustExist(dbContext, request, cancellationToken),
- ValidateName(request),
+ Channel channel,
+ CancellationToken cancellationToken)
+ {
+ Validation channelValidation = (ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
- await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
- .Apply((channelToUpdate, _, _, _) => channelToUpdate);
+ await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
+ ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
+ ValidateLogo(request.Logo?.Path))
+ .Apply((_, _, _, _, _) => channel);
- private static Task> ChannelMustExist(
+ // combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
+ // fallback filler) with the channel validation; splitting keeps tuple arity within
+ // LanguageExt's supported applicative range while still accumulating all errors
+ return (ValidateGroup(request.Group),
+ await FFmpegProfileMustExist(dbContext, request, cancellationToken),
+ await WatermarkMustExist(dbContext, request, cancellationToken),
+ await FillerPresetMustExist(dbContext, request, cancellationToken),
+ channelValidation)
+ .Apply((_, _, _, _, c) => c);
+ }
+
+ private static async Task> FFmpegProfileMustExist(
TvContext dbContext,
- UpdateChannel updateChannel,
- CancellationToken cancellationToken) =>
- dbContext.Channels
- .Include(c => c.Artwork)
- .Include(c => c.Watermark)
- .Include(c => c.Playouts)
- .SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId, cancellationToken)
- .Map(o => o.ToValidation("Channel does not exist."));
+ UpdateChannel request,
+ CancellationToken cancellationToken)
+ {
+ bool exists = await dbContext.FFmpegProfiles
+ .AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
+ if (exists)
+ {
+ return request.FFmpegProfileId;
+ }
+
+ return BaseError.New($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
+ }
+
+ private static async Task>> WatermarkMustExist(
+ TvContext dbContext,
+ UpdateChannel request,
+ CancellationToken cancellationToken)
+ {
+ if (request.WatermarkId is null)
+ {
+ return Option.None;
+ }
+
+ bool exists = await dbContext.ChannelWatermarks
+ .AnyAsync(w => w.Id == request.WatermarkId, cancellationToken);
+ if (exists)
+ {
+ return Optional(request.WatermarkId);
+ }
+
+ return BaseError.New($"Watermark {request.WatermarkId} does not exist.");
+ }
+
+ private static async Task>> FillerPresetMustExist(
+ TvContext dbContext,
+ UpdateChannel request,
+ CancellationToken cancellationToken)
+ {
+ if (request.FallbackFillerId is null)
+ {
+ return Option.None;
+ }
+
+ bool exists = await dbContext.FillerPresets
+ .Filter(fp => fp.FillerKind == FillerKind.Fallback)
+ .AnyAsync(w => w.Id == request.FallbackFillerId, cancellationToken);
+ if (exists)
+ {
+ return Optional(request.FallbackFillerId);
+ }
+
+ return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
+ }
private static async Task> MirrorSourceMustBeValid(
TvContext dbContext,
diff --git a/ErsatzTV.Core/Errors/NotFoundError.cs b/ErsatzTV.Core/Errors/NotFoundError.cs
new file mode 100644
index 000000000..3d15e3caf
--- /dev/null
+++ b/ErsatzTV.Core/Errors/NotFoundError.cs
@@ -0,0 +1,12 @@
+namespace ErsatzTV.Core.Errors;
+
+///
+/// A that indicates the addressed resource does not exist.
+/// REST endpoints map this to HTTP 404; other values map to 422.
+///
+public class NotFoundError : BaseError
+{
+ public NotFoundError(string value) : base(value)
+ {
+ }
+}
diff --git a/ErsatzTV.Tests/Application/Channels/CreateChannelHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/CreateChannelHandlerTests.cs
new file mode 100644
index 000000000..7579f78b6
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Channels/CreateChannelHandlerTests.cs
@@ -0,0 +1,104 @@
+using ErsatzTV.Application.Channels;
+using ErsatzTV.Core;
+using LanguageExt;
+using ErsatzTV.Core.Errors;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Tests.Support;
+using Microsoft.EntityFrameworkCore;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Application.Channels;
+
+[TestFixture]
+public class CreateChannelHandlerTests : ChannelHandlerTestBase
+{
+ private CreateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets);
+
+ [Test]
+ public async Task Should_Create_Channel_When_Valid()
+ {
+ await SeedFFmpegProfile();
+
+ Either result =
+ await MakeHandler().Handle(MakeCreate(number: "7", name: "News"), CancellationToken.None);
+
+ result.IsRight.ShouldBeTrue();
+
+ await using TvContext context = Db.CreateContext();
+ bool exists = await context.Channels.AnyAsync(c => c.Number == "7" && c.Name == "News");
+ exists.ShouldBeTrue();
+ }
+
+ [Test]
+ public async Task Should_Reject_Duplicate_Number_With_422_Error()
+ {
+ await SeedFFmpegProfile();
+ await SeedChannel(1, "5");
+
+ Either result =
+ await MakeHandler().Handle(MakeCreate(number: "5"), CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldNotBeOfType();
+ error.Value.ShouldContain("unique");
+ }
+
+ [Test]
+ public async Task Should_Reject_ShowInEpg_When_Disabled()
+ {
+ await SeedFFmpegProfile();
+
+ Either result =
+ await MakeHandler().Handle(
+ MakeCreate(number: "8", isEnabled: false, showInEpg: true),
+ CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldNotBeOfType();
+ error.Value.ShouldContain("EPG");
+ }
+
+ [Test]
+ public async Task Should_Reject_Invalid_External_Logo_Url()
+ {
+ await SeedFFmpegProfile();
+
+ Either result =
+ await MakeHandler().Handle(
+ MakeCreate(number: "9", logoPath: "ftp://example.com/logo.png"),
+ CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldNotBeOfType();
+ error.Value.ShouldContain("logo");
+ }
+
+ [Test]
+ public async Task Should_Reject_Empty_Group()
+ {
+ await SeedFFmpegProfile();
+
+ Either result =
+ await MakeHandler().Handle(MakeCreate(number: "11", group: " "), CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldNotBeOfType();
+ error.Value.ShouldContain("group");
+ }
+
+ [Test]
+ public async Task Should_Reject_Nonexistent_FFmpegProfile()
+ {
+ // intentionally do not seed an FFmpegProfile
+ Either result =
+ await MakeHandler().Handle(MakeCreate(number: "12", ffmpegProfileId: 999), CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldNotBeOfType();
+ error.Value.ShouldContain("FFmpegProfile");
+ }
+
+ private static BaseError LeftOf(Either either) =>
+ either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
+}
diff --git a/ErsatzTV.Tests/Application/Channels/DeleteChannelHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/DeleteChannelHandlerTests.cs
new file mode 100644
index 000000000..3fbb057dc
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Channels/DeleteChannelHandlerTests.cs
@@ -0,0 +1,44 @@
+using ErsatzTV.Application.Channels;
+using ErsatzTV.Core;
+using LanguageExt;
+using ErsatzTV.Core.Errors;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Tests.Support;
+using Microsoft.EntityFrameworkCore;
+using NUnit.Framework;
+using Shouldly;
+using Testably.Abstractions.Testing;
+
+namespace ErsatzTV.Tests.Application.Channels;
+
+[TestFixture]
+public class DeleteChannelHandlerTests : ChannelHandlerTestBase
+{
+ private DeleteChannelHandler MakeHandler() => new(Worker, Db.Factory, new MockFileSystem(), SearchTargets);
+
+ [Test]
+ public async Task Should_Return_NotFoundError_When_Channel_Missing()
+ {
+ Either result = await MakeHandler().Handle(new DeleteChannel(999), CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldBeOfType();
+ }
+
+ [Test]
+ public async Task Should_Delete_Existing_Channel()
+ {
+ await SeedChannel(1, "5");
+
+ Either result = await MakeHandler().Handle(new DeleteChannel(1), CancellationToken.None);
+
+ result.IsRight.ShouldBeTrue();
+
+ await using TvContext context = Db.CreateContext();
+ bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
+ exists.ShouldBeFalse();
+ }
+
+ private static BaseError LeftOf(Either either) =>
+ either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
+}
diff --git a/ErsatzTV.Tests/Application/Channels/UpdateChannelHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/UpdateChannelHandlerTests.cs
new file mode 100644
index 000000000..1df79be1b
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Channels/UpdateChannelHandlerTests.cs
@@ -0,0 +1,119 @@
+using ErsatzTV.Application.Channels;
+using ErsatzTV.Core;
+using LanguageExt;
+using ErsatzTV.Core.Errors;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Tests.Support;
+using Microsoft.EntityFrameworkCore;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Application.Channels;
+
+[TestFixture]
+public class UpdateChannelHandlerTests : ChannelHandlerTestBase
+{
+ private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets);
+
+ [Test]
+ public async Task Should_Return_NotFoundError_When_Channel_Missing()
+ {
+ Either result =
+ await MakeHandler().Handle(MakeUpdate(999, number: "5"), CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldBeOfType();
+ }
+
+ [Test]
+ public async Task Should_Update_Existing_Channel()
+ {
+ await SeedFFmpegProfile();
+ await SeedChannel(1, "5", "Old Name");
+
+ Either result =
+ await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "New Name"), CancellationToken.None);
+
+ result.IsRight.ShouldBeTrue();
+
+ await using TvContext context = Db.CreateContext();
+ var channel = await context.Channels.SingleAsync(c => c.Id == 1);
+ channel.Name.ShouldBe("New Name");
+ }
+
+ [Test]
+ public async Task Should_Allow_Keeping_Own_Number()
+ {
+ await SeedFFmpegProfile();
+ await SeedChannel(1, "5", "Old Name");
+
+ Either result =
+ await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None);
+
+ result.IsRight.ShouldBeTrue();
+ }
+
+ [Test]
+ public async Task Should_Reject_Number_Used_By_Another_Channel()
+ {
+ await SeedFFmpegProfile();
+ await SeedChannel(1, "5");
+ await SeedChannel(2, "6");
+
+ Either result =
+ await MakeHandler().Handle(MakeUpdate(1, number: "6"), CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldNotBeOfType();
+ error.Value.ShouldContain("unique");
+ }
+
+ [Test]
+ public async Task Should_Reject_ShowInEpg_When_Disabled()
+ {
+ await SeedFFmpegProfile();
+ await SeedChannel(1, "5");
+
+ Either result =
+ await MakeHandler().Handle(
+ MakeUpdate(1, number: "5", isEnabled: false, showInEpg: true),
+ CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldNotBeOfType();
+ error.Value.ShouldContain("EPG");
+ }
+
+ [Test]
+ public async Task Should_Reject_Empty_Group()
+ {
+ await SeedFFmpegProfile();
+ await SeedChannel(1, "5");
+
+ Either result =
+ await MakeHandler().Handle(MakeUpdate(1, number: "5", group: ""), CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldNotBeOfType();
+ error.Value.ShouldContain("group");
+ }
+
+ [Test]
+ public async Task Should_Reject_Nonexistent_FFmpegProfile()
+ {
+ await SeedFFmpegProfile();
+ await SeedChannel(1, "5");
+
+ Either result =
+ await MakeHandler().Handle(
+ MakeUpdate(1, number: "5", ffmpegProfileId: 999),
+ CancellationToken.None);
+
+ BaseError error = LeftOf(result);
+ error.ShouldNotBeOfType();
+ error.Value.ShouldContain("FFmpegProfile");
+ }
+
+ private static BaseError LeftOf(Either either) =>
+ either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
+}
diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs
new file mode 100644
index 000000000..444051220
--- /dev/null
+++ b/ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs
@@ -0,0 +1,62 @@
+using System.Linq;
+using System.Reflection;
+using ErsatzTV.Controllers.Api;
+using ErsatzTV.Filters;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Routing;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Controllers;
+
+///
+/// Guards the API-key write-path contract for : every mutating
+/// action (POST/PUT/PATCH/DELETE) must be covered by .
+/// The filter is applied at the controller level, so this also protects any future write endpoint
+/// added to the controller (regression net for the ResetPlayout bypass).
+///
+[TestFixture]
+public class ChannelControllerSecurityTests
+{
+ [Test]
+ public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
+ {
+ ServiceFilterAttribute? filter = typeof(ChannelController)
+ .GetCustomAttributes(inherit: true)
+ .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
+
+ filter.ShouldNotBeNull("ChannelController must carry the ApiKeyAuthorizationFilter at the class level");
+ }
+
+ [Test]
+ public void Every_Mutating_Action_Should_Be_Protected()
+ {
+ MethodInfo[] actions = typeof(ChannelController)
+ .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
+
+ bool controllerHasFilter = typeof(ChannelController)
+ .GetCustomAttributes(inherit: true)
+ .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
+
+ foreach (MethodInfo action in actions)
+ {
+ bool isMutating = action
+ .GetCustomAttributes(inherit: true)
+ .SelectMany(a => a.HttpMethods)
+ .Any(m =>
+ m is "POST" or "PUT" or "PATCH" or "DELETE");
+
+ if (!isMutating)
+ {
+ continue;
+ }
+
+ bool actionHasFilter = action
+ .GetCustomAttributes(inherit: true)
+ .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
+
+ (controllerHasFilter || actionHasFilter)
+ .ShouldBeTrue($"Mutating action {action.Name} is not protected by ApiKeyAuthorizationFilter");
+ }
+ }
+}
diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs
new file mode 100644
index 000000000..f6eed998d
--- /dev/null
+++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs
@@ -0,0 +1,241 @@
+using System.Threading.Channels;
+using ErsatzTV.Application;
+using ErsatzTV.Application.Artworks;
+using ErsatzTV.Application.Channels;
+using ErsatzTV.Controllers.Api;
+using ErsatzTV.Controllers.Api.Requests;
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Errors;
+using LanguageExt;
+using static LanguageExt.Prelude;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+using Unit = LanguageExt.Unit;
+
+namespace ErsatzTV.Tests.Controllers;
+
+[TestFixture]
+public class ChannelControllerTests
+{
+ private IMediator _mediator = null!;
+ private ChannelController _controller = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _mediator = Substitute.For();
+ ChannelWriter writer =
+ System.Threading.Channels.Channel.CreateUnbounded().Writer;
+ _controller = new ChannelController(writer, _mediator);
+ }
+
+ [Test]
+ public async Task Create_Should_Return_201_With_Location_And_Body()
+ {
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Right(new CreateChannelResult(5)));
+ ChannelViewModel vm = MakeVm(5);
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Option.Some(vm));
+
+ IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None);
+
+ var created = result.ShouldBeOfType();
+ created.StatusCode.ShouldBe(201);
+ created.Location.ShouldBe("/api/channels/5");
+ created.Value.ShouldBe(vm);
+ }
+
+ [Test]
+ public async Task Create_Should_Map_Request_To_Command()
+ {
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Right(new CreateChannelResult(5)));
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Option.Some(MakeVm(5)));
+
+ await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None);
+
+ await _mediator.Received(1).Send(
+ Arg.Is(c => c.Number == "12" && c.Name == "Movies"),
+ Arg.Any());
+ }
+
+ [Test]
+ public async Task Create_Should_Return_422_On_Validation_Error()
+ {
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Left(BaseError.New("bad")));
+
+ IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None);
+
+ result.ShouldBeOfType();
+ }
+
+ [Test]
+ public async Task Update_Should_Return_200_And_Map_Route_Id()
+ {
+ ChannelViewModel vm = MakeVm(7);
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Right(vm));
+
+ IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None);
+
+ result.ShouldBeOfType().Value.ShouldBe(vm);
+ await _mediator.Received(1).Send(
+ Arg.Is(c => c.ChannelId == 7),
+ Arg.Any());
+ }
+
+ [Test]
+ public async Task Update_Should_Return_404_For_NotFoundError()
+ {
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Left(new NotFoundError("missing")));
+
+ IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None);
+
+ result.ShouldBeOfType();
+ }
+
+ [Test]
+ public async Task Delete_Should_Return_204_On_Success()
+ {
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Right(Unit.Default));
+
+ IActionResult result = await _controller.Delete(3, CancellationToken.None);
+
+ result.ShouldBeOfType();
+ }
+
+ [Test]
+ public async Task Delete_Should_Return_404_For_NotFoundError()
+ {
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Left(new NotFoundError("missing")));
+
+ IActionResult result = await _controller.Delete(99, CancellationToken.None);
+
+ result.ShouldBeOfType();
+ }
+
+ [Test]
+ public async Task GetById_Should_Return_200_For_Some()
+ {
+ ChannelViewModel vm = MakeVm(4);
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Option.Some(vm));
+
+ IActionResult result = await _controller.GetById(4, CancellationToken.None);
+
+ result.ShouldBeOfType().Value.ShouldBe(vm);
+ }
+
+ [Test]
+ public async Task GetById_Should_Return_404_For_None()
+ {
+ _mediator.Send(Arg.Any(), Arg.Any())
+ .Returns(Option.None);
+
+ IActionResult result = await _controller.GetById(4, CancellationToken.None);
+
+ result.ShouldBeOfType();
+ }
+
+ private static ChannelViewModel MakeVm(int id) =>
+ new(
+ id,
+ "5",
+ "Test",
+ "ErsatzTV",
+ string.Empty,
+ 1,
+ null,
+ ArtworkContentTypeModel.None,
+ ChannelStreamSelectorMode.Default,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ ChannelPlayoutSource.Generated,
+ ChannelPlayoutMode.Continuous,
+ null,
+ null,
+ StreamingMode.TransportStreamHybrid,
+ null,
+ null,
+ 0,
+ string.Empty,
+ ChannelSubtitleMode.None,
+ ChannelMusicVideoCreditsMode.None,
+ string.Empty,
+ ChannelSongVideoMode.Default,
+ ChannelTranscodeMode.OnDemand,
+ ChannelIdleBehavior.StopOnDisconnect,
+ true,
+ false);
+
+ private static CreateChannelRequest MakeCreateRequest(string number = "5", string name = "Test") =>
+ new(
+ name,
+ number,
+ "ErsatzTV",
+ string.Empty,
+ 1,
+ null,
+ ArtworkContentTypeModel.None,
+ ChannelStreamSelectorMode.Default,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ ChannelPlayoutSource.Generated,
+ ChannelPlayoutMode.Continuous,
+ null,
+ null,
+ StreamingMode.TransportStreamHybrid,
+ null,
+ null,
+ string.Empty,
+ ChannelSubtitleMode.None,
+ ChannelMusicVideoCreditsMode.None,
+ string.Empty,
+ ChannelSongVideoMode.Default,
+ ChannelTranscodeMode.OnDemand,
+ ChannelIdleBehavior.StopOnDisconnect,
+ true,
+ false);
+
+ private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") =>
+ new(
+ name,
+ number,
+ "ErsatzTV",
+ string.Empty,
+ 1,
+ null,
+ ArtworkContentTypeModel.None,
+ ChannelStreamSelectorMode.Default,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ ChannelPlayoutSource.Generated,
+ ChannelPlayoutMode.Continuous,
+ null,
+ null,
+ StreamingMode.TransportStreamHybrid,
+ null,
+ null,
+ string.Empty,
+ ChannelSubtitleMode.None,
+ ChannelMusicVideoCreditsMode.None,
+ string.Empty,
+ ChannelSongVideoMode.Default,
+ ChannelTranscodeMode.OnDemand,
+ ChannelIdleBehavior.StopOnDisconnect,
+ true,
+ false);
+}
diff --git a/ErsatzTV.Tests/ErsatzTV.Tests.csproj b/ErsatzTV.Tests/ErsatzTV.Tests.csproj
index 5581d1eaf..76c49cf5a 100644
--- a/ErsatzTV.Tests/ErsatzTV.Tests.csproj
+++ b/ErsatzTV.Tests/ErsatzTV.Tests.csproj
@@ -11,13 +11,16 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
diff --git a/ErsatzTV.Tests/Extensions/ApiResultsTests.cs b/ErsatzTV.Tests/Extensions/ApiResultsTests.cs
new file mode 100644
index 000000000..9170eeaac
--- /dev/null
+++ b/ErsatzTV.Tests/Extensions/ApiResultsTests.cs
@@ -0,0 +1,115 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Errors;
+using ErsatzTV.Extensions;
+using LanguageExt;
+using static LanguageExt.Prelude;
+using Microsoft.AspNetCore.Mvc;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Extensions;
+
+[TestFixture]
+public class ApiResultsTests
+{
+ [Test]
+ public void ToErrorResult_Should_Map_NotFoundError_To_404()
+ {
+ IActionResult result = new NotFoundError("missing").ToErrorResult();
+ result.ShouldBeOfType().StatusCode.ShouldBe(404);
+ }
+
+ [Test]
+ public void ToErrorResult_Should_Map_Other_Error_To_422()
+ {
+ IActionResult result = BaseError.New("bad").ToErrorResult();
+ result.ShouldBeOfType().StatusCode.ShouldBe(422);
+ }
+
+ [Test]
+ public void ToCreatedResult_Should_Return_201_With_Location_And_Body()
+ {
+ Either either = Right(5);
+
+ IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => $"body-{id}");
+
+ var created = result.ShouldBeOfType();
+ created.StatusCode.ShouldBe(201);
+ created.Location.ShouldBe("/api/channels/5");
+ created.Value.ShouldBe("body-5");
+ }
+
+ [Test]
+ public void ToCreatedResult_Should_Map_NotFoundError_To_404()
+ {
+ Either either = Left(new NotFoundError("nope"));
+
+ IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => id);
+
+ result.ShouldBeOfType();
+ }
+
+ [Test]
+ public void ToUpdatedResult_Should_Return_200_On_Right()
+ {
+ Either either = Right("vm");
+ IActionResult result = either.ToUpdatedResult();
+
+ var ok = result.ShouldBeOfType();
+ ok.Value.ShouldBe("vm");
+ }
+
+ [Test]
+ public void ToUpdatedResult_Should_Return_404_For_NotFoundError()
+ {
+ Either either = Left(new NotFoundError("missing"));
+ IActionResult result = either.ToUpdatedResult();
+
+ result.ShouldBeOfType();
+ }
+
+ [Test]
+ public void ToUpdatedResult_Should_Return_422_For_Other_Error()
+ {
+ Either either = Left(BaseError.New("bad"));
+ IActionResult result = either.ToUpdatedResult();
+
+ result.ShouldBeOfType();
+ }
+
+ [Test]
+ public void ToDeletedResult_Should_Return_204_On_Right()
+ {
+ Either either = Right(Unit.Default);
+ IActionResult result = either.ToDeletedResult();
+
+ result.ShouldBeOfType().StatusCode.ShouldBe(204);
+ }
+
+ [Test]
+ public void ToDeletedResult_Should_Return_404_For_NotFoundError()
+ {
+ Either either = Left(new NotFoundError("missing"));
+ IActionResult result = either.ToDeletedResult();
+
+ result.ShouldBeOfType();
+ }
+
+ [Test]
+ public void ToGetResult_Should_Return_200_For_Some()
+ {
+ Option option = "value";
+ IActionResult result = option.ToGetResult();
+
+ result.ShouldBeOfType().Value.ShouldBe("value");
+ }
+
+ [Test]
+ public void ToGetResult_Should_Return_404_For_None()
+ {
+ Option option = Option.None;
+ IActionResult result = option.ToGetResult();
+
+ result.ShouldBeOfType();
+ }
+}
diff --git a/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs
new file mode 100644
index 000000000..3699e5256
--- /dev/null
+++ b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs
@@ -0,0 +1,89 @@
+using System.Collections.Generic;
+using ErsatzTV.Filters;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Configuration;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Filters;
+
+[TestFixture]
+public class ApiKeyAuthorizationFilterTests
+{
+ private static AuthorizationFilterContext MakeContext(string method, string? apiKeyHeader)
+ {
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Method = method;
+ if (apiKeyHeader is not null)
+ {
+ httpContext.Request.Headers[ApiKeyAuthorizationFilter.HeaderName] = apiKeyHeader;
+ }
+
+ var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
+ return new AuthorizationFilterContext(actionContext, new List());
+ }
+
+ private static ApiKeyAuthorizationFilter MakeFilter(string? configuredKey)
+ {
+ var settings = new Dictionary();
+ if (configuredKey is not null)
+ {
+ settings[ApiKeyAuthorizationFilter.ConfigurationKey] = configuredKey;
+ }
+
+ IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
+ return new ApiKeyAuthorizationFilter(configuration);
+ }
+
+ [Test]
+ public void Should_Allow_When_Key_Not_Configured()
+ {
+ AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
+ MakeFilter(configuredKey: null).OnAuthorization(context);
+ context.Result.ShouldBeNull();
+ }
+
+ [Test]
+ public void Should_Allow_When_Key_Configured_Empty()
+ {
+ AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
+ MakeFilter(configuredKey: string.Empty).OnAuthorization(context);
+ context.Result.ShouldBeNull();
+ }
+
+ [Test]
+ public void Should_Reject_Mutating_Request_When_Key_Configured_And_Header_Missing()
+ {
+ AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
+ MakeFilter(configuredKey: "secret").OnAuthorization(context);
+ context.Result.ShouldBeOfType();
+ }
+
+ [Test]
+ public void Should_Reject_Mutating_Request_When_Key_Wrong()
+ {
+ AuthorizationFilterContext context = MakeContext("DELETE", apiKeyHeader: "wrong");
+ MakeFilter(configuredKey: "secret").OnAuthorization(context);
+ context.Result.ShouldBeOfType();
+ }
+
+ [Test]
+ public void Should_Allow_Mutating_Request_When_Key_Correct()
+ {
+ AuthorizationFilterContext context = MakeContext("PUT", apiKeyHeader: "secret");
+ MakeFilter(configuredKey: "secret").OnAuthorization(context);
+ context.Result.ShouldBeNull();
+ }
+
+ [Test]
+ public void Should_Allow_Get_Request_Even_When_Key_Configured()
+ {
+ AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null);
+ MakeFilter(configuredKey: "secret").OnAuthorization(context);
+ context.Result.ShouldBeNull();
+ }
+}
diff --git a/ErsatzTV.Tests/Integration/ChannelLifecycleIntegrationTests.cs b/ErsatzTV.Tests/Integration/ChannelLifecycleIntegrationTests.cs
new file mode 100644
index 000000000..be0edd769
--- /dev/null
+++ b/ErsatzTV.Tests/Integration/ChannelLifecycleIntegrationTests.cs
@@ -0,0 +1,52 @@
+using ErsatzTV.Application.Channels;
+using ErsatzTV.Core;
+using LanguageExt;
+using ErsatzTV.Infrastructure.Data.Repositories;
+using ErsatzTV.Tests.Support;
+using NUnit.Framework;
+using Shouldly;
+using Testably.Abstractions.Testing;
+
+namespace ErsatzTV.Tests.Integration;
+
+///
+/// End-to-end create -> read (get-by-id) -> delete against the in-memory SQLite harness,
+/// exercising the real EF Core handlers and repository.
+///
+[TestFixture]
+public class ChannelLifecycleIntegrationTests : ChannelHandlerTestBase
+{
+ [Test]
+ public async Task Create_Then_Read_Then_Delete()
+ {
+ await SeedFFmpegProfile();
+
+ var createHandler = new CreateChannelHandler(Worker, Db.Factory, SearchTargets);
+ Either created =
+ await createHandler.Handle(MakeCreate(number: "42", name: "Integration"), CancellationToken.None);
+
+ int channelId = created.Match(Left: _ => throw new AssertionException("create failed"), Right: r => r.ChannelId);
+ channelId.ShouldBeGreaterThan(0);
+
+ var getHandler = new GetChannelByIdHandler(new ChannelRepository(Db.Factory));
+ Option afterCreate =
+ await getHandler.Handle(new GetChannelById(channelId), CancellationToken.None);
+ afterCreate.IsSome.ShouldBeTrue();
+ afterCreate.Match(
+ Some: vm =>
+ {
+ vm.Number.ShouldBe("42");
+ vm.Name.ShouldBe("Integration");
+ },
+ None: () => throw new AssertionException("expected channel to exist"));
+
+ var deleteHandler = new DeleteChannelHandler(Worker, Db.Factory, new MockFileSystem(), SearchTargets);
+ Either deleted =
+ await deleteHandler.Handle(new DeleteChannel(channelId), CancellationToken.None);
+ deleted.IsRight.ShouldBeTrue();
+
+ Option afterDelete =
+ await getHandler.Handle(new GetChannelById(channelId), CancellationToken.None);
+ afterDelete.IsNone.ShouldBeTrue();
+ }
+}
diff --git a/ErsatzTV.Tests/Support/ChannelHandlerTestBase.cs b/ErsatzTV.Tests/Support/ChannelHandlerTestBase.cs
new file mode 100644
index 000000000..be790eed5
--- /dev/null
+++ b/ErsatzTV.Tests/Support/ChannelHandlerTestBase.cs
@@ -0,0 +1,137 @@
+using System.Threading.Channels;
+using ErsatzTV.Application;
+using ErsatzTV.Application.Artworks;
+using ErsatzTV.Application.Channels;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Interfaces.Search;
+using ErsatzTV.Infrastructure.Data;
+using NSubstitute;
+using NUnit.Framework;
+using DomainChannel = ErsatzTV.Core.Domain.Channel;
+
+namespace ErsatzTV.Tests.Support;
+
+public abstract class ChannelHandlerTestBase
+{
+ protected InMemoryTvContext Db = null!;
+ protected ChannelWriter Worker = null!;
+ protected ISearchTargets SearchTargets = null!;
+
+ [SetUp]
+ public async Task BaseSetUp()
+ {
+ Db = await InMemoryTvContext.CreateAsync();
+ Worker = System.Threading.Channels.Channel.CreateUnbounded().Writer;
+ SearchTargets = Substitute.For();
+ }
+
+ [TearDown]
+ public async Task BaseTearDown() => await Db.DisposeAsync();
+
+ protected async Task SeedFFmpegProfile(int id = 1)
+ {
+ await using TvContext context = Db.CreateContext();
+ context.FFmpegProfiles.Add(new FFmpegProfile { Id = id, Name = $"profile-{id}" });
+ await context.SaveChangesAsync();
+ }
+
+ protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1)
+ {
+ await using TvContext context = Db.CreateContext();
+ context.Channels.Add(
+ new DomainChannel(Guid.NewGuid())
+ {
+ Id = id,
+ Number = number,
+ Name = name,
+ Group = "ErsatzTV",
+ Categories = string.Empty,
+ FFmpegProfileId = ffmpegProfileId,
+ StreamSelector = string.Empty,
+ PreferredAudioLanguageCode = string.Empty,
+ PreferredAudioTitle = string.Empty,
+ PreferredSubtitleLanguageCode = string.Empty,
+ MusicVideoCreditsTemplate = string.Empty,
+ StreamingMode = StreamingMode.TransportStreamHybrid,
+ PlayoutSource = ChannelPlayoutSource.Generated,
+ PlayoutMode = ChannelPlayoutMode.Continuous
+ });
+ await context.SaveChangesAsync();
+ }
+
+ protected static CreateChannel MakeCreate(
+ string number = "5",
+ int ffmpegProfileId = 1,
+ bool isEnabled = true,
+ bool showInEpg = false,
+ string logoPath = "",
+ string name = "Test",
+ string group = "ErsatzTV") =>
+ new(
+ name,
+ number,
+ group,
+ string.Empty,
+ ffmpegProfileId,
+ null,
+ new ArtworkContentTypeModel(logoPath, string.Empty),
+ ChannelStreamSelectorMode.Default,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ ChannelPlayoutSource.Generated,
+ ChannelPlayoutMode.Continuous,
+ null,
+ null,
+ StreamingMode.TransportStreamHybrid,
+ null,
+ null,
+ string.Empty,
+ ChannelSubtitleMode.None,
+ ChannelMusicVideoCreditsMode.None,
+ string.Empty,
+ ChannelSongVideoMode.Default,
+ ChannelTranscodeMode.OnDemand,
+ ChannelIdleBehavior.StopOnDisconnect,
+ isEnabled,
+ showInEpg);
+
+ protected static UpdateChannel MakeUpdate(
+ int channelId,
+ string number = "5",
+ int ffmpegProfileId = 1,
+ bool isEnabled = true,
+ bool showInEpg = false,
+ string logoPath = "",
+ string name = "Test",
+ string group = "ErsatzTV") =>
+ new(
+ channelId,
+ name,
+ number,
+ group,
+ string.Empty,
+ ffmpegProfileId,
+ null,
+ new ArtworkContentTypeModel(logoPath, string.Empty),
+ ChannelStreamSelectorMode.Default,
+ string.Empty,
+ string.Empty,
+ string.Empty,
+ ChannelPlayoutSource.Generated,
+ ChannelPlayoutMode.Continuous,
+ null,
+ null,
+ StreamingMode.TransportStreamHybrid,
+ null,
+ null,
+ string.Empty,
+ ChannelSubtitleMode.None,
+ ChannelMusicVideoCreditsMode.None,
+ string.Empty,
+ ChannelSongVideoMode.Default,
+ ChannelTranscodeMode.OnDemand,
+ ChannelIdleBehavior.StopOnDisconnect,
+ isEnabled,
+ showInEpg);
+}
diff --git a/ErsatzTV.Tests/Support/InMemoryTvContext.cs b/ErsatzTV.Tests/Support/InMemoryTvContext.cs
new file mode 100644
index 000000000..93def6448
--- /dev/null
+++ b/ErsatzTV.Tests/Support/InMemoryTvContext.cs
@@ -0,0 +1,66 @@
+using ErsatzTV.Infrastructure;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace ErsatzTV.Tests.Support;
+
+///
+/// In-memory SQLite harness for handler/integration tests. A single
+/// is kept open for the lifetime of the harness so the schema (built via
+/// ) and data persist across the multiple
+/// instances created by an .
+/// Foreign keys are disabled so partial graphs can be seeded without satisfying every FK.
+///
+public sealed class InMemoryTvContext : IAsyncDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions _options;
+
+ private InMemoryTvContext(SqliteConnection connection, DbContextOptions options)
+ {
+ _connection = connection;
+ _options = options;
+ }
+
+ public IDbContextFactory Factory => new TestDbContextFactory(_options);
+
+ public static async Task CreateAsync()
+ {
+ TvContext.IsSqlite = true;
+
+ var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
+ await connection.OpenAsync();
+
+ DbContextOptions options = new DbContextOptionsBuilder()
+ .UseSqlite(connection)
+ .Options;
+
+ await using (TvContext context = Create(options))
+ {
+ await context.Database.EnsureCreatedAsync();
+ await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys=OFF");
+ }
+
+ return new InMemoryTvContext(connection, options);
+ }
+
+ public TvContext CreateContext() => Create(_options);
+
+ public async ValueTask DisposeAsync()
+ {
+ await _connection.DisposeAsync();
+ }
+
+ private static TvContext Create(DbContextOptions options) =>
+ new(
+ options,
+ NullLoggerFactory.Instance,
+ new SlowQueryInterceptor(NullLogger.Instance));
+
+ private sealed class TestDbContextFactory(DbContextOptions options) : IDbContextFactory
+ {
+ public TvContext CreateDbContext() => Create(options);
+ }
+}
diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs
index baa1edb5d..2ff273549 100644
--- a/ErsatzTV/Controllers/Api/ChannelController.cs
+++ b/ErsatzTV/Controllers/Api/ChannelController.cs
@@ -1,9 +1,13 @@
-using System.Threading.Channels;
+using System.ComponentModel.DataAnnotations;
+using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
+using ErsatzTV.Controllers.Api.Requests;
+using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Scheduling;
+using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -11,12 +15,72 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
+// Apply the optional API-key control at the controller level so that EVERY mutating action
+// (including future ones) is covered by default; the filter no-ops on read methods (GET) and
+// when Api:WriteKey is unset, preserving the open LAN behavior. This is fail-safe: a developer
+// adding a new write endpoint here cannot accidentally leave it unauthenticated.
+[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class ChannelController(ChannelWriter workerChannel, IMediator mediator)
{
[HttpGet("/api/channels")]
[EndpointGroupName("general")]
public async Task> GetAll() => await mediator.Send(new GetAllChannelsForApi());
+ [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
+ [Tags("Channels")]
+ [EndpointSummary("Get a channel by id")]
+ [EndpointGroupName("general")]
+ public async Task GetById(int id, CancellationToken cancellationToken)
+ {
+ Option result = await mediator.Send(new GetChannelById(id), cancellationToken);
+ return result.ToGetResult();
+ }
+
+ [HttpPost("/api/channels")]
+ [Tags("Channels")]
+ [EndpointSummary("Create a channel")]
+ [EndpointGroupName("general")]
+ public async Task Create(
+ [Required] [FromBody] CreateChannelRequest request,
+ CancellationToken cancellationToken)
+ {
+ Either result = await mediator.Send(request.ToCommand(), cancellationToken);
+ return await result.Match(
+ Left: error => Task.FromResult(error.ToErrorResult()),
+ Right: async created =>
+ {
+ Option channel =
+ await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken);
+ return channel.Match(
+ Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm),
+ None: () => new NotFoundResult());
+ });
+ }
+
+ [HttpPut("/api/channels/{id:int}")]
+ [Tags("Channels")]
+ [EndpointSummary("Update a channel")]
+ [EndpointGroupName("general")]
+ public async Task Update(
+ int id,
+ [Required] [FromBody] UpdateChannelRequest request,
+ CancellationToken cancellationToken)
+ {
+ Either result =
+ await mediator.Send(request.ToCommand(id), cancellationToken);
+ return result.ToUpdatedResult();
+ }
+
+ [HttpDelete("/api/channels/{id:int}")]
+ [Tags("Channels")]
+ [EndpointSummary("Delete a channel")]
+ [EndpointGroupName("general")]
+ public async Task Delete(int id, CancellationToken cancellationToken)
+ {
+ Either result = await mediator.Send(new DeleteChannel(id), cancellationToken);
+ return result.ToDeletedResult();
+ }
+
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
@@ -32,24 +96,4 @@ public class ChannelController(ChannelWriter workerCh
return new NotFoundResult();
}
-
- // for debugging by fast-forwarding a playout
- // [HttpPost("/api/channels/{channelNumber}/playout/continue")]
- // public async Task ContinuePlayout(string channelNumber, [FromQuery] int days = 1)
- // {
- // Option maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
- // foreach (int playoutId in maybePlayoutId)
- // {
- // DateTimeOffset start = DateTimeOffset.Now;
- // for (int i = 0; i < 24 * days; i++)
- // {
- // await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue, start));
- // start += TimeSpan.FromHours(1);
- // }
- //
- // return new OkResult();
- // }
- //
- // return new NotFoundResult();
- // }
}
diff --git a/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs
new file mode 100644
index 000000000..c1fafb073
--- /dev/null
+++ b/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs
@@ -0,0 +1,69 @@
+using ErsatzTV.Application.Artworks;
+using ErsatzTV.Application.Channels;
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Controllers.Api.Requests;
+
+///
+/// JSON request body for creating a channel. Mirrors ; decoupling the
+/// API contract from the MediatR command keeps the wire format stable as the command evolves.
+///
+public record CreateChannelRequest(
+ string Name,
+ string Number,
+ string Group,
+ string Categories,
+ int FFmpegProfileId,
+ double? SlugSeconds,
+ ArtworkContentTypeModel Logo,
+ ChannelStreamSelectorMode StreamSelectorMode,
+ string StreamSelector,
+ string PreferredAudioLanguageCode,
+ string PreferredAudioTitle,
+ ChannelPlayoutSource PlayoutSource,
+ ChannelPlayoutMode PlayoutMode,
+ int? MirrorSourceChannelId,
+ TimeSpan? PlayoutOffset,
+ StreamingMode StreamingMode,
+ int? WatermarkId,
+ int? FallbackFillerId,
+ string PreferredSubtitleLanguageCode,
+ ChannelSubtitleMode SubtitleMode,
+ ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
+ string MusicVideoCreditsTemplate,
+ ChannelSongVideoMode SongVideoMode,
+ ChannelTranscodeMode TranscodeMode,
+ ChannelIdleBehavior IdleBehavior,
+ bool IsEnabled,
+ bool ShowInEpg)
+{
+ public CreateChannel ToCommand() =>
+ new(
+ Name,
+ Number,
+ Group,
+ Categories,
+ FFmpegProfileId,
+ SlugSeconds,
+ Logo,
+ StreamSelectorMode,
+ StreamSelector,
+ PreferredAudioLanguageCode,
+ PreferredAudioTitle,
+ PlayoutSource,
+ PlayoutMode,
+ MirrorSourceChannelId,
+ PlayoutOffset,
+ StreamingMode,
+ WatermarkId,
+ FallbackFillerId,
+ PreferredSubtitleLanguageCode,
+ SubtitleMode,
+ MusicVideoCreditsMode,
+ MusicVideoCreditsTemplate,
+ SongVideoMode,
+ TranscodeMode,
+ IdleBehavior,
+ IsEnabled,
+ ShowInEpg);
+}
diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs
new file mode 100644
index 000000000..f028084e6
--- /dev/null
+++ b/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs
@@ -0,0 +1,70 @@
+using ErsatzTV.Application.Artworks;
+using ErsatzTV.Application.Channels;
+using ErsatzTV.Core.Domain;
+
+namespace ErsatzTV.Controllers.Api.Requests;
+
+///
+/// JSON request body for updating a channel. The channel id comes from the route, not the body;
+/// all other fields mirror .
+///
+public record UpdateChannelRequest(
+ string Name,
+ string Number,
+ string Group,
+ string Categories,
+ int FFmpegProfileId,
+ double? SlugSeconds,
+ ArtworkContentTypeModel Logo,
+ ChannelStreamSelectorMode StreamSelectorMode,
+ string StreamSelector,
+ string PreferredAudioLanguageCode,
+ string PreferredAudioTitle,
+ ChannelPlayoutSource PlayoutSource,
+ ChannelPlayoutMode PlayoutMode,
+ int? MirrorSourceChannelId,
+ TimeSpan? PlayoutOffset,
+ StreamingMode StreamingMode,
+ int? WatermarkId,
+ int? FallbackFillerId,
+ string PreferredSubtitleLanguageCode,
+ ChannelSubtitleMode SubtitleMode,
+ ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
+ string MusicVideoCreditsTemplate,
+ ChannelSongVideoMode SongVideoMode,
+ ChannelTranscodeMode TranscodeMode,
+ ChannelIdleBehavior IdleBehavior,
+ bool IsEnabled,
+ bool ShowInEpg)
+{
+ public UpdateChannel ToCommand(int channelId) =>
+ new(
+ channelId,
+ Name,
+ Number,
+ Group,
+ Categories,
+ FFmpegProfileId,
+ SlugSeconds,
+ Logo,
+ StreamSelectorMode,
+ StreamSelector,
+ PreferredAudioLanguageCode,
+ PreferredAudioTitle,
+ PlayoutSource,
+ PlayoutMode,
+ MirrorSourceChannelId,
+ PlayoutOffset,
+ StreamingMode,
+ WatermarkId,
+ FallbackFillerId,
+ PreferredSubtitleLanguageCode,
+ SubtitleMode,
+ MusicVideoCreditsMode,
+ MusicVideoCreditsTemplate,
+ SongVideoMode,
+ TranscodeMode,
+ IdleBehavior,
+ IsEnabled,
+ ShowInEpg);
+}
diff --git a/ErsatzTV/Extensions/ApiResults.cs b/ErsatzTV/Extensions/ApiResults.cs
new file mode 100644
index 000000000..8bd85137c
--- /dev/null
+++ b/ErsatzTV/Extensions/ApiResults.cs
@@ -0,0 +1,49 @@
+using System.Diagnostics.CodeAnalysis;
+using ErsatzTV.Core;
+using ErsatzTV.Core.Errors;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ErsatzTV.Extensions;
+
+///
+/// REST status-code mapping helpers for the JSON write/read API (slice #2a).
+/// These are additive and must not change the behavior of the existing
+/// / helpers,
+/// which IptvController and other read endpoints depend on.
+///
+[SuppressMessage("ReSharper", "VSTHRD003")]
+public static class ApiResults
+{
+ /// Maps a failure to 404 when it is a , otherwise 422.
+ public static IActionResult ToErrorResult(this BaseError error) =>
+ error is NotFoundError
+ ? new NotFoundObjectResult(error.Value)
+ : new UnprocessableEntityObjectResult(error.Value);
+
+ /// Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422.
+ public static IActionResult ToCreatedResult(
+ this Either either,
+ Func location,
+ Func
body) =>
+ either.Match(
+ Left: error => error.ToErrorResult(),
+ Right: value => new CreatedResult(location(value), body(value)));
+
+ /// Right: 200 with body; Left: 404 (NotFound) or 422.
+ public static IActionResult ToUpdatedResult
(this Either either) =>
+ either.Match(
+ Left: error => error.ToErrorResult(),
+ Right: value => (IActionResult)new OkObjectResult(value));
+
+ /// Right(Unit): 204 No Content; Left: 404 (NotFound) or 422.
+ public static IActionResult ToDeletedResult(this Either either) =>
+ either.Match(
+ Left: error => error.ToErrorResult(),
+ Right: _ => (IActionResult)new NoContentResult());
+
+ /// Some: 200 with body; None: 404.
+ public static IActionResult ToGetResult(this Option option) =>
+ option.Match(
+ Some: value => (IActionResult)new OkObjectResult(value),
+ None: () => new NotFoundResult());
+}
diff --git a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs
new file mode 100644
index 000000000..f737b9cbd
--- /dev/null
+++ b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs
@@ -0,0 +1,48 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.Primitives;
+
+namespace ErsatzTV.Filters;
+
+///
+/// Optional API-key authorization for mutating JSON API endpoints (slice #2a).
+/// Reads the configured key from Api:WriteKey. When that key is empty the filter is
+/// a no-op (preserving the current open LAN behavior); when it is set, mutating requests
+/// (POST/PUT/PATCH/DELETE) must present a matching X-Api-Key header or receive 401.
+/// This is fully independent of and only applies to the actions it
+/// decorates — it never affects /iptv/* or any read endpoint.
+///
+public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter
+{
+ public const string HeaderName = "X-Api-Key";
+ public const string ConfigurationKey = "Api:WriteKey";
+
+ public void OnAuthorization(AuthorizationFilterContext context)
+ {
+ string configuredKey = configuration[ConfigurationKey];
+
+ // empty key => API-key auth disabled, endpoint is open
+ if (string.IsNullOrEmpty(configuredKey))
+ {
+ return;
+ }
+
+ string method = context.HttpContext.Request.Method;
+ bool isMutating = HttpMethods.IsPost(method)
+ || HttpMethods.IsPut(method)
+ || HttpMethods.IsPatch(method)
+ || HttpMethods.IsDelete(method);
+
+ if (!isMutating)
+ {
+ return;
+ }
+
+ if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided)
+ || !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal))
+ {
+ context.Result = new UnauthorizedResult();
+ }
+ }
+}
diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs
index 2c8f13bb7..8ffa20081 100644
--- a/ErsatzTV/Startup.cs
+++ b/ErsatzTV/Startup.cs
@@ -309,6 +309,9 @@ public class Startup
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
+ // optional API-key authorization for mutating JSON API endpoints (independent of JWT/OIDC)
+ services.AddScoped();
+
services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining();
diff --git a/docs/rest-api.md b/docs/rest-api.md
new file mode 100644
index 000000000..08ff709a8
--- /dev/null
+++ b/docs/rest-api.md
@@ -0,0 +1,144 @@
+# REST API Design — ersatztv#2
+
+**Status:** Phase 1 signed off → Phase 2 in progress.
+**Tracker:** ersatztv#2. **Sub-issues:** #34 (#2a), #35 (#2b), #36 (#2c), #37 (#2d), #38 (#2e). **Handoff:** `docs/handoffs/rest-api.md`.
+
+## 1. Goals
+
+1. **Programmatic CRUD** for channels / collections / schedules / playouts via REST — enabling **MCP** and direct API automation.
+2. **Foundation for a new UI** built on top of the API (it replaces read paths too, not just writes).
+3. **Retire fragile direct-SQLite writes.** All mutations route through EF Core + existing domain validation, returning proper HTTP codes — no more TPT/enum/NOT-NULL footguns, no container-stop-for-writes.
+
+These goals push three things to the front: **OpenAPI is in-scope** (MCP tool-gen + UI codegen), **stable DTO contracts**, and a **programmatic auth** story.
+
+## 2. What the investigation found (code-grounded)
+
+### 2.1 No migration needed
+Pure CRUD over existing tables. The full schema — including the four `ProgramScheduleItem` **TPT subtype tables** (`ProgramScheduleOneItem`, `ProgramScheduleMultipleItem`, `ProgramScheduleFloodItem`, `ProgramScheduleDurationItem`, via `.UseTptMappingStrategy()`) — already exists. The `migrations` CI job will stay green; no `scripts/add-migration.sh` run is required for any slice.
+
+### 2.2 Every command already exists
+All four resources already have MediatR Create/Update/Delete (+ item ops) handlers, **almost all returning `Either`** — which maps cleanly to HTTP. The API is mostly **thin controllers over existing handlers**, not new business logic.
+
+| Resource | Create | Update | Delete | Item ops |
+|---|---|---|---|---|
+| **Channels** | `CreateChannel` → `Either<_, CreateChannelResult>` | `UpdateChannel` → `Either<_, ChannelViewModel>` | `DeleteChannel` → `Either<_, Unit>` | — |
+| **Collections** | `CreateCollection` → `Either<_, MediaCollectionViewModel>` | `UpdateCollection` → `Either<_, Unit>` | `DeleteCollection` → `Either<_, Unit>` | `AddItemsToCollection`, `RemoveItemsFromCollection` |
+| **Schedules** | `CreateProgramSchedule` → `Either<_, CreateProgramScheduleResult>` | `UpdateProgramSchedule` → `Either<_, UpdateProgramScheduleResult>` | `DeleteProgramSchedule` → `Either<_, Unit>` | `AddProgramScheduleItem`, `ReplaceProgramScheduleItems` (bulk) |
+| **Playouts** | `CreateClassic/Block/Sequential/Scripted/ExternalJsonPlayout` → `Either<_, CreatePlayoutResponse>` | `UpdatePlayout` → `Either<_, PlayoutNameViewModel>` | `DeletePlayout` → `Either<_, Unit>` | reset (exists, via worker channel) |
+
+Net-new command work is essentially **zero** for the happy paths. Effort is in controllers, DTOs, status-code mapping, auth, and tests.
+
+### 2.3 Validation already lives in the handlers (mostly)
+The substantive rules are in-handler, so the API inherits them:
+- Channel **number uniqueness** (`CreateChannelHandler.cs:137`, `UpdateChannelHandler.cs:234` excludes self), name/FK-exists, mirror-source validity.
+- Collection / schedule **name uniqueness** + length.
+- Playout business rules: **"Channel already has one playout"** (`CreateClassicPlayoutHandler.cs:77`), **"Program schedule must have items"** (`:93`).
+
+A few **format/UX checks are page-only** (FluentValidation in `.razor` / `Validators/`) and would NOT reach the API — port these into the handlers (with test-before/after):
+- Channel: `ShowInEpg` must be false when `!IsEnabled`; external-logo-URL format. (`ChannelEditViewModelValidator.cs`)
+- Playout: required-field shape per kind. (`PlayoutEditViewModelValidator.cs`) — mostly redundant with handler checks; confirm parity.
+
+### 2.4 Existing controller conventions (match these)
+- Controllers in `ErsatzTV/Controllers/Api/`, base `ControllerBase`, **routes declared per-action** (full path, no controller `[Route]` prefix).
+- JSON: **Newtonsoft**, **camelCase** (`CustomNamingStrategy`, with `FFmpegProfileId` special-cased), **enums as strings** (`StringEnumConverter`), `NullValueHandling.Ignore` (`Startup.cs:294`).
+- **OpenAPI already wired**: `AddOpenApi("v1")` + **Scalar UI at `/docs`** (`Startup.cs:136`, `:664`); endpoints opt in via `[EndpointGroupName("general")]`.
+- `.ToActionResult()` extensions map: `Either` Left→**400**, Right→**200**; `Option` None→**404**, Some→**200**; `Validation` Failure→**400**. **No 201/422 today.**
+
+### 2.5 Two existing CRUD controllers are non-idiomatic → standardize
+`FFmpegProfileController` and `SmartCollectionController` use verb-in-path (`/new`, `/update`, `/delete/{id}`). Per the modernization decision, retrofit them to idiomatic REST as we work the related slices, gated by characterization tests.
+
+### 2.6 Auth today
+A **JWT bearer scheme** exists (`JwtHelper`, `JwtOnlyScheme` policy, `access_token` query-param support, 1-day tokens) but is wired **only to `IptvController`** via `ConditionalIptvAuthorizeFilter` (enforced only when `JWT:IssuerSigningKey` is set). `/api/*` currently has **no auth**; CORS is **AllowAll**.
+
+## 3. Conventions (decided)
+
+### 3.1 Routing — idiomatic REST
+```
+POST /api/channels GET /api/channels GET /api/channels/{id}
+PUT /api/channels/{id} DELETE /api/channels/{id}
+
+POST /api/collections GET /api/collections GET /api/collections/{id}
+PUT /api/collections/{id} DELETE /api/collections/{id}
+POST /api/collections/{id}/items
+DELETE /api/collections/{id}/items/{mediaItemId}
+
+POST /api/schedules GET /api/schedules GET /api/schedules/{id}
+PUT /api/schedules/{id} DELETE /api/schedules/{id}
+GET /api/schedules/{id}/items
+POST /api/schedules/{id}/items
+PUT /api/schedules/{id}/items # bulk replace (maps to ReplaceProgramScheduleItems)
+DELETE /api/schedules/{id}/items/{itemId}
+
+POST /api/playouts GET /api/playouts GET /api/playouts/{id}
+DELETE /api/playouts/{id}
+POST /api/channels/{number}/playout/reset # exists; keep
+```
+`{id}` = numeric entity id. Channel reset keeps its existing `{number}` route (already public).
+
+### 3.2 Status codes
+| Outcome | Code |
+|---|---|
+| Create success | **201 Created** + `Location` header + body |
+| Update success | **200 OK** + body |
+| Delete success | **204 No Content** |
+| Read success | **200 OK** |
+| Resource not found | **404** |
+| Validation failure (bad field, uniqueness, business rule) | **422 Unprocessable Entity** + error body |
+| Malformed request (unparseable JSON, missing required) | **400** |
+
+The current `.ToActionResult()` only yields 200/400/404, so we add **richer mapping helpers** (a deliberate, stated deviation — proper HTTP semantics matter for MCP/UI consumers). **Foundation decision (#2a):** to distinguish 404 from 422, handlers fold "does not exist" into `BaseError` today (flat). Introduce a lightweight typed error (e.g. `NotFoundError : BaseError`) in `ErsatzTV.Core`; mapping helper returns 404 for it, 422 for other `BaseError`s. This is a Core/error-layer change (not an EF model change) — test-before/after applies where it touches existing handlers. If it proves to sprawl, fall back to per-controller existence checks and backlog the typed-error refactor.
+
+### 3.3 DTOs
+- **Requests:** dedicated request DTOs (bound from JSON) → mapped to existing Commands. Stable external contract; decouples the wire shape from internal command records.
+- **Responses:** **reuse existing `*ViewModel`s** (already the camelCase read contract used by current GETs). One contract, already proven.
+
+### 3.4 Validation
+Reuse handler validation. Port the page-only checks in §2.3 into handlers so the API reaches parity with the Blazor UI. No validation logic in controllers.
+
+### 3.5 Auth — dedicated API key for writes (decoupled from IPTV)
+**Mechanism:** a dedicated **API key** for mutations — a new config key (e.g. `Api__WriteKey`) checked by a filter applied **only to POST/PUT/DELETE on `/api/*`**. Enforced only when the key is configured (LAN-open default preserved); reads and all `/iptv/*` stay open.
+
+**Why not reuse the JWT scheme (important):** JWT is gated by a single global toggle, `JwtHelper.IsEnabled` ← `JWT:IssuerSigningKey` (`Startup.cs:166`). That **same toggle also gates the IPTV endpoints** (`/iptv/channels.m3u`, `/iptv/xmltv.xml`, streams — `ConditionalIptvAuthorizeFilter:18`) which **Jellyfin and Dispatcharr consume**. Enabling JWT to protect writes would force token auth onto those media feeds — and JWT tokens **expire in 1 day** (`JwtHelper.cs:27`), unsuitable for a standing tuner URL. A dedicated API key **decouples write-auth from media-consumer auth**: turning it on changes **nothing** for Jellyfin/Dispatcharr.
+
+**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one filter + one config key); a standard machine-API pattern.
+
+**Backlog:** tighten CORS for mutation routes if writes are exposed beyond LAN.
+
+### 3.6 OpenAPI / discoverability
+New endpoints carry `[EndpointGroupName("general")]` → appear in the existing `v1` doc + Scalar `/docs`. This *is* the MCP/UI enabler (tool-gen + client codegen). Keep summaries/`[ProducesResponseType]` accurate per action.
+
+### 3.7 Other conventions
+- **Absolute URLs** in responses (logos/streams) use the **request-derived host**, same as M3U/XMLTV (`docs/m3u-xmltv.md`) — never bake a host. (Carries the #1 lesson forward.)
+- **List endpoints:** add simple pagination where the underlying query supports it; defer rich filtering/sorting (backlog — the new UI will want it).
+- **DELETE idempotency:** return 404 if the entity is already absent (clear over silently-204). EF cascade handles dependents (e.g. Channel→Playout cascade, ProgramSchedule→Items cascade) — integration tests prove this per slice.
+
+## 4. Standardization scope
+
+- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green).
+- Opportunistic-fix policy: backlog/document unrelated issues found en route; fix-in-place only when limited-scope + useful-now, or when deferring would force rework of the new code.
+
+## 5. Increment plan (sub-issues under #2 as tracker)
+
+One slice = one branch = one PR. PR runs `test` + `migrations` (both required); merge to `main` adds `build` + smoke/E2E. Tests with **NUnit + Shouldly + NSubstitute**, run locally `TZ=UTC`.
+
+- **#2a (#34) — API foundation + Channels (pattern-setter).** Shared conventions: idiomatic routing, status-code mapping helpers (incl. `NotFoundError` typed error + 201/422), request-DTO pattern, response = ViewModels, `[EndpointGroupName("general")]`, API-key mutation auth filter (decoupled from the IPTV JWT toggle). Then Channels CRUD (POST/PUT/DELETE, confirm/standardize GET list + add GET `/{id}`). Port page-only channel validations into handlers. Tests: handler unit (success + 404/422), controller status-mapping, create→read→delete EF integration.
+- **#2b (#35) — Collections** CRUD + add/remove items; **retrofit `SmartCollectionController`** to idiomatic (characterization tests first).
+- **#2c (#36) — Schedules** CRUD + schedule items (**TPT-heavy** — the one to budget for). Design the item DTO around the `PlayoutMode` discriminator (One/Multiple/Flood/Duration); reuse `AddProgramScheduleItem` / `ReplaceProgramScheduleItems`. Integration test proving the correct TPT subtype rows are written.
+- **#2d (#37) — Playouts** create (Classic + 4 kinds via discriminated DTO) / delete; keep existing reset. Validation already strong.
+- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a–#2d.
+
+**Sequencing:** #2a first (sets every convention the others copy), then #2b–#2d in parallel-able order, #2e last. Each slice ships its own read endpoints so the new UI gains coverage incrementally.
+
+## 6. Testing strategy (per slice)
+- Handler unit tests: success + each failure (not-found→404, validation→422).
+- Controller tests: status-code/`Location`/DTO mapping.
+- EF integration test (real SQLite): create→read→delete proving TPT subtype creation + cascade correctness.
+- Characterization tests before any existing-controller retrofit.
+- Lean on existing nets: architecture tests (#12 — controllers in `ErsatzTV`, logic in `ErsatzTV.Application`), M3U goldens (#11) if a change touches `ToM3U`.
+
+## 7. Open items / backlog seeds
+- CORS tightening for mutation routes (if exposed beyond LAN).
+- List-endpoint filtering/sorting/pagination depth (new-UI driven).
+- API-key provisioning UX for MCP/UI write clients (how a caller obtains/sets `Api__WriteKey`).
+- Decide whether `NotFoundError` typed-error becomes a repo-wide convention or stays API-local.
+```