From 72451f89a80119b4426310173aebb36a8002d646 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 11:10:33 +0300 Subject: [PATCH] Add original name support for shows: implement API endpoint to set original name, update Show and ShowDto models to include original name field, and enhance UI components for managing original names in show metadata. Update validation and database schema accordingly. --- .../TeleWave.Api/Endpoints/ShowEndpoints.cs | 20 + .../Library/CreateShow/CreateShowCommand.cs | 8 +- .../CreateShow/CreateShowCommandHandler.cs | 2 +- .../CreateShow/CreateShowCommandValidator.cs | 1 + .../Library/GetShow/GetShowQueryHandler.cs | 1 + .../SetShowOriginalNameCommand.cs | 7 + .../SetShowOriginalNameCommandHandler.cs | 26 + .../SetShowOriginalNameCommandValidator.cs | 12 + .../TeleWave.Application/Library/ShowDtos.cs | 1 + backend/src/TeleWave.Domain/Library/Show.cs | 19 +- ...0260725080749_ShowOriginalName.Designer.cs | 862 ++++++++++++++++++ .../20260725080749_ShowOriginalName.cs | 28 + .../Migrations/AppDbContextModelSnapshot.cs | 3 + .../features/admin/shows/ShowMetadataCard.tsx | 35 +- .../src/features/admin/shows/ShowsPanel.tsx | 11 +- frontend/src/features/admin/shows/api.ts | 14 +- frontend/src/shared/api/types.ts | 1 + frontend/src/shared/lib/i18n.ts | 8 + 18 files changed, 1052 insertions(+), 7 deletions(-) create mode 100644 backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommandValidator.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725080749_ShowOriginalName.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725080749_ShowOriginalName.cs diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs index e5dfc5c..33e467c 100644 --- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs @@ -7,6 +7,7 @@ using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Library.GetShow; using TeleWave.Application.Library.ListShows; using TeleWave.Application.Library.RemoveEpisode; +using TeleWave.Application.Library.SetShowOriginalName; using TeleWave.Infrastructure.Identity; namespace TeleWave.Api.Endpoints; @@ -22,6 +23,9 @@ public static class ShowEndpoints admin.MapPost("", CreateShow).Produces(StatusCodes.Status201Created); admin.MapGet("", ListShows).Produces>(); admin.MapGet("/{id:guid}", GetShow).Produces(); + admin + .MapPut("/{id:guid}/original-name", SetOriginalName) + .Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); admin .MapPost("/{id:guid}/episodes", AddEpisode) @@ -61,6 +65,20 @@ public static class ShowEndpoints return result.ToHttpResult(); } + private static async Task SetOriginalName( + Guid id, + SetShowOriginalNameBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetShowOriginalNameCommand(id, body.OriginalName), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task DeleteShow( Guid id, ISender sender, @@ -97,3 +115,5 @@ public static class ShowEndpoints } public sealed record AddEpisodeBody(Guid MediaAssetId); + +public sealed record SetShowOriginalNameBody(string? OriginalName); diff --git a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs index 7974734..dc24606 100644 --- a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs +++ b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs @@ -4,5 +4,9 @@ using TeleWave.Domain.Library; namespace TeleWave.Application.Library.CreateShow; -public sealed record CreateShowCommand(string Name, ShowKind Kind, string? Description) - : ICommand>; +public sealed record CreateShowCommand( + string Name, + ShowKind Kind, + string? Description, + string? OriginalName = null +) : ICommand>; diff --git a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs index c4e4017..773c975 100644 --- a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs @@ -10,7 +10,7 @@ public sealed class CreateShowCommandHandler(IAppDbContext dbContext) { public Task> Handle(CreateShowCommand command, CancellationToken cancellationToken) { - var show = Show.Create(command.Name, command.Kind, command.Description); + var show = Show.Create(command.Name, command.Kind, command.Description, command.OriginalName); dbContext.Shows.Add(show); return Task.FromResult(Result.Success(show.Id)); } diff --git a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandValidator.cs b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandValidator.cs index 36e2864..e931877 100644 --- a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandValidator.cs +++ b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandValidator.cs @@ -7,6 +7,7 @@ public sealed class CreateShowCommandValidator : AbstractValidator x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.OriginalName).MaximumLength(256); RuleFor(x => x.Description).MaximumLength(2048); } } diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs index aa87285..4668a8d 100644 --- a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs @@ -54,6 +54,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) new ShowDto( show.Id, show.Name, + show.OriginalName, show.Kind, show.Description, show.MetadataProvider, diff --git a/backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommand.cs b/backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommand.cs new file mode 100644 index 0000000..eb6bc3e --- /dev/null +++ b/backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.SetShowOriginalName; + +/// Задать/снять оригинальное название шоу (по нему ищутся метаданные). +public sealed record SetShowOriginalNameCommand(Guid Id, string? OriginalName) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommandHandler.cs b/backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommandHandler.cs new file mode 100644 index 0000000..4388ee7 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommandHandler.cs @@ -0,0 +1,26 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.SetShowOriginalName; + +public sealed class SetShowOriginalNameCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + SetShowOriginalNameCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext.Shows.FirstOrDefaultAsync( + s => s.Id == command.Id, + cancellationToken + ); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + show.SetOriginalName(command.OriginalName); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommandValidator.cs b/backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommandValidator.cs new file mode 100644 index 0000000..e3e688b --- /dev/null +++ b/backend/src/TeleWave.Application/Library/SetShowOriginalName/SetShowOriginalNameCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Library.SetShowOriginalName; + +public sealed class SetShowOriginalNameCommandValidator + : AbstractValidator +{ + public SetShowOriginalNameCommandValidator() + { + RuleFor(x => x.OriginalName).MaximumLength(256); + } +} diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index 489ac55..6d67399 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -32,6 +32,7 @@ public sealed record EpisodeDto( public sealed record ShowDto( Guid Id, string Name, + string? OriginalName, ShowKind Kind, string? Description, string? MetadataProvider, diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs index 24c4d54..3f4f784 100644 --- a/backend/src/TeleWave.Domain/Library/Show.cs +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -11,6 +11,11 @@ public class Show public Guid Id { get; private set; } public string Name { get; private set; } = string.Empty; + + /// Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах + /// продолжаем показывать . Null/пусто — ищем по . + public string? OriginalName { get; private set; } + public string? Description { get; private set; } public ShowKind Kind { get; private set; } public DateTimeOffset CreatedAt { get; private set; } @@ -33,11 +38,17 @@ public class Show private Show() { } - public static Show Create(string name, ShowKind kind, string? description = null) => + public static Show Create( + string name, + ShowKind kind, + string? description = null, + string? originalName = null + ) => new() { Id = Guid.NewGuid(), Name = name, + OriginalName = Normalize(originalName), Kind = kind, Description = description, CreatedAt = DateTimeOffset.UtcNow, @@ -49,6 +60,12 @@ public class Show Description = description; } + /// Задать/снять оригинальное название (пустая строка трактуется как отсутствие). + public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName); + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + /// Добавляет серию в конец. Для допустима ровно одна серия. public ShowEpisode AddEpisode(Guid mediaAssetId) { diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725080749_ShowOriginalName.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725080749_ShowOriginalName.Designer.cs new file mode 100644 index 0000000..4bbb918 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725080749_ShowOriginalName.Designer.cs @@ -0,0 +1,862 @@ +// +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("20260725080749_ShowOriginalName")] + partial class ShowOriginalName + { + /// + 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("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("ToShowId") + .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("BackgroundImageExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + 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.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperNextLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperNowLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperOnlyBetweenDifferentShows") + .HasColumnType("boolean"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .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("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", 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("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") + .HasColumnType("text"); + + b.Property("PosterPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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("StillPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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.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("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.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.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .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.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725080749_ShowOriginalName.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725080749_ShowOriginalName.cs new file mode 100644 index 0000000..35ee987 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725080749_ShowOriginalName.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ShowOriginalName : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginalName", + table: "Shows", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginalName", + table: "Shows"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index dc0c6ad..ff92711 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -482,6 +482,9 @@ namespace TeleWave.Infrastructure.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("OriginalName") + .HasColumnType("text"); + b.Property("PosterPath") .HasMaxLength(256) .HasColumnType("character varying(256)"); diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index 9d3d99e..c90e6a2 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -15,6 +15,7 @@ import { getMetadataProviders, refreshEpisodesMetadata, searchMetadata, + setShowOriginalName, showPosterUrl, updateMetadata, uploadPoster, @@ -25,7 +26,8 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged const posterInput = useRef(null) const [bust, setBust] = useState(0) const [provider, setProvider] = useState('') - const [query, setQuery] = useState(show.name) + const [originalName, setOriginalName] = useState(show.originalName ?? '') + const [query, setQuery] = useState(show.originalName || show.name) const [results, setResults] = useState([]) const [searched, setSearched] = useState(false) const [description, setDescription] = useState(show.description ?? '') @@ -53,6 +55,15 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged }, onError, }) + const saveOriginal = useMutation({ + mutationFn: () => setShowOriginalName(show.id, originalName.trim() || null), + onSuccess: () => { + toast.success(t('settings.saved')) + setQuery(originalName.trim() || show.name) + onChanged() + }, + onError, + }) const apply = useMutation({ mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId), onSuccess: () => { @@ -143,6 +154,28 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged {/* Поиск + ручная правка */}
+
+ +
+ setOriginalName(e.target.value)} + /> + +
+

{t('admin.metadata.originalNameHint')}

+
+ {providers && providers.length > 0 && (
diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index f0ac573..1db58cf 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -15,6 +15,7 @@ export function ShowsPanel() { const { t } = useTranslation() const queryClient = useQueryClient() const [name, setName] = useState('') + const [originalName, setOriginalName] = useState('') const [kind, setKind] = useState('Series') const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) @@ -23,9 +24,11 @@ export function ShowsPanel() { toast.error(error instanceof HttpError ? error.detail : t('common.error')) const createMutation = useMutation({ - mutationFn: () => createShow({ name: name.trim(), kind }), + mutationFn: () => + createShow({ name: name.trim(), kind, originalName: originalName.trim() || undefined }), onSuccess: () => { setName('') + setOriginalName('') invalidate() }, onError, @@ -43,6 +46,12 @@ export function ShowsPanel() { value={name} onChange={(e) => setName(e.target.value)} /> + setOriginalName(e.target.value)} + />