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.

This commit is contained in:
Leonid Pershin
2026-07-25 11:10:33 +03:00
parent 84c2867062
commit 72451f89a8
18 changed files with 1052 additions and 7 deletions
@@ -7,6 +7,7 @@ using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow; using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows; using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode; using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
@@ -22,6 +23,9 @@ public static class ShowEndpoints
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created); admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>(); admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>(); admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin
.MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
admin admin
.MapPost("/{id:guid}/episodes", AddEpisode) .MapPost("/{id:guid}/episodes", AddEpisode)
@@ -61,6 +65,20 @@ public static class ShowEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> 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<IResult> DeleteShow( private static async Task<IResult> DeleteShow(
Guid id, Guid id,
ISender sender, ISender sender,
@@ -97,3 +115,5 @@ public static class ShowEndpoints
} }
public sealed record AddEpisodeBody(Guid MediaAssetId); public sealed record AddEpisodeBody(Guid MediaAssetId);
public sealed record SetShowOriginalNameBody(string? OriginalName);
@@ -4,5 +4,9 @@ using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.CreateShow; namespace TeleWave.Application.Library.CreateShow;
public sealed record CreateShowCommand(string Name, ShowKind Kind, string? Description) public sealed record CreateShowCommand(
: ICommand<Result<Guid>>; string Name,
ShowKind Kind,
string? Description,
string? OriginalName = null
) : ICommand<Result<Guid>>;
@@ -10,7 +10,7 @@ public sealed class CreateShowCommandHandler(IAppDbContext dbContext)
{ {
public Task<Result<Guid>> Handle(CreateShowCommand command, CancellationToken cancellationToken) public Task<Result<Guid>> 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); dbContext.Shows.Add(show);
return Task.FromResult(Result.Success(show.Id)); return Task.FromResult(Result.Success(show.Id));
} }
@@ -7,6 +7,7 @@ public sealed class CreateShowCommandValidator : AbstractValidator<CreateShowCom
public CreateShowCommandValidator() public CreateShowCommandValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.OriginalName).MaximumLength(256);
RuleFor(x => x.Description).MaximumLength(2048); RuleFor(x => x.Description).MaximumLength(2048);
} }
} }
@@ -54,6 +54,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
new ShowDto( new ShowDto(
show.Id, show.Id,
show.Name, show.Name,
show.OriginalName,
show.Kind, show.Kind,
show.Description, show.Description,
show.MetadataProvider, show.MetadataProvider,
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.SetShowOriginalName;
/// <summary>Задать/снять оригинальное название шоу (по нему ищутся метаданные).</summary>
public sealed record SetShowOriginalNameCommand(Guid Id, string? OriginalName) : ICommand<Result>;
@@ -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<SetShowOriginalNameCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Library.SetShowOriginalName;
public sealed class SetShowOriginalNameCommandValidator
: AbstractValidator<SetShowOriginalNameCommand>
{
public SetShowOriginalNameCommandValidator()
{
RuleFor(x => x.OriginalName).MaximumLength(256);
}
}
@@ -32,6 +32,7 @@ public sealed record EpisodeDto(
public sealed record ShowDto( public sealed record ShowDto(
Guid Id, Guid Id,
string Name, string Name,
string? OriginalName,
ShowKind Kind, ShowKind Kind,
string? Description, string? Description,
string? MetadataProvider, string? MetadataProvider,
+18 -1
View File
@@ -11,6 +11,11 @@ public class Show
public Guid Id { get; private set; } public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty; public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; }
public string? Description { get; private set; } public string? Description { get; private set; }
public ShowKind Kind { get; private set; } public ShowKind Kind { get; private set; }
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
@@ -33,11 +38,17 @@ public class Show
private 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() new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = name, Name = name,
OriginalName = Normalize(originalName),
Kind = kind, Kind = kind,
Description = description, Description = description,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
@@ -49,6 +60,12 @@ public class Show
Description = description; Description = description;
} }
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия.</summary> /// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия.</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId) public ShowEpisode AddEpisode(Guid mediaAssetId)
{ {
@@ -0,0 +1,862 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<double?>("AudioDurationSeconds")
.HasColumnType("double precision");
b.Property<string>("AudioExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("BackgroundColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundColor2")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundImageExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<string>("BumperNextLabel")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperNowLabel")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumperOnlyBetweenDifferentShows")
.HasColumnType("boolean");
b.Property<int>("BumperSelection")
.HasColumnType("integer");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("MetadataExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MetadataProvider")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OriginalName")
.HasColumnType("text");
b.Property<string>("PosterPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateOnly?>("AirDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Episode")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Overview")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int?>("Season")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<string>("StillPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowOriginalName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OriginalName",
table: "Shows",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OriginalName",
table: "Shows");
}
}
}
@@ -482,6 +482,9 @@ namespace TeleWave.Infrastructure.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
b.Property<string>("OriginalName")
.HasColumnType("text");
b.Property<string>("PosterPath") b.Property<string>("PosterPath")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
@@ -15,6 +15,7 @@ import {
getMetadataProviders, getMetadataProviders,
refreshEpisodesMetadata, refreshEpisodesMetadata,
searchMetadata, searchMetadata,
setShowOriginalName,
showPosterUrl, showPosterUrl,
updateMetadata, updateMetadata,
uploadPoster, uploadPoster,
@@ -25,7 +26,8 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
const posterInput = useRef<HTMLInputElement>(null) const posterInput = useRef<HTMLInputElement>(null)
const [bust, setBust] = useState(0) const [bust, setBust] = useState(0)
const [provider, setProvider] = useState('') 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<MetadataCandidate[]>([]) const [results, setResults] = useState<MetadataCandidate[]>([])
const [searched, setSearched] = useState(false) const [searched, setSearched] = useState(false)
const [description, setDescription] = useState(show.description ?? '') const [description, setDescription] = useState(show.description ?? '')
@@ -53,6 +55,15 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
}, },
onError, 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({ const apply = useMutation({
mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId), mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId),
onSuccess: () => { onSuccess: () => {
@@ -143,6 +154,28 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
{/* Поиск + ручная правка */} {/* Поиск + ручная правка */}
<div className="flex min-w-0 flex-1 flex-col gap-4"> <div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.metadata.originalName')}</Label>
<div className="flex items-end gap-2">
<Input
className="min-w-40 flex-1"
value={originalName}
maxLength={256}
placeholder={t('admin.metadata.originalNamePlaceholder')}
onChange={(e) => setOriginalName(e.target.value)}
/>
<Button
size="sm"
variant="outline"
disabled={saveOriginal.isPending || originalName === (show.originalName ?? '')}
onClick={() => saveOriginal.mutate()}
>
{t('common.save')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.metadata.originalNameHint')}</p>
</div>
{providers && providers.length > 0 && ( {providers && providers.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
@@ -15,6 +15,7 @@ export function ShowsPanel() {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [name, setName] = useState('') const [name, setName] = useState('')
const [originalName, setOriginalName] = useState('')
const [kind, setKind] = useState<ShowKind>('Series') const [kind, setKind] = useState<ShowKind>('Series')
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) 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')) toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => createShow({ name: name.trim(), kind }), mutationFn: () =>
createShow({ name: name.trim(), kind, originalName: originalName.trim() || undefined }),
onSuccess: () => { onSuccess: () => {
setName('') setName('')
setOriginalName('')
invalidate() invalidate()
}, },
onError, onError,
@@ -43,6 +46,12 @@ export function ShowsPanel() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
<Input
className="max-w-xs"
placeholder={t('admin.shows.originalName')}
value={originalName}
onChange={(e) => setOriginalName(e.target.value)}
/>
<Select value={kind} onValueChange={(v) => setKind(v as ShowKind)}> <Select value={kind} onValueChange={(v) => setKind(v as ShowKind)}>
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue /> <SelectValue />
+13 -1
View File
@@ -15,10 +15,22 @@ export function getShow(id: string) {
return apiRequest<ShowDto>(`/admin/shows/${id}`) return apiRequest<ShowDto>(`/admin/shows/${id}`)
} }
export function createShow(body: { name: string; kind: ShowKind; description?: string }) { export function createShow(body: {
name: string
kind: ShowKind
description?: string
originalName?: string
}) {
return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body }) return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body })
} }
export function setShowOriginalName(id: string, originalName: string | null) {
return apiRequest<void>(`/admin/shows/${id}/original-name`, {
method: 'PUT',
body: { originalName },
})
}
export function deleteShow(id: string) { export function deleteShow(id: string) {
return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' }) return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' })
} }
+1
View File
@@ -102,6 +102,7 @@ export type EpisodeDto = {
export type ShowDto = { export type ShowDto = {
id: string id: string
name: string name: string
originalName: string | null
kind: ShowKind kind: ShowKind
description: string | null description: string | null
metadataProvider: string | null metadataProvider: string | null
+8
View File
@@ -152,6 +152,7 @@ const resources = {
shows: { shows: {
title: 'Шоу', title: 'Шоу',
name: 'Название', name: 'Название',
originalName: 'Оригинальное название (eng)',
kind: 'Тип', kind: 'Тип',
kinds: { Series: 'Сериал', Single: 'Полнометражка' }, kinds: { Series: 'Сериал', Single: 'Полнометражка' },
seasons: 'Сезоны', seasons: 'Сезоны',
@@ -268,6 +269,9 @@ const resources = {
}, },
metadata: { metadata: {
title: 'Метаданные', title: 'Метаданные',
originalName: 'Оригинальное название (eng)',
originalNamePlaceholder: 'Например: Family Guy',
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
source: 'Источник', source: 'Источник',
sourceLabel: 'Источник', sourceLabel: 'Источник',
searchPlaceholder: 'Название для поиска', searchPlaceholder: 'Название для поиска',
@@ -437,6 +441,7 @@ const resources = {
shows: { shows: {
title: 'Shows', title: 'Shows',
name: 'Name', name: 'Name',
originalName: 'Original name (eng)',
kind: 'Kind', kind: 'Kind',
kinds: { Series: 'Series', Single: 'Movie' }, kinds: { Series: 'Series', Single: 'Movie' },
seasons: 'Seasons', seasons: 'Seasons',
@@ -553,6 +558,9 @@ const resources = {
}, },
metadata: { metadata: {
title: 'Metadata', title: 'Metadata',
originalName: 'Original name (eng)',
originalNamePlaceholder: 'e.g. Family Guy',
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',
source: 'Source', source: 'Source',
sourceLabel: 'Source', sourceLabel: 'Source',
searchPlaceholder: 'Title to search', searchPlaceholder: 'Title to search',