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: 'Серия',