Files
ersatztv/docs/superpowers/plans/2026-07-16-auto-tuning-pr1-backend.md
T
timothyandClaude Opus 4.8 2352604836 docs(69): PR1 backend implementation plan
Task-by-task TDD plan for the auto-tuning backend: axis map, number
allocator, preview query + EF enumeration, bulk-create orchestration,
REST endpoints, OpenAPI + docs.

Refs #69

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:16:43 +02:00

47 KiB

Auto-Tuning PR1 (Backend) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship the backend for #69 auto-tuning — three REST endpoints that enumerate library metadata, preview proposed channels, and bulk-create them — reusing the #63 composite-create primitive and Smart Collections.

Architecture: CQRS/MediatR. A read PreviewAutoTuneChannels query enumerates distinct axis values (TV Show / TV Genre / Movie Genre) with exact EF counts and returns proposed channels (name, allocated number, item count, already-exists flag) with no writes. A CreateAutoTunedChannels command orchestrates, per selected proposal: create a SmartCollection (server-generated Lucene query) → call the existing CreateChannelFromLineup handler via ISender → aggregate a per-channel Created/Skipped/Failed outcome. Two thin controller actions on ChannelController map DTOs to commands.

Tech Stack: C# / .NET 10, MediatR, EF Core (SQLite default), LanguageExt (Either/Option), NUnit + Shouldly + NSubstitute.

Worktree: All work happens in /Users/timothy/ersatztv/.claude/worktrees/69-auto-tuning on branch feat/69-auto-tuning. Reference spec: docs/superpowers/specs/2026-07-16-auto-tuning-design.md.

Global Constraints

  • Read first: docs/README.mddocs/api-conventions.md (controller/DTO/error patterns), docs/testing.md (NUnit harness). Do not re-derive conventions from source.
  • Test framework is NUnit + Shouldly + NSubstitute. Never xUnit. Handler tests live in ErsatzTV.Tests/; the in-memory DB harness is ErsatzTV.Tests/Support/InMemoryTvContext.cs.
  • LanguageExt everywhere: handlers return Either<BaseError, T> (or plain results). Use BaseError.New("msg") / NotFoundError. Iterate Either via .LeftToSeq() / .RightToSeq() or .Match(...).
  • Channel.Number is a string validated by regex ^[0-9]+(\.[0-9]{1,2})?$; uniqueness is enforced inside CreateChannelFromLineupHandler (returns BaseError.New("Channel number must be unique")). Channel name must be non-empty and ≤ 50 chars. Channel Group is REQUIRED (non-empty).
  • A SmartCollection lineup item for CreateChannelFromLineup uses exactly: MediaType = LibraryBrowseMediaType.SmartCollection, CollectionType = CollectionType.SmartCollection, SmartCollectionId = <id>, all other ids null.
  • Central Package Management: no new packages needed. Never add Version= to a <PackageReference>.
  • No DI edits: MediatR auto-scans the ErsatzTV.Application assembly; new handlers register automatically.
  • Worktree hooks: commit with git commit --no-verify (the pre-commit dotnet format/lint-staged reverts BOMs mid-hook; pre-push eslint isn't on PATH). After each task verify manually: dotnet build, dotnet test, and dotnet format whitespace <project> on your touched files only (de-BOM touched files → charset=utf-8, per the #311 formatting gate). Keep the branch current by rebasing on origin/main, never merging main in.
  • Deviation from spec (planning refinement): the preview request drops templateId (preview does not create, so it does not need a template). templateId stays on the create request only. Update the spec's preview-request shape in Task 7's doc pass.

Task 1: AutoTuneAxis enum + AutoTuneAxisMap pure helper

Files:

  • Create: ErsatzTV.Core/Domain/AutoTuneAxis.cs
  • Create: ErsatzTV.Application/Channels/AutoTuneAxisMap.cs
  • Test: ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs

Interfaces:

  • Produces: enum AutoTuneAxis { TvShow, TvGenre, MovieGenre }; static AutoTuneAxisMap.GenerateQuery(AutoTuneAxis, string) : string, GenerateName(AutoTuneAxis, string) : string, PlaybackOrderFor(AutoTuneAxis) : PlaybackOrder, EscapeLuceneValue(string) : string.

  • Step 1: Write the failing test

ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs:

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);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTuneAxisMapTests Expected: FAIL — AutoTuneAxis / AutoTuneAxisMap do not exist (compile error).

  • Step 3: Write the enum

ErsatzTV.Core/Domain/AutoTuneAxis.cs:

namespace ErsatzTV.Core.Domain;

public enum AutoTuneAxis
{
    TvShow = 0,
    TvGenre = 1,
    MovieGenre = 2
}
  • Step 4: Write the helper

ErsatzTV.Application/Channels/AutoTuneAxisMap.cs:

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("\"", "\\\"");
}
  • Step 5: Run test to verify it passes

Run: dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTuneAxisMapTests Expected: PASS (4 tests).

  • Step 6: Commit
git add ErsatzTV.Core/Domain/AutoTuneAxis.cs ErsatzTV.Application/Channels/AutoTuneAxisMap.cs ErsatzTV.Tests/Application/Channels/AutoTuneAxisMapTests.cs
git commit --no-verify -m "feat(69): auto-tune axis map (query/name/order/escape)

Refs #69"

Task 2: AutoTuneNumberAllocator pure helper

Files:

  • Create: ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs
  • Test: ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs

Interfaces:

  • Produces: static AutoTuneNumberAllocator.Allocate(int startingNumber, int count, ISet<string> existingNumbers) : List<string>count sequential integer numbers from startingNumber, skipping any string already in existingNumbers.

  • Step 1: Write the failing test

ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs:

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();
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTuneNumberAllocatorTests Expected: FAIL — AutoTuneNumberAllocator does not exist.

  • Step 3: Write the implementation

ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs:

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;
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTuneNumberAllocatorTests Expected: PASS (3 tests).

  • Step 5: Commit
git add ErsatzTV.Application/Channels/AutoTuneNumberAllocator.cs ErsatzTV.Tests/Application/Channels/AutoTuneNumberAllocatorTests.cs
git commit --no-verify -m "feat(69): auto-tune channel-number allocator

Refs #69"

Task 3: Preview query + handler (EF enumeration)

Files:

  • Create: ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs (query + AutoTuneProposal result)
  • Create: ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs
  • Test: ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs

Interfaces:

  • Consumes: AutoTuneAxisMap (Task 1), AutoTuneNumberAllocator (Task 2).

  • Produces: record PreviewAutoTuneChannels(List<AutoTuneAxis> Axes, int MinItems, int StartingNumber) : IRequest<Either<BaseError, List<AutoTuneProposal>>>; record AutoTuneProposal(AutoTuneAxis Axis, string Value, string Name, string Number, int ItemCount, bool AlreadyExists).

  • Step 1: Write the failing test

ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs:

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.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 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);
}

Note the alias needs: using Channel = ErsatzTV.Core.Domain.Channel; is NOT required here because there's no System.Threading.Channels import in this file. Use ErsatzTV.Core.Domain.Channel directly (it's imported via ErsatzTV.Core.Domain).

  • Step 2: Run test to verify it fails

Run: dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PreviewAutoTuneChannelsHandlerTests Expected: FAIL — types do not exist.

  • Step 3: Write the query + result records

ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs:

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);
  • Step 4: Write the handler

ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs:

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));
        }

        HashSet<string> existingNumbers = (await dbContext.Channels.AsNoTracking()
                .Select(c => c.Number).ToListAsync(cancellationToken))
            .ToHashSet();
        HashSet<string> existingNames = (await dbContext.Channels.AsNoTracking()
                .Select(c => c.Name).ToListAsync(cancellationToken))
            .ToHashSet(StringComparer.OrdinalIgnoreCase);

        List<string> numbers = AutoTuneNumberAllocator.Allocate(
            request.StartingNumber, raw.Count, existingNumbers);

        var proposals = new List<AutoTuneProposal>(raw.Count);
        for (int i = 0; i < raw.Count; i++)
        {
            (AutoTuneAxis axis, string value, int count) = raw[i];
            string name = AutoTuneAxisMap.GenerateName(axis, value);
            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();
    }
}

If the SQLite provider cannot translate .SelectMany(m => m.Genres).GroupBy(...) (the test in Step 5 is the check), replace the genre enumerators' first line with a materialize-then-group form: var names = await dbContext.EpisodeMetadata.AsNoTracking().SelectMany(m => m.Genres.Select(g => g.Name)).ToListAsync(ct); then var counts = names.GroupBy(n => n).Select(g => new { Name = g.Key, Count = g.Count() }).ToList();. Prefer the DB-side form; only fall back if the test surfaces a translation error.

  • Step 5: Run test to verify it passes

Run: dotnet test ErsatzTV.Tests --filter FullyQualifiedName~PreviewAutoTuneChannelsHandlerTests Expected: PASS (4 tests). This also proves the EF queries translate on SQLite.

  • Step 6: Commit
git add ErsatzTV.Application/Channels/PreviewAutoTuneChannels.cs ErsatzTV.Application/Channels/PreviewAutoTuneChannelsHandler.cs ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs
git commit --no-verify -m "feat(69): auto-tune preview query + EF enumeration handler

Refs #69"

Task 4: Bulk-create command + handler (orchestration via ISender)

Files:

  • Create: ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs (command + selection + result + outcome + status enum)
  • Create: ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs
  • Test: ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs

Interfaces:

  • Consumes: AutoTuneAxisMap (Task 1); the existing CreateSmartCollection(string Query, string Name) : IRequest<Either<BaseError, SmartCollectionViewModel>> (namespace ErsatzTV.Application.MediaCollections; SmartCollectionViewModel(int Id, string Name, string Query)) and CreateChannelFromLineup(...) : IRequest<Either<BaseError, CreateChannelFromLineupResponseModel>> (namespace ErsatzTV.Application.Channels), sent via ISender.

  • Produces: record CreateAutoTunedChannels(int TemplateId, string Group, List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>; record AutoTuneChannelSelection(AutoTuneAxis Axis, string Value, string Name, string Number); record AutoTuneResult(List<AutoTuneChannelOutcome> Results) with computed CreatedCount/SkippedCount/FailedCount; record AutoTuneChannelOutcome(string Name, AutoTuneOutcomeStatus Status, int? ChannelId, string Reason); enum AutoTuneOutcomeStatus { Created, Skipped, Failed }.

  • Step 1: Write the failing test

ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs:

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);
            });
    }

    [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 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);
}
  • Step 2: Run test to verify it fails

Run: dotnet test ErsatzTV.Tests --filter FullyQualifiedName~CreateAutoTunedChannelsHandlerTests Expected: FAIL — types do not exist.

  • Step 3: Write the command + result records

ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs:

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
}
  • Step 4: Write the handler

ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs:

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())
        {
            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);
    }
}

ArtworkContentTypeModel lives in the same namespace as CreateChannelFromLineupResponseModel usage in the request DTO — resolve its exact using with the C# LSP if the build reports it missing (it is referenced by CreateChannelFromLineupRequest.cs; copy that file's using for it). Likewise confirm SmartCollectionViewModel's namespace via the LSP if ErsatzTV.Application.MediaCollections is wrong.

  • Step 5: Run test to verify it passes

Run: dotnet test ErsatzTV.Tests --filter FullyQualifiedName~CreateAutoTunedChannelsHandlerTests Expected: PASS (3 tests).

  • Step 6: Commit
git add ErsatzTV.Application/Channels/CreateAutoTunedChannels.cs ErsatzTV.Application/Channels/CreateAutoTunedChannelsHandler.cs ErsatzTV.Tests/Application/Channels/CreateAutoTunedChannelsHandlerTests.cs
git commit --no-verify -m "feat(69): auto-tune bulk-create orchestration handler

Refs #69"

Task 5: Response models + controller endpoints + request DTOs

Files:

  • Create: ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs
  • Create: ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs
  • Modify: ErsatzTV/Controllers/Api/ChannelController.cs (add two actions + two projection helpers)

Interfaces:

  • Consumes: PreviewAutoTuneChannels/AutoTuneProposal (Task 3), CreateAutoTunedChannels/AutoTuneResult (Task 4), AutoTuneAxis (Task 1).

  • Produces: routes POST /api/v1/channels/auto-tune/preview and POST /api/v1/channels/auto-tune.

  • Step 1: Write the response models

ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs:

#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);
  • Step 2: Write the request DTOs

ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs:

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);
}
  • Step 3: Add the two controller actions + projections

In ErsatzTV/Controllers/Api/ChannelController.cs, add the following actions (place them near the existing BulkRenumber/ResetAll-style actions). Confirm the file already has using ErsatzTV.Application.Channels;, using ErsatzTV.Core.Api.Channels;, using ErsatzTV.Controllers.Api.Requests;, using LanguageExt;, using ErsatzTV.Core; — add any that are missing.

    [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 => Ok(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 Ok(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);

error.ToErrorResult() is the same extension used by BulkRenumber in this controller. If ProjectToResponseModel(AutoTuneProposal) name-clashes with an existing single-arg overload, that's fine (C# overloads by parameter type: AutoTuneProposal vs AutoTuneResult are distinct).

  • Step 4: Build

Run: dotnet build ErsatzTV/ErsatzTV.csproj Expected: Build succeeded, 0 Error(s). (Fix any missing using the compiler reports; grep the error for error CS before trusting the tail.)

  • Step 5: Run the full test project to confirm nothing regressed

Run: dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTune Expected: PASS (all AutoTune* tests).

  • Step 6: Commit
git add ErsatzTV.Core/Api/Channels/AutoTuneResponseModels.cs ErsatzTV/Controllers/Api/Requests/AutoTuneRequests.cs ErsatzTV/Controllers/Api/ChannelController.cs
git commit --no-verify -m "feat(69): auto-tune preview + bulk-create REST endpoints

Refs #69"

Task 6: OpenAPI regen + docs

Files:

  • Modify (generated): ErsatzTV/wwwroot/**/v1.json (or wherever update-openapi.sh writes), docs/endpoint-index.md, web/src/** generated API client (npm run generate:api)
  • Modify: docs/api-conventions.md, docs/domain-model.md, docs/decisions.md, docs/superpowers/specs/2026-07-16-auto-tuning-design.md (drop templateId from the preview request to match the plan)

Interfaces: none (docs + generated artifacts).

  • Step 1: Regenerate the OpenAPI spec + typed client

Run (app project must build first — Task 5 ensured that):

./scripts/update-openapi.sh
cd web && npm run generate:api && cd ..

Expected: v1.json, docs/endpoint-index.md, and the generated web client update to include PreviewAutoTuneChannels and CreateAutoTunedChannels. Run cd web && npm run check:api && cd .. — expected clean.

  • Step 2: Update the docs

  • docs/api-conventions.md: add the two endpoints to the endpoint inventory/checklist section following the existing format (method, path, operationId, summary, auth: standard API-key).

  • docs/domain-model.md: add a short paragraph — auto-tuning is a second, automatic-first channel-creation mode that enumerates library metadata and bulk-creates channels via the #63 composite create; each generated channel is backed by a live SmartCollection.

  • docs/decisions.md: append a new dated entry (append-only; also add its TOC line in the Index):

    ## 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.
    
  • docs/superpowers/specs/2026-07-16-auto-tuning-design.md: in the preview request example, remove the "templateId" line (preview does not create, so it needs no template).

  • Step 3: Build + full test + format check

dotnet build ErsatzTV.sln
dotnet test ErsatzTV.Tests --filter FullyQualifiedName~AutoTune
dotnet format whitespace ErsatzTV.Application ErsatzTV.Core ErsatzTV --verify-no-changes

Expected: build succeeds, AutoTune tests pass, format check clean on touched projects. (If format flags a touched file, run dotnet format whitespace <project> without --verify-no-changes, then re-stage only your files; ensure touched files are charset=utf-8 / no BOM.)

  • Step 4: Commit
git add -A
git commit --no-verify -m "docs(69): auto-tune OpenAPI regen + api/domain/decisions docs

Refs #69"

Post-plan: PR + review (not a code task)

After Task 6: rebase on origin/main, push feat/69-auto-tuning, open the PR (Refs #69, not fixes — #69 closes only after PR2 SPA + live-E2E), and arm a CI monitor on the head sha at PR-open. This is a write-path handler change, so an independent (cross-model or cold-context) review is mandatory before merge. Add a ## Done-when section to #69 covering: PR1 backend merged, PR2 SPA + live-E2E, adversarial review passed, docs updated. Live-E2E belongs to PR2 (the SPA slice).

Self-review notes (author)

  • Spec coverage: axis enumeration (Task 3), preview (Task 3), bulk-create (Task 4), server-side query gen + escaping (Task 1), reserve-block numbering + name dedup (Tasks 2/3), partial-success result (Task 4), endpoints (Task 5), OpenAPI + docs (Task 6). SPA + live-E2E are explicitly PR2, per the spec's phasing. ✓
  • Type consistency: AutoTuneAxis, AutoTuneProposal, AutoTuneChannelSelection, AutoTuneResult, AutoTuneOutcomeStatus, and the response models are used with identical names/shapes across tasks. The SmartCollection lineup item uses LibraryBrowseMediaType.SmartCollection + CollectionType.SmartCollection + SmartCollectionId (verified against CreateChannelFromLineupHandler.NormalizeLineupItem). ✓
  • Known unknowns flagged inline (not placeholders): exact using for ArtworkContentTypeModel / SmartCollectionViewModel (resolve via LSP — types definitely exist); SQLite translatability of grouped SelectMany over the genre nav (the Task 3 test is the gate, with a decisive in-memory fallback noted). ✓