From 5ad28b2966607b530ffada0800fdb58fda82877f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 05:39:23 +0300 Subject: [PATCH] Implement collection suggestions and bulk show addition features Added new API endpoints for suggesting collections based on existing shows and for bulk adding shows to collections. Enhanced the backend with necessary logic and DTOs to support these features. Updated the frontend to include new components for displaying collection suggestions and managing bulk additions, improving the user experience for collection management. Localization updates were made to support these new features in both English and Russian. --- .../Endpoints/CollectionEndpoints.cs | 64 + .../TeleWave.Api/Endpoints/ShowEndpoints.cs | 12 + .../DependencyInjection.cs | 2 + .../AddShowsToCollectionCommand.cs | 22 + .../AddShowsToCollectionCommandHandler.cs | 62 + .../AddShowsToCollectionCommandValidator.cs | 19 + .../Library/Collections/CollectionErrors.cs | 10 + .../Suggest/CollectionSuggestionBuilder.cs | 239 +++ .../Suggest/CollectionSuggestionSource.cs | 11 + .../CreateCollectionFromSuggestionCommand.cs | 11 + ...eCollectionFromSuggestionCommandHandler.cs | 38 + .../Suggest/SuggestCollectionsQuery.cs | 20 + .../Suggest/SuggestCollectionsQueryHandler.cs | 40 + .../Library/GetShowUsage/GetShowUsageQuery.cs | 22 + .../GetShowUsage/GetShowUsageQueryHandler.cs | 90 ++ .../Metadata/MetadataModels.cs | 4 +- .../Metadata/ShowMetadataApplier.cs | 9 +- .../Library/MetadataFranchise.cs | 9 + backend/src/TeleWave.Domain/Library/Show.cs | 16 +- .../Metadata/TmdbMetadataProvider.cs | 22 +- .../20260727022637_ShowFranchise.Designer.cs | 1407 +++++++++++++++++ .../20260727022637_ShowFranchise.cs | 38 + .../Migrations/AppDbContextModelSnapshot.cs | 8 + .../Configurations/ShowConfiguration.cs | 2 + .../Library/CollectionSuggestionsTests.cs | 142 ++ .../collections/CollectionSuggestions.tsx | 95 ++ .../admin/collections/CollectionsPanel.tsx | 3 + .../src/features/admin/collections/api.ts | 31 +- .../src/features/admin/groups/GroupDetail.tsx | 15 +- .../src/features/admin/groups/GroupsPanel.tsx | 14 +- .../src/features/admin/media/MediaPanel.tsx | 8 +- .../admin/shows/AddToCollectionDialog.tsx | 114 ++ .../src/features/admin/shows/BulkTagBar.tsx | 17 +- .../src/features/admin/shows/ShowDetail.tsx | 33 +- frontend/src/features/admin/shows/api.ts | 6 + frontend/src/shared/api/query-keys.ts | 3 + frontend/src/shared/api/types.ts | 26 + frontend/src/shared/lib/locales/en.ts | 18 + frontend/src/shared/lib/locales/ru.ts | 18 + 39 files changed, 2710 insertions(+), 10 deletions(-) create mode 100644 backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionBuilder.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionSource.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQuery.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/GetShowUsage/GetShowUsageQuery.cs create mode 100644 backend/src/TeleWave.Application/Library/GetShowUsage/GetShowUsageQueryHandler.cs create mode 100644 backend/src/TeleWave.Domain/Library/MetadataFranchise.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260727022637_ShowFranchise.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260727022637_ShowFranchise.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Library/CollectionSuggestionsTests.cs create mode 100644 frontend/src/features/admin/collections/CollectionSuggestions.tsx create mode 100644 frontend/src/features/admin/shows/AddToCollectionDialog.tsx diff --git a/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs index 8dcc52f..d9dd61b 100644 --- a/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs @@ -2,6 +2,7 @@ using LiteCqrs; using TeleWave.Api.Common; using TeleWave.Application.Library.Collections; using TeleWave.Application.Library.Collections.AddCollectionShow; +using TeleWave.Application.Library.Collections.AddShowsToCollection; using TeleWave.Application.Library.Collections.CreateCollection; using TeleWave.Application.Library.Collections.DeleteCollection; using TeleWave.Application.Library.Collections.GetCollection; @@ -9,6 +10,7 @@ using TeleWave.Application.Library.Collections.ListCollections; using TeleWave.Application.Library.Collections.RemoveCollectionShow; using TeleWave.Application.Library.Collections.ReorderCollection; using TeleWave.Application.Library.Collections.SetCollectionPoster; +using TeleWave.Application.Library.Collections.Suggest; using TeleWave.Application.Library.Collections.UpdateCollection; using TeleWave.Infrastructure.Identity; @@ -36,9 +38,62 @@ public static class CollectionEndpoints admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent); + // Сборка франшиз: массовое добавление отмеченных шоу и предложения по библиотеке. + admin.MapPost("/bulk/shows", AddShows).Produces(); + admin + .MapGet("/suggestions", Suggestions) + .Produces>(); + admin + .MapPost("/suggestions", CreateFromSuggestion) + .Produces(StatusCodes.Status201Created); + return app; } + private static async Task AddShows( + AddShowsToCollectionBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new AddShowsToCollectionCommand( + body.ShowIds, + body.CollectionId, + body.NewCollectionName + ), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task Suggestions( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new SuggestCollectionsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task CreateFromSuggestion( + CreateCollectionFromSuggestionBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new CreateCollectionFromSuggestionCommand(body.Key), + cancellationToken + ); + return result.IsSuccess + ? Results.Created( + $"/api/admin/collections/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + private static async Task ListCollections( ISender sender, CancellationToken cancellationToken @@ -161,3 +216,12 @@ public sealed record CollectionShowBody(Guid ShowId); public sealed record ReorderCollectionBody(IReadOnlyList ShowIdsInOrder); public sealed record CollectionPosterBody(Guid? ImageId); + +/// Куда складывать: существующая коллекция либо новая по имени. +public sealed record AddShowsToCollectionBody( + IReadOnlyList ShowIds, + Guid? CollectionId = null, + string? NewCollectionName = null +); + +public sealed record CreateCollectionFromSuggestionBody(string Key); diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs index 739413d..483d46a 100644 --- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs @@ -6,6 +6,7 @@ using TeleWave.Application.Library.BulkTagShows; using TeleWave.Application.Library.CreateShow; using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Library.GetShow; +using TeleWave.Application.Library.GetShowUsage; using TeleWave.Application.Library.ListShows; using TeleWave.Application.Library.RemoveEpisode; using TeleWave.Application.Library.RenameShow; @@ -35,6 +36,7 @@ public static class ShowEndpoints .Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent); + admin.MapGet("/{id:guid}/usage", GetUsage).Produces(); admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); // Массовые операции над отмеченными шоу: разметка руками и обогащение из источника. @@ -179,6 +181,16 @@ public static class ShowEndpoints return result.ToHttpResult(); } + private static async Task GetUsage( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetShowUsageQuery(id), cancellationToken); + return result.ToHttpResult(); + } + private static async Task BulkTag( BulkTagShowsBody body, ISender sender, diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 40e36b6..c34d8b2 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -5,6 +5,7 @@ using LiteCqrs.DependencyInjection; using Microsoft.Extensions.DependencyInjection; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Behaviors; +using TeleWave.Application.Library.Collections.Suggest; using TeleWave.Application.Library.Genres; using TeleWave.Application.Metadata; using TeleWave.Application.Programming.Groups; @@ -38,6 +39,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommand.cs b/backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommand.cs new file mode 100644 index 0000000..5aafc79 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommand.cs @@ -0,0 +1,22 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.AddShowsToCollection; + +/// +/// Отправляет несколько шоу во франшизу разом. Коллекция либо уже есть +/// (), либо заводится по имени +/// () — иначе на сборку франшизы уходило бы два экрана. +/// +/// Порядок добавления — по году выпуска: сиквелы почти всегда идут так, а исключения правятся +/// перетаскиванием в карточке коллекции. +/// +public sealed record AddShowsToCollectionCommand( + IReadOnlyList ShowIds, + Guid? CollectionId = null, + string? NewCollectionName = null +) : ICommand>; + +/// Куда в итоге сложили — новая коллекция или переданная. +/// Сколько шоу реально добавлено (уже входящие пропускаются). +public sealed record AddShowsToCollectionResultDto(Guid CollectionId, string Name, int Added); diff --git a/backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommandHandler.cs new file mode 100644 index 0000000..1250be4 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommandHandler.cs @@ -0,0 +1,62 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Collections.AddShowsToCollection; + +public sealed class AddShowsToCollectionCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + AddShowsToCollectionCommand command, + CancellationToken cancellationToken + ) + { + var showIds = command.ShowIds.Distinct().ToList(); + var shows = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new + { + s.Id, + s.Name, + s.Year, + }) + .ToListAsync(cancellationToken); + if (shows.Count == 0) + return Result.Failure(ShowErrors.NotFound); + + var collection = await ResolveCollectionAsync(command, cancellationToken); + if (collection is null) + return Result.Failure(CollectionErrors.NotFound); + + // Год — главный порядок франшизы; без года часть уходит в конец, там её и переставят руками. + var ordered = shows + .OrderBy(s => s.Year is null) + .ThenBy(s => s.Year) + .ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase); + + var added = ordered.Count(show => collection.AddShow(show.Id) is not null); + + return Result.Success( + new AddShowsToCollectionResultDto(collection.Id, collection.Name, added) + ); + } + + private async Task ResolveCollectionAsync( + AddShowsToCollectionCommand command, + CancellationToken cancellationToken + ) + { + if (command.CollectionId is { } id) + return await dbContext + .Collections.Include(c => c.Items) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + var created = Collection.Create(command.NewCollectionName!); + dbContext.Collections.Add(created); + return created; + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommandValidator.cs b/backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommandValidator.cs new file mode 100644 index 0000000..103da4d --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/AddShowsToCollection/AddShowsToCollectionCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace TeleWave.Application.Library.Collections.AddShowsToCollection; + +public sealed class AddShowsToCollectionCommandValidator + : AbstractValidator +{ + public AddShowsToCollectionCommandValidator() + { + RuleFor(x => x.ShowIds).NotEmpty(); + // Либо кладём в существующую, либо заводим новую — «ни то ни другое» указывает на ошибку UI. + RuleFor(x => x) + .Must(x => + x.CollectionId is not null || !string.IsNullOrWhiteSpace(x.NewCollectionName) + ) + .WithMessage("Не указано, в какую коллекцию складывать."); + RuleFor(x => x.NewCollectionName).MaximumLength(256); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/CollectionErrors.cs b/backend/src/TeleWave.Application/Library/Collections/CollectionErrors.cs index feb2b63..caeacd3 100644 --- a/backend/src/TeleWave.Application/Library/Collections/CollectionErrors.cs +++ b/backend/src/TeleWave.Application/Library/Collections/CollectionErrors.cs @@ -18,4 +18,14 @@ public static class CollectionErrors "Collections.ShowNotInCollection", "Шоу не входит в коллекцию." ); + + public static readonly Error SuggestionNotFound = Error.NotFound( + "Collections.SuggestionNotFound", + "Предложение устарело — библиотека изменилась. Обновите список предложений." + ); + + public static readonly Error SuggestionAlreadyCreated = Error.Conflict( + "Collections.SuggestionAlreadyCreated", + "Коллекция с таким названием уже есть." + ); } diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionBuilder.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionBuilder.cs new file mode 100644 index 0000000..e415f66 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionBuilder.cs @@ -0,0 +1,239 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Collections.Suggest; + +/// Предложение франшизы: имя и части в предлагаемом порядке. +public sealed record CollectionSuggestion( + string Key, + CollectionSuggestionSource Source, + string Name, + IReadOnlyList Parts +); + +/// +/// Ищет, что стоит собрать в коллекцию, среди полнометражек, ещё не входящих ни в одну. +/// +/// Два источника, и они не равны по надёжности. Франшиза из метаданных — это факт: TMDb сам считает +/// эти фильмы одним целым. Догадка по названиям — эвристика для библиотеки без метаданных, и она +/// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе +/// «Человек-паук» и «Человек дождя» уедут в одну франшизу. +/// +/// Решение всё равно за человеком: предложение показывает состав целиком, а создаётся коллекция +/// кнопкой. Сериалы сюда не попадают — у них своя форма продолжения, сезоны. +/// +public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext) +{ + /// Меньше двух частей — не франшиза. + private const int MinParts = 2; + + /// + /// Со скольких общих слов начало названия само по себе считается признаком франшизы: + /// «Harry Potter and the …» случайным совпадением не бывает, а «Человек …» — бывает. + /// + private const int StrongCommonWords = 3; + + /// + /// Чем продолжение отличается от однофамильца: номер («Рокки 2», «Рокки III»), слово «часть» + /// либо подзаголовок через двоеточие или тире («Матрица: Перезагрузка»). У «Человек дождя» + /// и «Человек-слон» хвосты — обычные слова, и в одну франшизу они не попадут. + /// + private static readonly Regex SequelTail = new( + @"^(?:\d{1,2}\b|[IVXivx]{1,4}\b|part\b|часть\b|[:–—-]\s*\S)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(1) + ); + + public async Task> BuildAsync( + CancellationToken cancellationToken + ) + { + var inCollections = await dbContext + .CollectionItems.AsNoTracking() + .Select(i => i.ShowId) + .Distinct() + .ToListAsync(cancellationToken); + + var movies = await dbContext + .Shows.AsNoTracking() + .Where(s => s.Kind == ShowKind.Single && !inCollections.Contains(s.Id)) + .Select(s => new MovieRow( + s.Id, + s.Name, + s.OriginalName, + s.Year, + s.FranchiseExternalId, + s.FranchiseName + )) + .ToListAsync(cancellationToken); + if (movies.Count < MinParts) + return []; + + var suggestions = new List(); + var taken = new HashSet(); + + foreach (var franchise in ByMetadata(movies)) + { + suggestions.Add(franchise); + foreach (var part in franchise.Parts) + taken.Add(part.ShowId); + } + + // По названиям добираем только то, что не разобрала метадата: иначе одни и те же фильмы + // попали бы в два предложения сразу и админ выбирал бы между ними вслепую. + suggestions.AddRange(ByName(movies.Where(m => !taken.Contains(m.Id)).ToList())); + + return + [ + .. suggestions + .OrderBy(s => s.Source) + .ThenByDescending(s => s.Parts.Count) + .ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase), + ]; + } + + private static IEnumerable ByMetadata(List movies) + { + var groups = movies + .Where(m => m.FranchiseExternalId is not null && m.FranchiseName is not null) + .GroupBy(m => m.FranchiseExternalId!) + .Where(g => g.Count() >= MinParts); + + foreach (var group in groups) + yield return new CollectionSuggestion( + $"franchise:{group.Key}", + CollectionSuggestionSource.Metadata, + group.First().FranchiseName!, + Parts(group) + ); + } + + /// + /// Догадка по названиям для библиотеки без метаданных. Сравниваются целые слова, а не символы: + /// посимвольное совпадение слишком легко ловит однофамильцев («Человек дождя» и «Человек-слон»). + /// Пара считается франшизой, если общее начало длинное либо хвосты обеих частей выглядят + /// продолжением. Имя предложения — общее начало, очищенное от хвостовых предлогов и знаков. + /// + private static IEnumerable ByName(List movies) + { + var used = new HashSet(); + + // От длинных названий к коротким: так более специфичная франшиза собирается первой. + foreach (var movie in movies.OrderByDescending(m => Title(m).Length)) + { + if (used.Contains(movie.Id)) + continue; + + var words = Words(Title(movie)); + var family = movies + .Where(other => + other.Id != movie.Id + && !used.Contains(other.Id) + && SameFranchise(words, Words(Title(other))) + ) + .ToList(); + if (family.Count == 0) + continue; + + var common = family.Aggregate( + words, + (acc, other) => acc[..CommonWordCount(acc, Words(Title(other)))] + ); + var name = Clean(string.Join(' ', common)); + if (name.Length < 3) + continue; + + family.Add(movie); + foreach (var part in family) + used.Add(part.Id); + + yield return new CollectionSuggestion( + $"name:{name.ToLowerInvariant()}", + CollectionSuggestionSource.NameGuess, + name, + Parts(family) + ); + } + } + + /// Части идут по году: у франшизы это и есть порядок просмотра. + private static IReadOnlyList Parts(IEnumerable movies) => + [ + .. movies + .OrderBy(m => m.Year is null) + .ThenBy(m => m.Year) + .ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase) + .Select(m => new CollectionSuggestionPartDto(m.Id, m.Name, m.Year)), + ]; + + /// Сравниваем по оригинальному названию, если оно есть: у релизов оно стабильнее. + private static string Title(MovieRow movie) => + string.IsNullOrWhiteSpace(movie.OriginalName) ? movie.Name : movie.OriginalName; + + private static bool SameFranchise(string[] first, string[] second) + { + var common = CommonWordCount(first, second); + if (common == 0) + return false; + if (common >= StrongCommonWords) + return true; + return IsSequelTail(first, common) && IsSequelTail(second, common); + } + + /// Хвост после общего начала: пусто — это первая часть, иначе должен выглядеть продолжением. + private static bool IsSequelTail(string[] words, int common) + { + var tail = string.Join(' ', words.Skip(common)).Trim(); + return tail.Length == 0 || SequelTail.IsMatch(tail); + } + + private static int CommonWordCount(string[] first, string[] second) + { + var count = 0; + while ( + count < first.Length + && count < second.Length + && string.Equals(first[count], second[count], StringComparison.CurrentCultureIgnoreCase) + ) + count++; + return count; + } + + private static string[] Words(string title) => + title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + /// + /// Общее начало обрывается на союзе или знаке: «Harry Potter and the» — это ещё не имя франшизы. + /// Чистим, пока есть что убирать: за один проход «and the» не снять. + /// + private static string Clean(string prefix) + { + var trimmed = prefix.Trim(); + bool changed; + do + { + changed = false; + trimmed = trimmed.TrimEnd(':', '-', '–', '—', '.', ',', ' '); + foreach (var tail in (string[])[" и", " and", " the", " part", " часть"]) + { + if (!trimmed.EndsWith(tail, StringComparison.CurrentCultureIgnoreCase)) + continue; + trimmed = trimmed[..^tail.Length]; + changed = true; + } + } while (changed); + + return trimmed.Trim(); + } + + private sealed record MovieRow( + Guid Id, + string Name, + string? OriginalName, + int? Year, + string? FranchiseExternalId, + string? FranchiseName + ); +} diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionSource.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionSource.cs new file mode 100644 index 0000000..e2147c8 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionSource.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Application.Library.Collections.Suggest; + +/// Насколько предложению можно верить — по этому UI подписывает происхождение. +public enum CollectionSuggestionSource +{ + /// Франшиза пришла из метаданных (TMDb знает её как единое целое). + Metadata = 0, + + /// Догадка по названиям: общее начало и нумерация. Требует проверки глазами. + NameGuess = 1, +} diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommand.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommand.cs new file mode 100644 index 0000000..d90447b --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommand.cs @@ -0,0 +1,11 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.Suggest; + +/// +/// Создаёт коллекцию по предложению: имя и состав сервер пересобирает по ключу, а не берёт +/// с клиента. Между показом и нажатием библиотека могла измениться — во франшизу должно попасть +/// актуальное. +/// +public sealed record CreateCollectionFromSuggestionCommand(string Key) : ICommand>; diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs new file mode 100644 index 0000000..1a9cacf --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs @@ -0,0 +1,38 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Collections.Suggest; + +public sealed class CreateCollectionFromSuggestionCommandHandler( + IAppDbContext dbContext, + CollectionSuggestionBuilder builder +) : ICommandHandler> +{ + public async Task> Handle( + CreateCollectionFromSuggestionCommand command, + CancellationToken cancellationToken + ) + { + var suggestions = await builder.BuildAsync(cancellationToken); + var suggestion = suggestions.FirstOrDefault(s => s.Key == command.Key); + if (suggestion is null) + return Result.Failure(CollectionErrors.SuggestionNotFound); + + var taken = await dbContext.Collections.AnyAsync( + c => c.Name.ToLower() == suggestion.Name.ToLower(), + cancellationToken + ); + if (taken) + return Result.Failure(CollectionErrors.SuggestionAlreadyCreated); + + var collection = Collection.Create(suggestion.Name); + foreach (var part in suggestion.Parts) + collection.AddShow(part.ShowId); + + dbContext.Collections.Add(collection); + return Result.Success(collection.Id); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQuery.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQuery.cs new file mode 100644 index 0000000..f5710fa --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQuery.cs @@ -0,0 +1,20 @@ +using LiteCqrs; + +namespace TeleWave.Application.Library.Collections.Suggest; + +/// Какие франшизы имеет смысл собрать из уже загруженных полнометражек. +public sealed record SuggestCollectionsQuery : IQuery>; + +/// Ключ предложения — с ним же приходит команда создания. +/// Откуда взялось: франшиза источника либо догадка по названиям. +/// Части в предлагаемом порядке. +/// Коллекция с таким названием уже есть — создавать нечего. +public sealed record CollectionSuggestionDto( + string Key, + CollectionSuggestionSource Source, + string Name, + IReadOnlyList Parts, + bool AlreadyExists +); + +public sealed record CollectionSuggestionPartDto(Guid ShowId, string Name, int? Year); diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQueryHandler.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQueryHandler.cs new file mode 100644 index 0000000..efbce0b --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQueryHandler.cs @@ -0,0 +1,40 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Library.Collections.Suggest; + +public sealed class SuggestCollectionsQueryHandler( + IAppDbContext dbContext, + CollectionSuggestionBuilder builder +) : IQueryHandler> +{ + public async Task> Handle( + SuggestCollectionsQuery query, + CancellationToken cancellationToken + ) + { + var suggestions = await builder.BuildAsync(cancellationToken); + if (suggestions.Count == 0) + return []; + + var names = await dbContext + .Collections.AsNoTracking() + .Select(c => c.Name) + .ToListAsync(cancellationToken); + var existing = names + .Select(n => n.Trim()) + .ToHashSet(StringComparer.CurrentCultureIgnoreCase); + + return + [ + .. suggestions.Select(s => new CollectionSuggestionDto( + s.Key, + s.Source, + s.Name, + s.Parts, + existing.Contains(s.Name) + )), + ]; + } +} diff --git a/backend/src/TeleWave.Application/Library/GetShowUsage/GetShowUsageQuery.cs b/backend/src/TeleWave.Application/Library/GetShowUsage/GetShowUsageQuery.cs new file mode 100644 index 0000000..98cb3c5 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/GetShowUsage/GetShowUsageQuery.cs @@ -0,0 +1,22 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.GetShowUsage; + +/// +/// Где шоу задействовано: группы, коллекции и каналы. Нужно до удаления — сейчас оно просто +/// отбивается ошибкой «используется», не говоря где, и приходится обходить экраны вручную. +/// +public sealed record GetShowUsageQuery(Guid ShowId) : IQuery>; + +/// Группы, в состав которых шоу входит (в том числе по правилу). +/// Франшизы, частью которых оно является. +/// Каналы, в чьём расписании оно уже стоит. +public sealed record ShowUsageDto( + IReadOnlyList Groups, + IReadOnlyList Collections, + IReadOnlyList Channels +); + +/// Шоу попало в группу правилом, а не явной позицией. +public sealed record ShowUsageRefDto(Guid Id, string Name, bool ViaRule = false); diff --git a/backend/src/TeleWave.Application/Library/GetShowUsage/GetShowUsageQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShowUsage/GetShowUsageQueryHandler.cs new file mode 100644 index 0000000..2fd29fb --- /dev/null +++ b/backend/src/TeleWave.Application/Library/GetShowUsage/GetShowUsageQueryHandler.cs @@ -0,0 +1,90 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Programming.Groups; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Library.GetShowUsage; + +public sealed class GetShowUsageQueryHandler( + IAppDbContext dbContext, + DynamicGroupResolver dynamicResolver +) : IQueryHandler> +{ + public async Task> Handle( + GetShowUsageQuery query, + CancellationToken cancellationToken + ) + { + if (!await dbContext.Shows.AnyAsync(s => s.Id == query.ShowId, cancellationToken)) + return Result.Failure(ShowErrors.NotFound); + + var collections = await dbContext + .Collections.AsNoTracking() + .Where(c => c.Items.Any(i => i.ShowId == query.ShowId)) + .Select(c => new ShowUsageRefDto(c.Id, c.Name, false)) + .ToListAsync(cancellationToken); + + var channels = await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => e.ShowId == query.ShowId) + .Select(e => e.ChannelId) + .Distinct() + .Join( + dbContext.Channels.AsNoTracking(), + id => id, + channel => channel.Id, + (_, channel) => new ShowUsageRefDto(channel.Id, channel.Name, false) + ) + .ToListAsync(cancellationToken); + + return Result.Success( + new ShowUsageDto( + await GroupsAsync(query.ShowId, collections, cancellationToken), + collections, + channels + ) + ); + } + + /// + /// Группы считаем по вычисленному составу: у динамической шоу может входить правилом, без своей + /// позиции, и такое участие для «можно ли удалять» ничем не отличается от явного. Коллекции + /// учитываются заодно — шоу попадает в группу и через франшизу, целиком. + /// + private async Task> GroupsAsync( + Guid showId, + IReadOnlyList collections, + CancellationToken cancellationToken + ) + { + var groups = await dbContext + .Groups.AsNoTracking() + .Include(g => g.Items) + .ToListAsync(cancellationToken); + + var collectionIds = collections.Select(c => c.Id).ToHashSet(); + var result = new List(); + + foreach (var group in groups) + { + var composition = await dynamicResolver.ResolveAsync(group, cancellationToken); + var element = composition.FirstOrDefault(e => + (e.Kind == GroupElementKind.Show && e.Id == showId) + || (e.Kind == GroupElementKind.Collection && collectionIds.Contains(e.Id)) + ); + if (element is null) + continue; + + result.Add( + new ShowUsageRefDto(group.Id, group.Name, !element.Pinned && HasRule(group)) + ); + } + + return result; + } + + private static bool HasRule(Domain.Programming.Group group) => + group.Mode == GroupMode.Dynamic && group.FilterJson is not null; +} diff --git a/backend/src/TeleWave.Application/Metadata/MetadataModels.cs b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs index 0758618..c348450 100644 --- a/backend/src/TeleWave.Application/Metadata/MetadataModels.cs +++ b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs @@ -32,7 +32,9 @@ public sealed record ShowMetadata( string? Overview, string? PosterUrl, IReadOnlyList? Genres = null, - string? ContentRating = null + string? ContentRating = null, + /// Франшиза по данным источника (у OMDb таких данных нет — null). + MetadataFranchise? Franchise = null ); /// Метаданные серии (для этапа 2). diff --git a/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs b/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs index aaa2443..515c763 100644 --- a/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs +++ b/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs @@ -43,7 +43,14 @@ public sealed class ShowMetadataApplier( var posterImageId = await SavePosterAsync(show, meta.PosterUrl, cancellationToken); - show.ApplyMetadata(providerKey, meta.ExternalId, meta.Overview, meta.Year, posterImageId); + show.ApplyMetadata( + providerKey, + meta.ExternalId, + meta.Overview, + meta.Year, + posterImageId, + meta.Franchise + ); // Жанры источника перезаписывают текущие, только если хоть один распознан справочником: // иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их. diff --git a/backend/src/TeleWave.Domain/Library/MetadataFranchise.cs b/backend/src/TeleWave.Domain/Library/MetadataFranchise.cs new file mode 100644 index 0000000..06b16ba --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/MetadataFranchise.cs @@ -0,0 +1,9 @@ +namespace TeleWave.Domain.Library; + +/// +/// Франшиза шоу по данным источника: «Гарри Поттер», «Терминатор». В домене это не сущность, а +/// подсказка от источника — по ней в библиотеке предлагается собрать , +/// которую дальше правит человек. Само объединение остаётся его решением: у TMDb во франшизу +/// попадают и спин-оффы, которые в эфир одним произведением не идут. +/// +public sealed record MetadataFranchise(string ExternalId, string Name); diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs index 25d3862..c68bf27 100644 --- a/backend/src/TeleWave.Domain/Library/Show.cs +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -135,16 +135,30 @@ public class Show public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0; /// Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null. + /// Франшиза шоу по данным источника: её идентификатор и название. + public string? FranchiseExternalId { get; private set; } + + public string? FranchiseName { get; private set; } + public void ApplyMetadata( string provider, string externalId, string? description, int? year, - Guid? posterImageId + Guid? posterImageId, + MetadataFranchise? franchise = null ) { MetadataProvider = provider; MetadataExternalId = externalId; + // Франшизу запоминаем, а не забываем сразу после применения: по ней потом предлагаются + // коллекции, и перезапрашивать источник ради каждого фильма было бы расточительно. + // Отсутствие в ответе — не повод стирать известное: у OMDb этих данных нет вовсе. + if (franchise is not null) + { + FranchiseExternalId = franchise.ExternalId; + FranchiseName = franchise.Name; + } if (!string.IsNullOrWhiteSpace(description)) Description = description; Year = year; diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs index a985f97..2ef932d 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -107,10 +107,30 @@ public sealed class TmdbMetadataProvider( GetString(root, "overview"), PosterUrl(GetString(root, "poster_path")), GenresFrom(root), - movie ? MovieCertification(root) : TvCertification(root) + movie ? MovieCertification(root) : TvCertification(root), + FranchiseFrom(root) ); } + /// + /// Франшиза фильма: TMDb отдаёт её прямо в ответе по фильму (belongs_to_collection), + /// отдельного запроса не нужно. У сериалов этого поля нет. + /// + private static MetadataFranchise? FranchiseFrom(JsonElement root) + { + if ( + !root.TryGetProperty("belongs_to_collection", out var block) + || block.ValueKind != JsonValueKind.Object + ) + return null; + + var id = GetInt(block, "id"); + var name = GetString(block, "name"); + return id is null || string.IsNullOrWhiteSpace(name) + ? null + : new MetadataFranchise(id.Value.ToString(CultureInfo.InvariantCulture), name.Trim()); + } + /// Код прокатного релиза в справочнике типов релизов TMDb. private const int TheatricalReleaseType = 3; diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260727022637_ShowFranchise.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260727022637_ShowFranchise.Designer.cs new file mode 100644 index 0000000..47ba466 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260727022637_ShowFranchise.Designer.cs @@ -0,0 +1,1407 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260727022637_ShowFranchise")] + partial class ShowFranchise + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AnalogFilterStrength") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LogoCorner") + .HasColumnType("integer"); + + b.Property("LogoImageId") + .HasColumnType("uuid"); + + b.Property("LogoOpacity") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("ShowClock") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FranchiseExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FranchiseName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260727022637_ShowFranchise.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260727022637_ShowFranchise.cs new file mode 100644 index 0000000..81895f6 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260727022637_ShowFranchise.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ShowFranchise : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FranchiseExternalId", + table: "Shows", + type: "character varying(64)", + maxLength: 64, + nullable: true + ); + + migrationBuilder.AddColumn( + name: "FranchiseName", + table: "Shows", + type: "character varying(256)", + maxLength: 256, + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "FranchiseExternalId", table: "Shows"); + + migrationBuilder.DropColumn(name: "FranchiseName", table: "Shows"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index ba6e6df..c6e8067 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -585,6 +585,14 @@ namespace TeleWave.Infrastructure.Migrations .HasMaxLength(2048) .HasColumnType("character varying(2048)"); + b.Property("FranchiseExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FranchiseName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + b.Property("Kind") .HasColumnType("integer"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs index 2933b44..54f7d57 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs @@ -14,6 +14,8 @@ public class ShowConfiguration : IEntityTypeConfiguration builder.Property(x => x.MetadataProvider).HasMaxLength(16); builder.Property(x => x.MetadataExternalId).HasMaxLength(64); builder.Property(x => x.OriginalName).HasMaxLength(256); + builder.Property(x => x.FranchiseExternalId).HasMaxLength(64); + builder.Property(x => x.FranchiseName).HasMaxLength(256); builder .HasMany(x => x.Episodes) diff --git a/backend/tests/TeleWave.Application.Tests/Library/CollectionSuggestionsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/CollectionSuggestionsTests.cs new file mode 100644 index 0000000..6ac06b5 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Library/CollectionSuggestionsTests.cs @@ -0,0 +1,142 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Library.Collections.AddShowsToCollection; +using TeleWave.Application.Library.Collections.Suggest; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Application.Tests.Library; + +/// +/// Сборка франшиз: предложения по метаданным и по названиям плюс массовое добавление в коллекцию. +/// Ошибка здесь означает чужой фильм в середине франшизы, поэтому осторожность важнее полноты. +/// +public class CollectionSuggestionsTests +{ + [Fact] + public async Task SuggestsFranchise_FromMetadata() + { + var fixture = new TestDb(); + var first = Movie("Гарри Поттер и философский камень", 2001, franchise: "1241"); + var second = Movie("Гарри Поттер и тайная комната", 2002, franchise: "1241"); + var other = Movie("Совсем другое", 1999, franchise: "777"); + + await SeedAsync(fixture, first, second, other); + + var suggestions = await BuildAsync(fixture); + + var franchise = Assert.Single( + suggestions, + s => s.Source == CollectionSuggestionSource.Metadata + ); + Assert.Equal("Франшиза 1241", franchise.Name); + // Порядок — по году: это и есть порядок просмотра. + Assert.Equal([first.Id, second.Id], franchise.Parts.Select(p => p.ShowId)); + } + + [Fact] + public async Task SuggestsByName_WhenMetadataMissing() + { + var fixture = new TestDb(); + var first = Movie("Рокки", 1976); + var second = Movie("Рокки 2", 1979); + await SeedAsync(fixture, first, second); + + var suggestions = await BuildAsync(fixture); + + var guess = Assert.Single( + suggestions, + s => s.Source == CollectionSuggestionSource.NameGuess + ); + Assert.Equal(2, guess.Parts.Count); + } + + [Fact] + public async Task DoesNotGuess_OnShortCommonStart() + { + // «Человек-паук» и «Человек дождя» — не франшиза, хотя начало совпадает. + var fixture = new TestDb(); + await SeedAsync(fixture, Movie("Человек дождя", 1988), Movie("Человек-слон", 1980)); + + var suggestions = await BuildAsync(fixture); + + Assert.DoesNotContain(suggestions, s => s.Source == CollectionSuggestionSource.NameGuess); + } + + [Fact] + public async Task SkipsShows_AlreadyInCollections() + { + var fixture = new TestDb(); + var first = Movie("Рокки", 1976, franchise: "1241"); + var second = Movie("Рокки 2", 1979, franchise: "1241"); + var collection = Collection.Create("Рокки"); + collection.AddShow(first.Id); + + await using (var seed = fixture.New()) + { + seed.Shows.AddRange(first, second); + seed.Collections.Add(collection); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var suggestions = await BuildAsync(fixture); + + // Одна часть уже разобрана — на франшизу оставшегося не хватает. + Assert.Empty(suggestions); + } + + [Fact] + public async Task AddShowsToCollection_CreatesNewAndOrdersByYear() + { + var fixture = new TestDb(); + var later = Movie("Вторая", 1979); + var earlier = Movie("Первая", 1976); + await SeedAsync(fixture, later, earlier); + + await using var db = fixture.New(); + var result = await new AddShowsToCollectionCommandHandler(db).Handle( + new AddShowsToCollectionCommand([later.Id, earlier.Id], NewCollectionName: "Франшиза"), + CancellationToken.None + ); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + + await using var check = fixture.New(); + var collection = check.Collections.Include(c => c.Items).Single(); + Assert.Equal(2, result.Value.Added); + Assert.Equal( + [earlier.Id, later.Id], + collection.Items.OrderBy(i => i.Position).Select(i => i.ShowId) + ); + } + + private static Show Movie(string name, int? year, string? franchise = null) + { + var show = Show.Create(name, ShowKind.Single); + if (franchise is not null) + show.ApplyMetadata( + "tmdb", + Guid.NewGuid().ToString("N"), + null, + year, + null, + new MetadataFranchise(franchise, $"Франшиза {franchise}") + ); + else + show.ApplyMetadata("manual", Guid.NewGuid().ToString("N"), null, year, null); + return show; + } + + private static async Task SeedAsync(TestDb fixture, params Show[] shows) + { + await using var seed = fixture.New(); + seed.Shows.AddRange(shows); + await seed.SaveChangesAsync(CancellationToken.None); + } + + private static async Task> BuildAsync(TestDb fixture) + { + await using var db = fixture.New(); + return await new CollectionSuggestionBuilder(db).BuildAsync(CancellationToken.None); + } +} diff --git a/frontend/src/features/admin/collections/CollectionSuggestions.tsx b/frontend/src/features/admin/collections/CollectionSuggestions.tsx new file mode 100644 index 0000000..8dd917b --- /dev/null +++ b/frontend/src/features/admin/collections/CollectionSuggestions.tsx @@ -0,0 +1,95 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ChevronDown, ChevronRight, Plus, Wand2 } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { qk } from '@/shared/api/query-keys' +import type { CollectionSuggestionDto } from '@/shared/api/types' +import { useApiError } from '@/shared/lib/use-api-error' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { toast } from '@/shared/ui/toast-store' +import { createCollectionFromSuggestion, suggestCollections } from './api' + +/** + * Что стоит собрать во франшизу. Состав виден целиком до создания — по названиям иногда + * промахивается, и проверить глазами должно быть проще, чем разбирать потом. + */ +export function CollectionSuggestions() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const onError = useApiError() + const [collapsed, setCollapsed] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: qk.collections.suggestions, + queryFn: suggestCollections, + }) + + const create = useMutation({ + mutationFn: (suggestion: CollectionSuggestionDto) => + createCollectionFromSuggestion(suggestion.key), + onSuccess: () => { + toast.success(t('admin.collections.suggestions.created')) + void queryClient.invalidateQueries({ queryKey: qk.collections.all }) + void queryClient.invalidateQueries({ queryKey: qk.shows.all }) + }, + onError, + }) + + if (isLoading || !data || data.length === 0) return null + + return ( +
+ + + {!collapsed && ( + <> +

{t('admin.collections.suggestions.hint')}

+
    + {data.map((suggestion) => ( +
  • +
    + {suggestion.name} + + {t(`admin.collections.suggestions.sources.${suggestion.source}`)} + + + {t('admin.collections.suggestions.parts', { count: suggestion.parts.length })} + + {suggestion.alreadyExists ? ( + {t('admin.collections.suggestions.exists')} + ) : ( + + )} +
    + {/* Состав в предлагаемом порядке: по нему и видно, промахнулась ли догадка. */} +

    + {suggestion.parts + .map((part) => (part.year ? `${part.name} (${part.year})` : part.name)) + .join(' → ')} +

    +
  • + ))} +
+ + )} +
+ ) +} diff --git a/frontend/src/features/admin/collections/CollectionsPanel.tsx b/frontend/src/features/admin/collections/CollectionsPanel.tsx index 2d56f1c..59b2b0e 100644 --- a/frontend/src/features/admin/collections/CollectionsPanel.tsx +++ b/frontend/src/features/admin/collections/CollectionsPanel.tsx @@ -10,6 +10,7 @@ import { Input } from '@/shared/ui/input' import { sortRows, useTableSort } from '@/shared/lib/table-sort' import { SortHeader } from '@/shared/ui/sortable' import { createCollection, deleteCollection, listCollections } from './api' +import { CollectionSuggestions } from './CollectionSuggestions' export function CollectionsPanel() { const { t } = useTranslation() @@ -68,6 +69,8 @@ export function CollectionsPanel() { + +
diff --git a/frontend/src/features/admin/collections/api.ts b/frontend/src/features/admin/collections/api.ts index 5c94587..dbcead1 100644 --- a/frontend/src/features/admin/collections/api.ts +++ b/frontend/src/features/admin/collections/api.ts @@ -1,5 +1,10 @@ import { apiRequest } from '@/shared/api/client' -import type { CollectionDto, CollectionSummaryDto, CreatedIdResponse } from '@/shared/api/types' +import type { + CollectionDto, + CollectionSuggestionDto, + CollectionSummaryDto, + CreatedIdResponse, +} from '@/shared/api/types' export function listCollections() { return apiRequest('/admin/collections') @@ -21,6 +26,30 @@ export function deleteCollection(id: string) { return apiRequest(`/admin/collections/${id}`, { method: 'DELETE' }) } +/** Массовое добавление во франшизу: существующая коллекция либо новая по имени. */ +export function addShowsToCollection(body: { + showIds: string[] + collectionId?: string + newCollectionName?: string +}) { + return apiRequest<{ collectionId: string; name: string; added: number }>( + '/admin/collections/bulk/shows', + { method: 'POST', body }, + ) +} + +/** Какие франшизы имеет смысл собрать из уже загруженных полнометражек. */ +export function suggestCollections() { + return apiRequest('/admin/collections/suggestions') +} + +export function createCollectionFromSuggestion(key: string) { + return apiRequest('/admin/collections/suggestions', { + method: 'POST', + body: { key }, + }) +} + export function addCollectionShow(id: string, showId: string) { return apiRequest(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } }) } diff --git a/frontend/src/features/admin/groups/GroupDetail.tsx b/frontend/src/features/admin/groups/GroupDetail.tsx index 19d454e..ef461b9 100644 --- a/frontend/src/features/admin/groups/GroupDetail.tsx +++ b/frontend/src/features/admin/groups/GroupDetail.tsx @@ -149,6 +149,10 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) { const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup) const isDynamic = group.mode === 'Dynamic' + // Закреплённое, которого черновик правила не находит, в составе всё равно останется. + const pinnedNotMatched = group.items.filter( + (i) => i.pinned && !(candidates ?? []).some((c) => c.elementId === i.elementId), + ).length return (
@@ -264,7 +268,16 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) { > {t('admin.groups.find')} - {candidates !== null && ( + {candidates !== null && isDynamic && ( + // У группы по правилу «добавить найденное» не имеет смысла — правило и есть состав. + // Показываем, каким станет состав, если сохранить черновик правила. + + {t('admin.groups.previewComposition', { + count: candidates.length + pinnedNotMatched, + })} + + )} + {candidates !== null && !isDynamic && ( <> {t('admin.groups.found', { total: candidates.length, fresh: fresh.length })} diff --git a/frontend/src/features/admin/groups/GroupsPanel.tsx b/frontend/src/features/admin/groups/GroupsPanel.tsx index fa9b0ec..fc076eb 100644 --- a/frontend/src/features/admin/groups/GroupsPanel.tsx +++ b/frontend/src/features/admin/groups/GroupsPanel.tsx @@ -17,6 +17,7 @@ export function GroupsPanel() { const { t } = useTranslation() const queryClient = useQueryClient() const [name, setName] = useState('') + const [query, setQuery] = useState('') const { sort, toggle } = useTableSort('name', false) const { data, isLoading } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups }) @@ -34,7 +35,11 @@ export function GroupsPanel() { }) const deleteMutation = useMutation({ mutationFn: deleteGroup, onSuccess: invalidate, onError }) - const rows = sortRows(data ?? [], sort, { + const term = query.trim().toLowerCase() + const matched = term + ? (data ?? []).filter((g) => g.name.toLowerCase().includes(term)) + : (data ?? []) + const rows = sortRows(matched, sort, { name: (g) => g.name.toLowerCase(), items: (g) => g.itemCount, units: (g) => g.unitCount, @@ -64,6 +69,13 @@ export function GroupsPanel() {
+ setQuery(e.target.value)} + /> +
diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 8cd0e6f..4c7c79b 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -9,6 +9,7 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Pager } from '@/shared/ui/pager' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' import { useTableSort } from '@/shared/lib/table-sort' import { SortHeader } from '@/shared/ui/sortable' import { deleteMedia, getMediaStats, listMedia } from './api' @@ -89,9 +90,14 @@ export function MediaPanel() { const lastActivity = useRef(activity) useEffect(() => { if (lastActivity.current === activity) return + // Очередь опустела — говорим об этом вслух: обработка идёт минутами, и следить за чипами, + // ожидая нуля, никто не должен. Сравниваем с прошлым состоянием, чтобы не поздравлять + // с пустой очередью при каждом заходе на экран. + const wasBusy = lastActivity.current !== '0:0' lastActivity.current = activity + if (wasBusy && activity === '0:0') toast.success(t('admin.media.queueDrained')) void refetch() - }, [activity, refetch]) + }, [activity, refetch, t]) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all }) const onError = useApiError() diff --git a/frontend/src/features/admin/shows/AddToCollectionDialog.tsx b/frontend/src/features/admin/shows/AddToCollectionDialog.tsx new file mode 100644 index 0000000..cef1f83 --- /dev/null +++ b/frontend/src/features/admin/shows/AddToCollectionDialog.tsx @@ -0,0 +1,114 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { addShowsToCollection, listCollections } from '@/features/admin/collections/api' +import { qk } from '@/shared/api/query-keys' +import { useApiError } from '@/shared/lib/use-api-error' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' + +/** Значение «завести новую» в списке коллекций: пустая строка у Radix зарезервирована под сброс. */ +const NEW = '__new__' + +/** + * Куда сложить отмеченные шоу: в существующую франшизу или в новую. Порядок частей сервер + * расставляет по году — правится он потом перетаскиванием в карточке коллекции. + */ +export function AddToCollectionDialog({ + showIds, + onClose, + onAdded, +}: Readonly<{ showIds: string[]; onClose: () => void; onAdded: () => void }>) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const onError = useApiError() + const [target, setTarget] = useState(NEW) + const [name, setName] = useState('') + + const { data: collections } = useQuery({ + queryKey: qk.collections.all, + queryFn: listCollections, + }) + + const add = useMutation({ + mutationFn: () => + addShowsToCollection({ + showIds, + collectionId: target === NEW ? undefined : target, + newCollectionName: target === NEW ? name.trim() : undefined, + }), + onSuccess: (result) => { + toast.success( + t('admin.shows.bulk.addedToCollection', { name: result.name, count: result.added }), + ) + void queryClient.invalidateQueries({ queryKey: qk.collections.all }) + onAdded() + onClose() + }, + onError, + }) + + const disabled = add.isPending || (target === NEW && !name.trim()) + + return ( + !open && onClose()}> + + + {t('admin.shows.bulk.toCollection')} + {t('admin.shows.bulk.toCollectionHint')} + + +
+
+ + +
+ + {target === NEW && ( +
+ + setName(e.target.value)} + /> +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/frontend/src/features/admin/shows/BulkTagBar.tsx b/frontend/src/features/admin/shows/BulkTagBar.tsx index 2e5bcfa..e5a816a 100644 --- a/frontend/src/features/admin/shows/BulkTagBar.tsx +++ b/frontend/src/features/admin/shows/BulkTagBar.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Sparkles, Tags } from 'lucide-react' +import { Layers, Sparkles, Tags } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { listGenres } from '@/features/admin/genres/api' @@ -10,6 +10,7 @@ import { Button } from '@/shared/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { bulkEnrichShows, bulkTagShows, getMetadataProviders } from './api' +import { AddToCollectionDialog } from './AddToCollectionDialog' /** Значение «не трогать рейтинг» в селекте — отдельное от «снять рейтинг» (AUDIENCE_UNSET). */ const KEEP = 'keep' @@ -30,6 +31,7 @@ export function BulkTagBar({ const onError = useApiError() const [genreId, setGenreId] = useState('') const [audience, setAudience] = useState(KEEP) + const [collectionOpen, setCollectionOpen] = useState(false) const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres }) const { data: providers } = useQuery({ @@ -129,9 +131,22 @@ export function BulkTagBar({ )} + + + + {collectionOpen && ( + setCollectionOpen(false)} + onAdded={invalidate} + /> + )}
) } diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 30dc587..68422ee 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -16,7 +16,7 @@ import { AddEpisodesDialog } from './AddEpisodesDialog' import { ShowGenresField } from './ShowGenresField' import { ShowMetadataCard } from './ShowMetadataCard' import { imageUrl } from '@/features/admin/images/api' -import { getShow, removeEpisode, setShowAudience } from './api' +import { getShow, getShowUsage, removeEpisode, setShowAudience } from './api' const PAGE_SIZE = 20 @@ -30,6 +30,13 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { queryKey: qk.shows.detail(showId), queryFn: () => getShow(showId), }) + // Где шоу задействовано — отдельным запросом: он обходит все группы, и держать его в карточке + // шоу заодно с остальным значило бы платить за него при каждом обновлении состава. + const { data: usage } = useQuery({ + queryKey: qk.shows.usage(showId), + queryFn: () => getShowUsage(showId), + }) + const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.detail(showId) }) const onError = useApiError() @@ -134,6 +141,30 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { ))}

)} + {/* Где шоу задействовано: без этого «используется» при удалении не объясняет, кем. */} + {usage && usage.groups.length > 0 && ( +

+ {t('admin.shows.inGroups')}: + {usage.groups.map((group) => ( + + {group.name} + {group.viaRule && ( + ({t('admin.groups.byRule')}) + )} + + ))} +

+ )} + {usage && usage.channels.length > 0 && ( +

+ {t('admin.shows.inChannels')}: {usage.channels.map((c) => c.name).join(', ')} +

+ )} diff --git a/frontend/src/features/admin/shows/api.ts b/frontend/src/features/admin/shows/api.ts index 2d567cb..d5131cc 100644 --- a/frontend/src/features/admin/shows/api.ts +++ b/frontend/src/features/admin/shows/api.ts @@ -9,6 +9,7 @@ import type { ShowDto, ShowKind, ShowSummaryDto, + ShowUsageDto, } from '@/shared/api/types' export function listShows(genreId?: string) { @@ -73,6 +74,11 @@ export function bulkEnrichShows(showIds: string[], provider: string) { }) } +/** Где шоу задействовано: группы, коллекции, каналы. */ +export function getShowUsage(id: string) { + return apiRequest(`/admin/shows/${id}/usage`) +} + export function deleteShow(id: string) { return apiRequest(`/admin/shows/${id}`, { method: 'DELETE' }) } diff --git a/frontend/src/shared/api/query-keys.ts b/frontend/src/shared/api/query-keys.ts index 86e071b..644bbfa 100644 --- a/frontend/src/shared/api/query-keys.ts +++ b/frontend/src/shared/api/query-keys.ts @@ -34,6 +34,8 @@ export const qk = { all: ['admin', 'shows'] as const, byGenre: (genreId: string | null) => ['admin', 'shows', { genreId }] as const, detail: (id: string) => ['admin', 'shows', id] as const, + /** Где шоу задействовано: группы, коллекции, каналы. */ + usage: (id: string) => ['admin', 'shows', id, 'usage'] as const, }, groups: { @@ -47,6 +49,7 @@ export const qk = { collections: { all: ['admin', 'collections'] as const, detail: (id: string) => ['admin', 'collections', id] as const, + suggestions: ['admin', 'collections', 'suggestions'] as const, }, genres: { diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 3f05509..e3ba05f 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -160,6 +160,20 @@ export type ShowSummaryDto = { otherGenres: string[] } +/** Где шоу задействовано — показывается на его карточке. */ +export type ShowUsageDto = { + groups: ShowUsageRefDto[] + collections: ShowUsageRefDto[] + channels: ShowUsageRefDto[] +} + +type ShowUsageRefDto = { + id: string + name: string + /** Шоу попало в группу правилом, а не явной позицией. */ + viaRule: boolean +} + type ShowGenreDto = { id: string name: string @@ -218,6 +232,18 @@ export type CollectionDto = { items: CollectionItemDto[] } +/** Откуда взялось предложение франшизы: данные источника либо догадка по названиям. */ +export type CollectionSuggestionSource = 'Metadata' | 'NameGuess' + +/** Предложение собрать франшизу: имя и части в предлагаемом порядке. */ +export type CollectionSuggestionDto = { + key: string + source: CollectionSuggestionSource + name: string + parts: { showId: string; name: string; year: number | null }[] + alreadyExists: boolean +} + /** Коллекция, в которую входит шоу — для блока на экране шоу. */ type ShowCollectionRefDto = { id: string diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index c03f2c1..954de95 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -116,6 +116,7 @@ export const en = { restore: 'Restore', dynamicOrderHint: 'Order: pinned items first (drag to arrange), then rule matches alphabetically.', + previewComposition: 'With this rule the composition would be: {{count}}', pendingFound: 'New matches for this rule: {{count}} —', pendingAdd: 'Add all ({{count}})', elementKinds: { Show: 'Show', Collection: 'Collection' }, @@ -165,6 +166,14 @@ export const en = { pickShow: 'Pick a show', empty: 'Collection is empty', orderHint: 'Drag to set the order of the parts — that is the order they air in.', + suggestions: { + title: 'Franchise suggestions', + hint: 'Built from movies not in any collection yet. Check the parts — the name-based guess sometimes misses.', + parts: '{{count}} parts', + exists: 'already exists', + created: 'Collection created', + sources: { Metadata: 'from metadata', NameGuess: 'by names' }, + }, }, interstitials: { title: 'Clips', @@ -281,6 +290,7 @@ export const en = { uploadingCount: 'Uploading {{done}}/{{total}}', cancelAll: 'Cancel all uploads', skippedDuplicates: 'Skipped duplicates: {{count}}', + queueDrained: 'Processing finished — the queue is empty', filterActive: 'Active', filterAll: 'All', name: 'File', @@ -363,12 +373,20 @@ export const en = { enriching: 'Searching…', enriched: 'Metadata applied: {{count}}', moreSkipped: 'Skipped as well: {{count}}', + toCollection: 'To collection…', + toCollectionHint: + 'The selected shows go into a franchise. Parts are ordered by release year; drag to rearrange them in the collection card.', + newCollection: 'New collection', + addToCollection: 'Add ({{count}})', + addedToCollection: '“{{name}}” updated: {{count}}', }, genresEmpty: 'No genres set', genresEdit: 'Genres', genresHint: 'Pick the genres of the show. The primary one is listed; all of them are used for selection.', genrePrimary: 'primary', + inGroups: 'In groups', + inChannels: 'Scheduled on', inCollections: 'Part of collections', episodes: 'Episodes', episode: 'Episode', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 3ef5df1..87a7aae 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -116,6 +116,7 @@ export const ru = { restore: 'Вернуть', dynamicOrderHint: 'Порядок: сначала закреплённое (перетаскиванием), затем найденное правилом по алфавиту.', + previewComposition: 'При этом правиле в составе будет: {{count}}', pendingFound: 'Под правило группы подходит нового: {{count}} —', pendingAdd: 'Добавить все ({{count}})', elementKinds: { Show: 'Шоу', Collection: 'Коллекция' }, @@ -165,6 +166,14 @@ export const ru = { pickShow: 'Выберите шоу', empty: 'Коллекция пуста', orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.', + suggestions: { + title: 'Предложения франшиз', + hint: 'Собрано из полнометражек, не входящих ни в одну коллекцию. Проверьте состав — по названиям догадка иногда промахивается.', + parts: '{{count}} частей', + exists: 'уже есть', + created: 'Коллекция создана', + sources: { Metadata: 'из метаданных', NameGuess: 'по названиям' }, + }, }, interstitials: { title: 'Ролики', @@ -282,6 +291,7 @@ export const ru = { uploadingCount: 'Загрузка {{done}}/{{total}}', cancelAll: 'Отменить все загрузки', skippedDuplicates: 'Пропущено дубликатов: {{count}}', + queueDrained: 'Обработка завершена — очередь пуста', filterActive: 'Активные', filterAll: 'Все', name: 'Файл', @@ -364,12 +374,20 @@ export const ru = { enriching: 'Ищем…', enriched: 'Метаданные применены: {{count}}', moreSkipped: 'Ещё пропущено: {{count}}', + toCollection: 'В коллекцию…', + toCollectionHint: + 'Отмеченные шоу уйдут во франшизу. Порядок частей — по году выпуска; поправить его можно перетаскиванием в карточке коллекции.', + newCollection: 'Новая коллекция', + addToCollection: 'Добавить ({{count}})', + addedToCollection: '«{{name}}» пополнена: {{count}}', }, genresEmpty: 'Жанры не проставлены', genresEdit: 'Жанры', genresHint: 'Отметьте жанры шоу. Основной показывается в списке; в отборе контента участвуют все.', genrePrimary: 'основной', + inGroups: 'В группах', + inChannels: 'В эфире каналов', inCollections: 'Входит в коллекции', episodes: 'Серии', episode: 'Серия',