Merge pull request 'feat(69): auto-tuning backend — enumerate library metadata, preview + bulk-create channels (PR1)' (#379) from feat/69-auto-tuning into main
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m44s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped

This commit was merged in pull request #379.
This commit is contained in:
2026-07-16 20:35:10 +00:00
22 changed files with 2576 additions and 1 deletions
@@ -0,0 +1,44 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
public static class AutoTuneAxisMap
{
// Server-owned Lucene smart-collection query for an axis value.
public static string GenerateQuery(AutoTuneAxis axis, string value)
{
string escaped = EscapeLuceneValue(value);
return axis switch
{
AutoTuneAxis.TvShow => $"type:episode AND show_title:\"{escaped}\"",
AutoTuneAxis.TvGenre => $"type:episode AND genre:\"{escaped}\"",
AutoTuneAxis.MovieGenre => $"type:movie AND genre:\"{escaped}\"",
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
}
// Human-facing channel name. Movie-genre channels are suffixed so a genre that exists for
// both TV and movies ("Comedy" vs "Comedy Movies") does not produce two identically-named channels.
public static string GenerateName(AutoTuneAxis axis, string value) =>
axis switch
{
AutoTuneAxis.TvShow => value,
AutoTuneAxis.TvGenre => value,
AutoTuneAxis.MovieGenre => $"{value} Movies",
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// PseudoTV per-type defaults: single-show channels play in episode order; genre channels shuffle.
public static PlaybackOrder PlaybackOrderFor(AutoTuneAxis axis) =>
axis switch
{
AutoTuneAxis.TvShow => PlaybackOrder.SeasonEpisode,
AutoTuneAxis.TvGenre => PlaybackOrder.Shuffle,
AutoTuneAxis.MovieGenre => PlaybackOrder.Shuffle,
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
public static string EscapeLuceneValue(string value) =>
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
}
@@ -0,0 +1,27 @@
using System.Globalization;
namespace ErsatzTV.Application.Channels;
public static class AutoTuneNumberAllocator
{
// Allocate `count` sequential integer channel numbers starting at `startingNumber`,
// skipping any number already present in `existingNumbers`. Channel.Number is a string,
// so numbers are returned as invariant-culture strings.
public static List<string> Allocate(int startingNumber, int count, ISet<string> existingNumbers)
{
var result = new List<string>(count);
int next = startingNumber;
while (result.Count < count)
{
string candidate = next.ToString(CultureInfo.InvariantCulture);
if (!existingNumbers.Contains(candidate))
{
result.Add(candidate);
}
next++;
}
return result;
}
}
@@ -0,0 +1,35 @@
using ErsatzTV.Core.Domain;
using MediatR;
namespace ErsatzTV.Application.Channels;
public record CreateAutoTunedChannels(
int TemplateId,
string Group,
List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
public record AutoTuneChannelSelection(
AutoTuneAxis Axis,
string Value,
string Name,
string Number);
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
{
public int CreatedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Created);
public int SkippedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Skipped);
public int FailedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Failed);
}
public record AutoTuneChannelOutcome(
string Name,
AutoTuneOutcomeStatus Status,
int? ChannelId,
string Reason);
public enum AutoTuneOutcomeStatus
{
Created,
Skipped,
Failed
}
@@ -0,0 +1,116 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels;
public class CreateAutoTunedChannelsHandler(ISender mediator)
: IRequestHandler<CreateAutoTunedChannels, AutoTuneResult>
{
private const string NumberTakenError = "Channel number must be unique";
private const string DefaultGroup = "Auto-Tuned";
public async Task<AutoTuneResult> Handle(
CreateAutoTunedChannels request,
CancellationToken cancellationToken)
{
string group = string.IsNullOrWhiteSpace(request.Group) ? DefaultGroup : request.Group.Trim();
var outcomes = new List<AutoTuneChannelOutcome>();
foreach (AutoTuneChannelSelection selection in request.Channels ?? [])
{
outcomes.Add(await CreateOne(request.TemplateId, group, selection, cancellationToken));
}
return new AutoTuneResult(outcomes);
}
private async Task<AutoTuneChannelOutcome> CreateOne(
int templateId,
string group,
AutoTuneChannelSelection selection,
CancellationToken cancellationToken)
{
string name = (selection.Name ?? string.Empty).Trim();
if (name.Length is 0 or > 50)
{
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name");
}
string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value);
PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
// 1. Create the smart collection that drives this channel.
Either<BaseError, SmartCollectionViewModel> scResult =
await mediator.Send(new CreateSmartCollection(query, name), cancellationToken);
SmartCollectionViewModel smartCollection = null;
foreach (BaseError error in scResult.LeftToSeq())
{
return new AutoTuneChannelOutcome(
name, AutoTuneOutcomeStatus.Failed, null, $"Smart collection: {error.Value}");
}
foreach (SmartCollectionViewModel vm in scResult.RightToSeq())
{
smartCollection = vm;
}
// 2. Create the channel from a single-item lineup referencing the smart collection.
var command = new CreateChannelFromLineup(
name,
selection.Number,
group,
string.Empty,
ArtworkContentTypeModel.None,
IsEnabled: true,
ShowInEpg: true,
templateId,
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order),
[
new CreateChannelFromLineupItem(
LibraryBrowseMediaType.SmartCollection,
CollectionType.SmartCollection,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: smartCollection.Id,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null)
]);
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
await mediator.Send(command, cancellationToken);
foreach (BaseError error in channelResult.LeftToSeq())
{
// Roll back the smart collection we just created so a retry of this
// axis/value doesn't fail on SmartCollection-name uniqueness. Best-effort;
// the primary outcome below is still Skipped/Failed regardless of the delete result.
// Swallow any exception (not just an Either.Left) so a transient infra failure
// during rollback never aborts this channel's outcome or the batch; the
// orphaned SmartCollection is an acceptable degraded outcome.
try
{
await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken);
}
catch (Exception)
{
// intentionally ignored; see comment above
}
AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal)
? AutoTuneOutcomeStatus.Skipped
: AutoTuneOutcomeStatus.Failed;
return new AutoTuneChannelOutcome(name, status, null, error.Value);
}
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
}
}
@@ -0,0 +1,19 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels;
public record PreviewAutoTuneChannels(
List<AutoTuneAxis> Axes,
int MinItems,
int StartingNumber) : IRequest<Either<BaseError, List<AutoTuneProposal>>>;
public record AutoTuneProposal(
AutoTuneAxis Axis,
string Value,
string Name,
string Number,
int ItemCount,
bool AlreadyExists);
@@ -0,0 +1,144 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class PreviewAutoTuneChannelsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<PreviewAutoTuneChannels, Either<BaseError, List<AutoTuneProposal>>>
{
public async Task<Either<BaseError, List<AutoTuneProposal>>> Handle(
PreviewAutoTuneChannels request,
CancellationToken cancellationToken)
{
if (request.Axes is null || request.Axes.Count == 0)
{
return BaseError.New("At least one axis is required");
}
if (request.MinItems < 1)
{
return BaseError.New("Minimum items must be at least 1");
}
if (request.StartingNumber < 1)
{
return BaseError.New("Starting channel number must be at least 1");
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// Enumerate (axis, value, count) triples per requested axis, preserving axis order.
var raw = new List<(AutoTuneAxis Axis, string Value, int Count)>();
foreach (AutoTuneAxis axis in request.Axes.Distinct())
{
raw.AddRange(await EnumerateAxis(dbContext, axis, request.MinItems, cancellationToken));
}
System.Collections.Generic.HashSet<string> existingNumbers = (await dbContext.Channels.AsNoTracking()
.Select(c => c.Number).ToListAsync(cancellationToken))
.ToHashSet();
System.Collections.Generic.HashSet<string> existingNames = (await dbContext.Channels.AsNoTracking()
.Select(c => c.Name).ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Drop entries whose generated name would be rejected at create time (Channel name <= 50
// chars) before number allocation, so numbers aren't wasted on proposals that can never
// be created.
List<(AutoTuneAxis Axis, string Value, int Count, string Name)> survivors = raw
.Select(r => (r.Axis, r.Value, r.Count, Name: AutoTuneAxisMap.GenerateName(r.Axis, r.Value)))
.Where(r => r.Name.Length <= 50)
.ToList();
List<string> numbers = AutoTuneNumberAllocator.Allocate(
request.StartingNumber, survivors.Count, existingNumbers);
var proposals = new List<AutoTuneProposal>(survivors.Count);
for (int i = 0; i < survivors.Count; i++)
{
(AutoTuneAxis axis, string value, int count, string name) = survivors[i];
proposals.Add(new AutoTuneProposal(
axis, value, name, numbers[i], count, existingNames.Contains(name)));
}
return proposals;
}
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateAxis(
TvContext dbContext, AutoTuneAxis axis, int minItems, CancellationToken cancellationToken) =>
axis switch
{
AutoTuneAxis.TvShow => await EnumerateTvShows(dbContext, minItems, cancellationToken),
AutoTuneAxis.TvGenre => await EnumerateEpisodeGenres(dbContext, minItems, cancellationToken),
AutoTuneAxis.MovieGenre => await EnumerateMovieGenres(dbContext, minItems, cancellationToken),
_ => []
};
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateTvShows(
TvContext dbContext, int minItems, CancellationToken cancellationToken)
{
// Episode count per show id (Episode -> Season -> ShowId). Proven query style from LibraryBrowseItemMapper.
Dictionary<int, int> episodeCounts = await dbContext.Episodes.AsNoTracking()
.GroupBy(e => e.Season.ShowId)
.Select(g => new { ShowId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
var showTitles = await dbContext.ShowMetadata.AsNoTracking()
.Select(sm => new { sm.ShowId, sm.Title })
.ToListAsync(cancellationToken);
// Collapse shows that share a title (the generated show_title query matches them together).
var byTitle = new Dictionary<string, int>();
foreach (var row in showTitles)
{
if (string.IsNullOrWhiteSpace(row.Title))
{
continue;
}
episodeCounts.TryGetValue(row.ShowId, out int count);
byTitle[row.Title] = byTitle.GetValueOrDefault(row.Title) + count;
}
return byTitle
.Where(kv => kv.Value >= minItems)
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv => (AutoTuneAxis.TvShow, kv.Key, kv.Value))
.ToList();
}
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateEpisodeGenres(
TvContext dbContext, int minItems, CancellationToken cancellationToken)
{
var counts = await dbContext.EpisodeMetadata.AsNoTracking()
.SelectMany(m => m.Genres)
.GroupBy(g => g.Name)
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
.ToListAsync(cancellationToken);
return counts
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(c => (AutoTuneAxis.TvGenre, c.Name, c.Count))
.ToList();
}
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateMovieGenres(
TvContext dbContext, int minItems, CancellationToken cancellationToken)
{
var counts = await dbContext.MovieMetadata.AsNoTracking()
.SelectMany(m => m.Genres)
.GroupBy(g => g.Name)
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
.ToListAsync(cancellationToken);
return counts
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(c => (AutoTuneAxis.MovieGenre, c.Name, c.Count))
.ToList();
}
}
@@ -0,0 +1,22 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
public record AutoTuneProposalResponseModel(
string Axis,
string Value,
string Name,
string Number,
int ItemCount,
bool AlreadyExists);
public record AutoTuneChannelResultModel(
string Name,
string Status,
int? ChannelId,
string? Reason);
public record AutoTuneResultResponseModel(
List<AutoTuneChannelResultModel> Results,
int CreatedCount,
int SkippedCount,
int FailedCount);
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public enum AutoTuneAxis
{
TvShow = 0,
TvGenre = 1,
MovieGenre = 2
}
@@ -0,0 +1,44 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class AutoTuneAxisMapTests
{
[Test]
public void GenerateQuery_Builds_Expected_Lucene()
{
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "The Office")
.ShouldBe("type:episode AND show_title:\"The Office\"");
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvGenre, "Comedy")
.ShouldBe("type:episode AND genre:\"Comedy\"");
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.MovieGenre, "Action")
.ShouldBe("type:movie AND genre:\"Action\"");
}
[Test]
public void GenerateQuery_Escapes_Quotes_And_Backslashes()
{
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "Bob\"s \\Show")
.ShouldBe("type:episode AND show_title:\"Bob\\\"s \\\\Show\"");
}
[Test]
public void GenerateName_Suffixes_Movie_Genres_Only()
{
AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvShow, "The Office").ShouldBe("The Office");
AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvGenre, "Comedy").ShouldBe("Comedy");
AutoTuneAxisMap.GenerateName(AutoTuneAxis.MovieGenre, "Action").ShouldBe("Action Movies");
}
[Test]
public void PlaybackOrderFor_Uses_PseudoTV_Defaults()
{
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvShow).ShouldBe(PlaybackOrder.SeasonEpisode);
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvGenre).ShouldBe(PlaybackOrder.Shuffle);
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.MovieGenre).ShouldBe(PlaybackOrder.Shuffle);
}
}
@@ -0,0 +1,31 @@
using System.Collections.Generic;
using ErsatzTV.Application.Channels;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class AutoTuneNumberAllocatorTests
{
[Test]
public void Allocate_Skips_Taken_Numbers()
{
var existing = new HashSet<string> { "500", "502" };
List<string> result = AutoTuneNumberAllocator.Allocate(500, 3, existing);
result.ShouldBe(new List<string> { "501", "503", "504" });
}
[Test]
public void Allocate_From_Empty_Is_Sequential()
{
List<string> result = AutoTuneNumberAllocator.Allocate(1, 3, new HashSet<string>());
result.ShouldBe(new List<string> { "1", "2", "3" });
}
[Test]
public void Allocate_Zero_Count_Is_Empty()
{
AutoTuneNumberAllocator.Allocate(500, 0, new HashSet<string>()).ShouldBeEmpty();
}
}
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class CreateAutoTunedChannelsHandlerTests
{
private ISender _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<ISender>();
// Smart collection creation always succeeds, echoing an incrementing id.
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var cmd = ci.Arg<CreateSmartCollection>();
return (Either<BaseError, SmartCollectionViewModel>)
new SmartCollectionViewModel(7, cmd.Name, cmd.Query);
});
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, LanguageExt.Unit>)LanguageExt.Unit.Default);
}
[Test]
public async Task Creates_Selected_Channels_And_Reports_Counts()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
new CreateChannelFromLineupResponseModel(88, null, 1, 2));
var result = await Handle(new CreateAutoTunedChannels(
TemplateId: 3, Group: "Auto-Tuned",
new List<AutoTuneChannelSelection>
{
new(AutoTuneAxis.TvShow, "The Office", "The Office", "500")
}));
result.CreatedCount.ShouldBe(1);
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Created);
result.Results[0].ChannelId.ShouldBe(88);
// Smart collection built with the server-generated query.
await _mediator.Received().Send(
Arg.Is<CreateSmartCollection>(c => c.Query == "type:episode AND show_title:\"The Office\""),
Arg.Any<CancellationToken>());
// Channel created referencing the smart collection id, number, and SeasonEpisode order.
await _mediator.Received().Send(
Arg.Is<CreateChannelFromLineup>(c =>
c.Number == "500" &&
c.TemplateId == 3 &&
c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode &&
c.Lineup.Count == 1 &&
c.Lineup[0].CollectionType == CollectionType.SmartCollection &&
c.Lineup[0].SmartCollectionId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Number_Collision_Is_Skipped_Not_Failed()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("Channel number must be unique"));
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
result.SkippedCount.ShouldBe(1);
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped);
}
[Test]
public async Task Number_Collision_Rolls_Back_Orphaned_SmartCollection()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("Channel number must be unique"));
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped);
await _mediator.Received().Send(
Arg.Is<DeleteSmartCollection>(d => d.SmartCollectionId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Other_Errors_Are_Failed()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("FFmpegProfile 9 does not exist."));
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
result.FailedCount.ShouldBe(1);
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed);
}
private Task<AutoTuneResult> Handle(CreateAutoTunedChannels request) =>
new CreateAutoTunedChannelsHandler(_mediator).Handle(request, CancellationToken.None);
}
@@ -0,0 +1,152 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class PreviewAutoTuneChannelsHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Enumerates_Shows_Above_MinItems_With_Counts_And_Numbers()
{
// Show 1 "The Office" with 3 episodes; Show 2 "Short" with 1 episode.
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102, 103 });
await SeedShow(showId: 2, title: "Short", seasonId: 22, episodeIds: new[] { 201 });
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 2, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals.Count.ShouldBe(1);
proposals[0].Value.ShouldBe("The Office");
proposals[0].Name.ShouldBe("The Office");
proposals[0].ItemCount.ShouldBe(3);
proposals[0].Number.ShouldBe("500");
proposals[0].AlreadyExists.ShouldBeFalse();
}
[Test]
public async Task Flags_AlreadyExists_By_Channel_Name_And_Skips_Taken_Numbers()
{
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102 });
await SeedChannel(number: "500", name: "The Office");
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 1, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals[0].AlreadyExists.ShouldBeTrue();
proposals[0].Number.ShouldBe("501"); // 500 is taken
}
[Test]
public async Task Enumerates_Movie_Genres_With_Suffixed_Names()
{
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: "Action");
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: "Action");
await SeedMovieWithGenre(movieId: 3, metadataId: 3, genre: "Drama");
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals.Count.ShouldBe(1); // Drama has only 1 movie, below minItems
proposals[0].Value.ShouldBe("Action");
proposals[0].Name.ShouldBe("Action Movies");
proposals[0].ItemCount.ShouldBe(2);
}
[Test]
public async Task Excludes_Proposals_Whose_Generated_Name_Exceeds_50_Chars()
{
// "Movies" suffix (7 chars) pushes this over the 50-char Channel.Name limit.
const string longGenre = "A Really Really Long And Overly Descriptive Genre"; // 50 chars, +" Movies" = 57
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: longGenre);
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: longGenre);
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals.ShouldNotContain(p => p.Value == longGenre);
proposals.ShouldBeEmpty();
}
[Test]
public async Task Empty_Axes_Is_Error()
{
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis>(), MinItems: 5, StartingNumber: 500));
LeftOf(result).Value.ShouldContain("axis");
}
private Task<Either<BaseError, List<AutoTuneProposal>>> Handle(PreviewAutoTuneChannels request) =>
new PreviewAutoTuneChannelsHandler(_db.Factory).Handle(request, CancellationToken.None);
private async Task SeedShow(int showId, string title, int seasonId, int[] episodeIds)
{
await using TvContext context = _db.CreateContext();
context.Shows.Add(new Show
{
Id = showId,
ShowMetadata = new List<ShowMetadata> { new() { ShowId = showId, Title = title } },
Seasons = new List<Season>
{
new()
{
Id = seasonId, ShowId = showId,
Episodes = episodeIds.Select(id => new Episode { Id = id, SeasonId = seasonId }).ToList()
}
}
});
await context.SaveChangesAsync();
}
private async Task SeedMovieWithGenre(int movieId, int metadataId, string genre)
{
await using TvContext context = _db.CreateContext();
context.Movies.Add(new Movie
{
Id = movieId,
MovieMetadata = new List<MovieMetadata>
{
new() { Id = metadataId, MovieId = movieId, Title = $"Movie {movieId}",
Genres = new List<Genre> { new() { Name = genre } } }
}
});
await context.SaveChangesAsync();
}
private async Task SeedChannel(string number, string name)
{
await using TvContext context = _db.CreateContext();
context.Channels.Add(new Channel(System.Guid.NewGuid())
{
Number = number, Name = name, Group = "Test", SortNumber = double.Parse(number)
});
await context.SaveChangesAsync();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> e) =>
e.Match(Left: x => x, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> e) =>
e.Match(Left: x => throw new AssertionException($"Expected Right, got {x.Value}"), Right: r => r);
}
@@ -214,6 +214,47 @@ public class ChannelController(
return result.ToDeletedResult();
}
[HttpPost("/api/v1/channels/auto-tune/preview", Name = "PreviewAutoTuneChannels")]
[Tags("Channels")]
[EndpointSummary("Preview auto-tuned channels")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<AutoTuneProposalResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> PreviewAutoTune(
[Required][FromBody] PreviewAutoTuneChannelsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, List<AutoTuneProposal>> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.Match<IActionResult>(
Left: error => error.ToErrorResult(),
Right: proposals => new OkObjectResult(proposals.Select(ProjectToResponseModel).ToList()));
}
[HttpPost("/api/v1/channels/auto-tune", Name = "CreateAutoTunedChannels")]
[Tags("Channels")]
[EndpointSummary("Create auto-tuned channels")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(AutoTuneResultResponseModel), StatusCodes.Status200OK)]
public async Task<IActionResult> CreateAutoTuned(
[Required][FromBody] CreateAutoTunedChannelsRequest request,
CancellationToken cancellationToken)
{
AutoTuneResult result = await mediator.Send(request.ToCommand(), cancellationToken);
return new OkObjectResult(ProjectToResponseModel(result));
}
private static AutoTuneProposalResponseModel ProjectToResponseModel(AutoTuneProposal p) =>
new(p.Axis.ToString(), p.Value, p.Name, p.Number, p.ItemCount, p.AlreadyExists);
private static AutoTuneResultResponseModel ProjectToResponseModel(AutoTuneResult r) =>
new(
r.Results.Select(o => new AutoTuneChannelResultModel(
o.Name, o.Status.ToString(), o.ChannelId, o.Reason)).ToList(),
r.CreatedCount,
r.SkippedCount,
r.FailedCount);
[HttpPost("/api/v1/channels/{id:int}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
@@ -0,0 +1,30 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record PreviewAutoTuneChannelsRequest(
List<AutoTuneAxis> Axes,
int MinItems,
int StartingNumber)
{
public PreviewAutoTuneChannels ToCommand() => new(Axes, MinItems, StartingNumber);
}
public record CreateAutoTunedChannelsRequest(
int TemplateId,
string Group,
List<AutoTunedChannelRequest> Channels)
{
public CreateAutoTunedChannels ToCommand() =>
new(TemplateId, Group, (Channels ?? []).Select(c => c.ToCommand()).ToList());
}
public record AutoTunedChannelRequest(
AutoTuneAxis Axis,
string Value,
string Name,
string Number)
{
public AutoTuneChannelSelection ToCommand() => new(Axis, Value, Name, Number);
}
+374
View File
@@ -2364,6 +2364,193 @@
]
}
},
"/api/v1/channels/auto-tune/preview": {
"post": {
"tags": [
"Channels"
],
"summary": "Preview auto-tuned channels",
"operationId": "PreviewAutoTuneChannels",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/PreviewAutoTuneChannelsRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreviewAutoTuneChannelsRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PreviewAutoTuneChannelsRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/PreviewAutoTuneChannelsRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AutoTuneProposalResponseModel"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AutoTuneProposalResponseModel"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AutoTuneProposalResponseModel"
}
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "API key missing or invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"400": {
"description": "Request validation failed (model binding or FluentValidation).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
},
"security": [
{ }
]
}
},
"/api/v1/channels/auto-tune": {
"post": {
"tags": [
"Channels"
],
"summary": "Create auto-tuned channels",
"operationId": "CreateAutoTunedChannels",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/CreateAutoTunedChannelsRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateAutoTunedChannelsRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/CreateAutoTunedChannelsRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/CreateAutoTunedChannelsRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/AutoTuneResultResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/AutoTuneResultResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/AutoTuneResultResponseModel"
}
}
}
},
"401": {
"description": "API key missing or invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"400": {
"description": "Request validation failed (model binding or FluentValidation).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
},
"security": [
{ }
]
}
},
"/api/v1/channels/{id}/playout/reset": {
"post": {
"tags": [
@@ -22535,6 +22722,137 @@
}
}
},
"AutoTuneAxis": {
"enum": [
"TvShow",
"TvGenre",
"MovieGenre"
],
"type": "string"
},
"AutoTuneChannelResultModel": {
"required": [
"name",
"status",
"channelId",
"reason"
],
"type": "object",
"properties": {
"name": {
"type": "string"
},
"status": {
"type": "string"
},
"channelId": {
"type": [
"null",
"integer"
],
"format": "int32"
},
"reason": {
"type": [
"null",
"string"
]
}
}
},
"AutoTunedChannelRequest": {
"required": [
"axis",
"value",
"name",
"number"
],
"type": "object",
"properties": {
"axis": {
"$ref": "#/components/schemas/AutoTuneAxis"
},
"value": {
"type": [
"null",
"string"
]
},
"name": {
"type": [
"null",
"string"
]
},
"number": {
"type": [
"null",
"string"
]
}
}
},
"AutoTuneProposalResponseModel": {
"required": [
"axis",
"value",
"name",
"number",
"itemCount",
"alreadyExists"
],
"type": "object",
"properties": {
"axis": {
"type": "string"
},
"value": {
"type": "string"
},
"name": {
"type": "string"
},
"number": {
"type": "string"
},
"itemCount": {
"type": "integer",
"format": "int32"
},
"alreadyExists": {
"type": "boolean"
}
}
},
"AutoTuneResultResponseModel": {
"required": [
"results",
"createdCount",
"skippedCount",
"failedCount"
],
"type": "object",
"properties": {
"results": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AutoTuneChannelResultModel"
}
},
"createdCount": {
"type": "integer",
"format": "int32"
},
"skippedCount": {
"type": "integer",
"format": "int32"
},
"failedCount": {
"type": "integer",
"format": "int32"
}
}
},
"BlockGroupResponseModel": {
"required": [
"id",
@@ -23616,6 +23934,35 @@
}
}
},
"CreateAutoTunedChannelsRequest": {
"required": [
"templateId",
"group",
"channels"
],
"type": "object",
"properties": {
"templateId": {
"type": "integer",
"format": "int32"
},
"group": {
"type": [
"null",
"string"
]
},
"channels": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/AutoTunedChannelRequest"
}
}
}
},
"CreateBlockGroupRequest": {
"required": [
"name"
@@ -28295,6 +28642,33 @@
}
}
},
"PreviewAutoTuneChannelsRequest": {
"required": [
"axes",
"minItems",
"startingNumber"
],
"type": "object",
"properties": {
"axes": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/AutoTuneAxis"
}
},
"minItems": {
"type": "integer",
"format": "int32"
},
"startingNumber": {
"type": "integer",
"format": "int32"
}
}
},
"ProblemDetails": {
"type": "object",
"properties": {
+9
View File
@@ -307,6 +307,15 @@ After any controller/DTO change:
`endpoint-index.md` from source and fails if any of the three is stale in the diff. This is the
mechanized half of the "docs-update in the same PR" rule for the API contract.
**Endpoint inventory additions (#69, auto-tuning PR1 backend)**: two `ChannelController` actions,
both requiring the standard credential (session-or-key, per §9 — no `[SkipApiAuthorization]`/
`[RequiresAuthentication]` override):
| Method | Path | Operation | Summary |
|---|---|---|---|
| POST | `/api/v1/channels/auto-tune/preview` | `PreviewAutoTuneChannels` | Preview auto-tuned channels |
| POST | `/api/v1/channels/auto-tune` | `CreateAutoTunedChannels` | Create auto-tuned channels |
**Resolved wart (#287)**: `DayOfWeek` previously serialized as an integer in the OpenAPI schema while
the runtime JSON payload is the enum's **name string** ("Sunday".."Saturday"). It is now added to
`Startup.UseStringEnumSchemas`'s hand-list, so the "v1" schema emits it as a **string enum** matching
+14
View File
@@ -81,6 +81,7 @@ in-file entries.
- [2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)](#2026-07-13--scheduling-api-hardening-null-name-500s-duplicate-template-items-unreachable-404-172)
- [2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)](#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299)
- [2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)](#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340)
- [2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)](#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69)
---
@@ -1026,3 +1027,16 @@ was explicitly out of scope.
Core `PathBase` (request routing/prefix); it does not advertise a scheme+host. `iptv.base_url` is the
separate, DB-stored (`ConfigElementKey.IptvBaseUrl`, **no EF migration**) advertised origin for IPTV
output. See `docs/m3u-xmltv.md` → "IPTV base URL (#340)".
## 2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)
Auto-tuning (#69) generates channels from library metadata (TV Show / TV Genre / Movie Genre).
Enumeration for the preview uses EF distinct+count queries (exact counts drive the min-items
threshold and preview display); each created channel is backed by a newly-created **SmartCollection**
(live Lucene query) so channels keep tracking the library as it grows. Query authorship is
server-side only — the client passes `{axis, value}`, never a Lucene string. Coexistence is additive:
the batch gets a reserved starting channel number (skipping taken numbers), a proposed channel whose
name already exists is flagged and de-selected by default, and existing channels are never mutated.
Bulk create loops the #63 `CreateChannelFromLineup` primitive via `ISender` and returns a per-channel
Created/Skipped/Failed outcome. Known MVP limitation: the generated SmartCollection is named after the
channel; a name collision with an existing SmartCollection surfaces as a per-channel Failed outcome.
+7
View File
@@ -12,6 +12,13 @@ external-JSON) — a playout belongs to exactly one **channel**. Channels are ex
(Jellyfin, Dispatcharr) as an M3U playlist + XMLTV guide, and streamed on demand via FFmpeg, all
under the `/iptv/*` routes (`ErsatzTV/Controllers/IptvController.cs`).
**Auto-tuning (#69)** is a second, automatic-first channel-creation mode alongside the manual
single-channel flow: it enumerates library metadata along an axis (TV Show / TV Genre / Movie
Genre), previews the proposed channels (name, allocated number, item count) with no writes, and
bulk-creates the selected ones via the #63 composite `CreateChannelFromLineup` primitive. Each
generated channel is backed by a newly-created, live **SmartCollection** query, so it keeps
tracking the library as it grows rather than freezing a static item list.
## Entity chain sketch
```
+3 -1
View File
@@ -2,7 +2,7 @@
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
161 endpoints, 244 operations.
163 endpoints, 246 operations.
## Artists
@@ -55,6 +55,8 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| POST | `/api/v1/channels` | ChannelCreate | Create a channel |
| POST | `/api/v1/channels/auto-tune` | CreateAutoTunedChannels | Create auto-tuned channels |
| POST | `/api/v1/channels/auto-tune/preview` | PreviewAutoTuneChannels | Preview auto-tuned channels |
| POST | `/api/v1/channels/bulk/delete` | ChannelBulkDelete | Delete channels |
| POST | `/api/v1/channels/bulk/group` | ChannelBulkMoveToGroup | Move channels to a group |
| POST | `/api/v1/channels/bulk/renumber` | ChannelBulkRenumber | Renumber channels |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
# Auto-Tuning: generate channels automatically from library metadata (#69)
**Status:** Design approved 2026-07-16. Implementation pending.
**Issue:** [ersatztv#69](http://192.168.1.95:3000/timothy/ersatztv/issues/69)
**Depends on (shipped):** #63 composite create-channel-from-lineup, #64 Channel Templates.
## Goal
"Install it and it builds a lineup for you." A creation mode that auto-generates whole
channels from existing library metadata — a second, automatic-first path alongside the
manual create-channel builder (epic #62). A large library becomes a full channel lineup
with little manual work.
Inspired by **PseudoTV Live's** signature Auto-Tuning feature. We deliberately fix its two
biggest weaknesses:
- PseudoTV is **all-or-nothing per category** with **no preview/selection**. We add a
preview-and-select step (this is written into #69's body).
- PseudoTV **wipes and rebuilds the whole lineup on every run** (destructive to manual
tweaks). We are **additive and non-destructive** — auto-tune never mutates or deletes
existing channels.
## Scope — first slice (3-axis MVP)
The full issue lists nine axes. This MVP ships the **complete pipeline**
(enumerate → preview → select → bulk-create) for **three axes only**, the highest-value and
simplest, covering both TV and movie libraries:
| Axis | Enumerate (exact count, EF) | Generated channel query (SmartCollection) | Playback order | Channel name |
|------|------------------------------|-------------------------------------------|----------------|--------------|
| **TV Show** (24/7 per-show) | distinct shows with ≥ minItems episodes | `type:episode AND show_title:"X"` | `SeasonEpisode` | `X` |
| **TV Genre** | distinct genres on episodes/shows with ≥ minItems episodes | `type:episode AND genre:"X"` | `Shuffle` | `X` |
| **Movie Genre** | distinct genres on movies with ≥ minItems movies | `type:movie AND genre:"X"` | `Shuffle` | `X Movies` |
Deferred to follow-up PRs (not this MVP): TV Network, Movie Studio, Mixed Genre, Music
Genre, Smart-Collection→channel, Mixed Content; per-axis templates; "even show
distribution" balancing.
## Key architectural decision — enumerate via EF, persist via SmartCollection
- **Enumeration** (for the preview) uses **EF Core distinct + count queries** over the
metadata tables (`ShowMetadata`, `GenreMetadata`, `MovieMetadata`, …). This gives *exact*
item counts, which we need for the min-items threshold and the preview display.
- **Persistence** — each generated channel references a newly-created **SmartCollection**
(a live Lucene query), not a static Collection. So a "Comedy" channel keeps picking up new
comedies as the library grows — PseudoTV's regenerative intent, but non-destructive.
#63's lineup `Item` DTO already accepts a `SmartCollectionId`.
- **Query authorship is server-side only.** The client never sends a Lucene string. The
preview returns `{ axis, value }`; bulk-create takes `{ axis, value, … }` and the server
regenerates the query. This avoids client-authored query injection and keeps the axis
semantics in one place.
Query-value escaping: show titles / genres containing quotes or Lucene special characters
must be escaped when building `show_title:"…"` / `genre:"…"`. Enumeration returns raw
values; query generation escapes them.
## Coexistence model (additive, non-destructive)
- **Numbering**: the user picks a **starting channel number** for the batch (default `500`).
Generated channels get sequential numbers from there, **skipping any already taken**.
- **Dedup**: on preview, a proposed channel whose generated **name already matches an
existing channel** is flagged `alreadyExists: true` and is **de-selected by default** in
the UI. Re-running auto-tune after adding library content therefore surfaces only
genuinely-new channels.
- Auto-tune **never** edits or deletes an existing channel. Number/name collisions are
resolved by skipping, never overwriting.
- **Concurrency**: numbers are re-validated at create time (another channel may have taken a
number between preview and create); a now-taken number is skipped/reallocated, and that
outcome is reported per-channel rather than failing the batch.
## API surface (under the frozen `/api/v1`)
All three follow `docs/api-conventions.md` (§1 controller shape, §2 request DTO with
`ToCommand()`, §3 error mapping, §7b post-commit side effects on `CancellationToken.None`,
§9 auth posture). New endpoints are additive to the frozen contract.
### 1. `POST /api/v1/channels/auto-tune/preview` (no writes)
Request:
```
{
"axes": ["TvShow", "TvGenre", "MovieGenre"], // subset, ≥1
"minItems": 5, // default 5
"startingNumber": 500 // default 500
}
```
Response — a list of proposed channels:
```
[
{ "axis": "TvShow", "value": "The Office", "name": "The Office",
"number": 500, "itemCount": 201, "alreadyExists": false },
{ "axis": "MovieGenre", "value": "Action", "name": "Action Movies",
"number": 501, "itemCount": 42, "alreadyExists": false },
...
]
```
Ordering of results: grouped by axis (TvShow, TvGenre, MovieGenre), then by value. Number
allocation is computed here so the UI can show final numbers; it is advisory (re-validated
at create).
### 2. `POST /api/v1/channels/auto-tune` (bulk create)
Request — the selected proposals echoed back (server regenerates the query from
`axis`+`value`):
```
{
"templateId": <int>,
"channels": [
{ "axis": "TvShow", "value": "The Office", "name": "The Office", "number": 500 },
{ "axis": "MovieGenre", "value": "Action", "name": "Action Movies", "number": 501 }
]
}
```
Response — partial-success list, mirroring the existing `ResetAllPlayoutsResult` /
`…ResponseModel` pattern (api-conventions §3a):
```
{
"results": [
{ "name": "The Office", "status": "Created", "channelId": 88 },
{ "name": "Action Movies", "status": "Skipped", "reason": "number 501 already taken" },
{ "name": "Sci-Fi", "status": "Failed", "reason": "…" }
],
"createdCount": 1, "skippedCount": 1, "failedCount": 1
}
```
Per selected channel the handler: (a) creates a SmartCollection with the server-generated
query, (b) allocates a free number, (c) calls the **#63 composite create-from-lineup**
handler with a single-item lineup referencing that `SmartCollectionId`, the batch
`templateId`, and an `Advanced.PlaybackOrder` override for the axis (SeasonEpisode / Shuffle).
Each channel is independent — one failure does not abort the batch. Side effects
(`BuildPlayout`, `RefreshChannelList`) are enqueued by the reused #63 handler on
`CancellationToken.None`.
## SPA
New wizard screen `web/src/screens/AutoTuneScreen.tsx` following `docs/spa-conventions.md`,
reached from the Channels area (a "Auto-tune channels" action). Flow:
1. **Configure** — axis checkboxes, starting number, min items, template select.
2. **Preview** — calls the preview endpoint; renders a table grouped by axis with per-row
checkboxes, select-all-per-axis, item counts, and `alreadyExists` rows shown greyed and
unchecked.
3. **Create** — posts selected rows to the bulk endpoint; shows a per-channel result summary
(created / skipped / failed counts + any errors).
No client-side query construction; the screen only passes `axis`+`value` back.
## Testing / verification
- **Application handler tests** (NUnit + Shouldly, the in-memory Sqlite
`EnsureCreatedAsync` harness from #28): enumeration distinct+count correctness, min-items
threshold, name-dedup / `alreadyExists`, number allocation with gaps, and bulk-create
partial-success (Created/Skipped/Failed). **Enumerate lazy LanguageExt returns in tests**
(the #229 lesson — a lazy `Map`/`Seq` the test never enumerates hides write-path faults).
- **OpenAPI**: build the app project, then `./scripts/update-openapi.sh` + `npm run
generate:api`; update `docs/api-conventions.md` checklist, regenerate `endpoint-index.md`.
- **Docs**: `docs/domain-model.md` (auto-tune as a second creation mode),
`docs/spa-conventions.md` if a new pattern is introduced, `docs/decisions.md` (the
enumerate-via-EF / persist-via-SmartCollection + additive-coexistence decisions),
`docs/blazor-route-parity.md` + `docs/README.md` for the new screen/route.
- **Live-E2E (required — write-path handlers)** via `scripts/e2e-local.sh`: seed a tiny TV +
movie library (testsrc MKVs + `LibraryPath` rows + scan per `docs/e2e-local.md`), run
preview → create, and verify the generated channels appear and produce valid M3U/XMLTV.
Never exercise download endpoints via browser tabs — curl them.
## Phasing (2 PRs)
- **PR1 — backend**: EF enumeration, preview endpoint, bulk-create endpoint,
server-side SmartCollection query generation, handler tests, OpenAPI regen + doc updates.
Write-path handlers → independent (cross-model or cold) review is mandatory.
- **PR2 — SPA wizard** + live-E2E verification.
## Defaults chosen (recorded so they can be revisited)
- `minItems` default **5**.
- Movie-genre channels suffixed **" Movies"**; TV genre and TV show names bare. (Disambiguates
a genre that exists for both TV and movies, e.g. "Comedy" vs "Comedy Movies".)
- Per-axis playback order: TV Show → `SeasonEpisode`; TV/Movie Genre → `Shuffle`.
- One Channel Template for the whole batch (per-axis templates deferred).
- Server owns all query generation; the client never sends Lucene.
## Non-goals (MVP)
- The remaining six axes, per-axis templates, even-show-distribution balancing, editing
generated channels in-wizard, scheduling filler/bumpers between items (channels inherit
whatever the chosen template configures), and any destructive "rebuild my lineup" mode.
+37
View File
@@ -58,6 +58,33 @@ export interface components {
"ArtworkUploadResponseModel": {
"path": string;
"contentType": string;
};
"AutoTuneAxis": "TvShow" | "TvGenre" | "MovieGenre";
"AutoTuneChannelResultModel": {
"name": string;
"status": string;
"channelId": null | number;
"reason": null | string;
};
"AutoTunedChannelRequest": {
"axis": components["schemas"]["AutoTuneAxis"];
"value": null | string;
"name": null | string;
"number": null | string;
};
"AutoTuneProposalResponseModel": {
"axis": string;
"value": string;
"name": string;
"number": string;
"itemCount": number;
"alreadyExists": boolean;
};
"AutoTuneResultResponseModel": {
"results": Array<components["schemas"]["AutoTuneChannelResultModel"]>;
"createdCount": number;
"skippedCount": number;
"failedCount": number;
};
"BlockGroupResponseModel": {
"id": number;
@@ -270,6 +297,11 @@ export interface components {
"CopyTemplateRequest": {
"templateGroupId": number;
"name": null | string;
};
"CreateAutoTunedChannelsRequest": {
"templateId": number;
"group": null | string;
"channels": null | Array<components["schemas"]["AutoTunedChannelRequest"]>;
};
"CreateBlockGroupRequest": {
"name": null | string;
@@ -1143,6 +1175,11 @@ export interface components {
};
"PlexPinFlowResponseModel": {
"authUrl": string;
};
"PreviewAutoTuneChannelsRequest": {
"axes": null | Array<components["schemas"]["AutoTuneAxis"]>;
"minItems": number;
"startingNumber": number;
};
"ProblemDetails": {
"type"?: null | string;