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
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:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
Vendored
+37
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user