Refactor .gitignore to streamline ignored files and enhance clarity. Update CLAUDE.md to improve unit test instructions and add coverage reporting details. Revise README.md for better project overview and deployment instructions. Refactor ChannelEndpoints and StreamingEndpoints to utilize SegmentFiles for file resolution, improving code maintainability. Remove unused JunctionHandlers and update DependencyInjection for cleaner service registration. Enhance media processing services for better job handling and error management. Update frontend API types for consistency and clarity.
build / backend (push) Successful in 1m28s
build / frontend (push) Failing after 31s
tests / backend-tests (push) Canceled after 0s
sonar / analyze (push) Successful in 4m39s

This commit is contained in:
Leonid Pershin
2026-07-26 20:43:38 +03:00
parent f36dbfa9cb
commit 205672b77d
77 changed files with 3292 additions and 3102 deletions
+82
View File
@@ -0,0 +1,82 @@
name: sonar
# Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты
# и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана
# на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте.
on:
push:
branches: [main]
workflow_dispatch:
jobs:
analyze:
runs-on: ubuntu-latest
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST: https://sonarcloud.io
SONAR_PROJECT_KEY: mrleo1nid_telewave
SONAR_ORGANIZATION: mrleo1nid
steps:
- uses: actions/checkout@v4
with:
# Полная история — иначе Sonar не сопоставит изменения с авторами и «новым кодом».
fetch-depth: 0
# Сканер — java-приложение, на runner-образе Java может не оказаться.
- uses: actions/setup-java@v4
if: env.SONAR_TOKEN != ''
with:
distribution: temurin
java-version: 21
- uses: actions/setup-dotnet@v4
if: env.SONAR_TOKEN != ''
with:
dotnet-version: 10.0.x
- name: Install scanner
if: env.SONAR_TOKEN != ''
run: dotnet tool install --global dotnet-sonarscanner
# Сканер запускается из корня репозитория, а не из backend/: так в анализ попадает и фронт.
- name: Begin analysis
if: env.SONAR_TOKEN != ''
run: |
export PATH="$PATH:$HOME/.dotnet/tools"
dotnet sonarscanner begin \
/k:"$SONAR_PROJECT_KEY" \
/o:"$SONAR_ORGANIZATION" \
/d:sonar.host.url="$SONAR_HOST" \
/d:sonar.token="$SONAR_TOKEN" \
/d:sonar.cs.opencover.reportsPaths="**/coverage/**/coverage.opencover.xml" \
/d:sonar.exclusions="frontend/node_modules/**,frontend/dist/**,frontend/src/routeTree.gen.ts,backend/src/TeleWave.Infrastructure/Migrations/**" \
/d:sonar.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**"
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
# сборка живёт в build.yml, здесь она нужна только как носитель анализа.
- name: Build
if: env.SONAR_TOKEN != ''
run: dotnet build backend/TeleWave.slnx -c Release /p:TreatWarningsAsErrors=false
# Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его.
# Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture).
- name: Test + coverage
if: env.SONAR_TOKEN != ''
run: >
dotnet test backend/TeleWave.slnx
-c Release
--no-build
--collect:"XPlat Code Coverage;Format=opencover"
--results-directory backend/coverage
--logger "console;verbosity=normal"
- name: End analysis
if: env.SONAR_TOKEN != ''
run: |
export PATH="$PATH:$HOME/.dotnet/tools"
dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN"
- name: Skipped
if: env.SONAR_TOKEN == ''
run: echo "SONAR_TOKEN не задан — анализ пропущен."
+4
View File
@@ -80,3 +80,7 @@ dist-ssr/
# Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json) # Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json)
.dev-media/ .dev-media/
# Отчёты покрытия (артефакт dotnet test / CI)
coverage/
TestResults/
+3 -1
View File
@@ -117,7 +117,9 @@
Backend (из `backend/`): Backend (из `backend/`):
```bash ```bash
dotnet build dotnet build
dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests # Юнит-тесты (по одному проекту за вызов — MSBuild не принимает несколько):
dotnet test tests/TeleWave.Domain.Tests
dotnet test tests/TeleWave.Application.Tests
# Интеграционные тесты (Testcontainers-Postgres) — нужен запущенный Docker; без него пропускаются: # Интеграционные тесты (Testcontainers-Postgres) — нужен запущенный Docker; без него пропускаются:
dotnet test tests/TeleWave.Integration.Tests dotnet test tests/TeleWave.Integration.Tests
dotnet run --project src/TeleWave.Api dotnet run --project src/TeleWave.Api
+1
View File
@@ -3,6 +3,7 @@
[![build](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/build.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=build.yml) [![build](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/build.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=build.yml)
[![tests](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/test.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml) [![tests](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/test.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml)
[![coverage](https://gitea.hsrv.site/mrleo1nid/TeleWave/raw/branch/badges/coverage.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml) [![coverage](https://gitea.hsrv.site/mrleo1nid/TeleWave/raw/branch/badges/coverage.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml)
[![quality gate](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из **TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере, админ управляет каналами и пользователями. хранилища на сервере, админ управляет каналами и пользователями.
@@ -0,0 +1,38 @@
using System.Text.RegularExpressions;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Common;
/// <summary>
/// Общая проверка файлов нарезки для всех эндпоинтов, отдающих HLS: эфир, превью заставок.
/// Имя сегмента сверяется с allowlist, а путь резолвится через <see cref="MediaPathResolver"/>,
/// который бросает <see cref="UnauthorizedAccessException"/> на попытку выйти за пределы каталога —
/// наружу это должно выглядеть как обычный 404, а не как ошибка сервера.
/// </summary>
internal static partial class SegmentFiles
{
[GeneratedRegex(@"^seg\d{1,6}\.ts$")]
private static partial Regex SegmentName();
public static bool IsSegmentName(string file) => SegmentName().IsMatch(file);
/// <summary>Путь к существующему файлу нарезки, либо null — если имя опасно или файла нет.</summary>
public static string? TryResolveExisting(
MediaPathResolver paths,
Guid assetId,
string fileName
)
{
string path;
try
{
path = paths.SegmentPath(assetId, fileName);
}
catch (UnauthorizedAccessException)
{
return null;
}
return File.Exists(path) ? path : null;
}
}
@@ -1,5 +1,4 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs; using LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
@@ -13,11 +12,6 @@ namespace TeleWave.Api.Endpoints;
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary> /// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
public static partial class ChannelEndpoints public static partial class ChannelEndpoints
{ {
private static readonly Regex BumperSegmentFileName = new(
@"^seg\d{1,6}\.ts$",
RegexOptions.Compiled
);
private static async Task<IResult> AddBumperTemplate( private static async Task<IResult> AddBumperTemplate(
Guid id, Guid id,
AddBumperTemplateBody body, AddBumperTemplateBody body,
@@ -229,16 +223,7 @@ public static partial class ChannelEndpoints
) )
{ {
var previewId = BumperPreview.AssetId(variantId); var previewId = BumperPreview.AssetId(variantId);
string indexPath; if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
try
{
indexPath = paths.SegmentPath(previewId, "index.m3u8");
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(indexPath))
return Results.NotFound(); return Results.NotFound();
var baseUrl = var baseUrl =
@@ -265,20 +250,11 @@ public static partial class ChannelEndpoints
MediaPathResolver paths MediaPathResolver paths
) )
{ {
if (!BumperSegmentFileName.IsMatch(file)) if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound(); return Results.NotFound();
var previewId = BumperPreview.AssetId(variantId); var previewId = BumperPreview.AssetId(variantId);
string path; if (SegmentFiles.TryResolveExisting(paths, previewId, file) is not { } path)
try
{
path = paths.SegmentPath(previewId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound(); return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true); return Results.File(path, "video/mp2t", enableRangeProcessing: true);
@@ -1,6 +1,5 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs; using LiteCqrs;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common; using TeleWave.Api.Common;
@@ -17,7 +16,6 @@ namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints public static class StreamingEndpoints
{ {
private const string StreamCookieName = "tw_stream"; private const string StreamCookieName = "tw_stream";
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{ {
@@ -143,20 +141,10 @@ public static class StreamingEndpoints
{ {
if (tokens.Validate(request.Cookies[StreamCookieName]) is null) if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized(); return Results.Unauthorized();
if (!SegmentFileName.IsMatch(file)) if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound(); return Results.NotFound();
string path; if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
try
{
path = paths.SegmentPath(assetId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound(); return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable"; response.Headers.CacheControl = "public, max-age=31536000, immutable";
@@ -0,0 +1,110 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил только
/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку,
/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения,
/// воркер лишь крутит ffmpeg.
/// </summary>
public sealed class BumperSpecLoader(
IAppDbContext dbContext,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
)
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Спецификация заставки для ассета, либо null если восстановить её уже нельзя.</summary>
public async Task<BumperRenderSpec?> LoadAsync(
Guid assetId,
CancellationToken cancellationToken
)
{
var cache = await dbContext
.BumperAssets.AsNoTracking()
.Where(b => b.MediaAssetId == assetId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (cache is null)
return null;
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
if (channel is null || template is null || variant is null)
return null;
var names = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
string? posterPath = null;
if (variant.Kind == BumperTextKind.NowNext)
posterPath = await ResolveShowPosterAsync(cache.ToShowId, cancellationToken);
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
);
return BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
names.GetValueOrDefault(cache.FromShowId, "…"),
names.GetValueOrDefault(cache.ToShowId, "…"),
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterPath,
bgPath
);
}
private async Task<string?> ResolveShowPosterAsync(
Guid showId,
CancellationToken cancellationToken
)
{
var posterImageId = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId && s.PosterImageId != null)
.Select(s => s.PosterImageId)
.FirstOrDefaultAsync(cancellationToken);
return await ResolveImagePathAsync(posterImageId, cancellationToken);
}
private async Task<string?> ResolveImagePathAsync(
Guid? imageId,
CancellationToken cancellationToken
)
{
if (imageId is not { } id)
return null;
var ext = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == id)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return ext is null ? null : imageStore.ResolvePath(id, ext);
}
}
@@ -3,7 +3,12 @@ using FluentValidation;
using LiteCqrs.Behaviors; using LiteCqrs.Behaviors;
using LiteCqrs.DependencyInjection; using LiteCqrs.DependencyInjection;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Behaviors; using TeleWave.Application.Common.Behaviors;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
namespace TeleWave.Application; namespace TeleWave.Application;
@@ -26,6 +31,19 @@ public static class DependencyInjection
RegisterClosedGeneric(services, assembly, typeof(IValidator<>)); RegisterClosedGeneric(services, assembly, typeof(IValidator<>));
// Сервисы самого слоя приложения — не хендлеры, а общие для них помощники. Регистрируются
// здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application.
services.AddScoped<BumperSpecLoader>();
services.AddScoped<GenreMatcher>();
services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupStatsService>();
services.AddScoped<GroupMembershipCleaner>();
services.AddScoped<SlotWriter>();
services.AddScoped<GroupExpander>();
services.AddScoped<BumperResolver>();
services.AddScoped<PostCheckRunner>();
services.AddScoped<GridScheduleGenerator>();
return services; return services;
} }
@@ -16,11 +16,6 @@ public static class GroupErrors
"Шоу или коллекция не найдены." "Шоу или коллекция не найдены."
); );
public static readonly Error ElementAlreadyAdded = Error.Conflict(
"Groups.ElementAlreadyAdded",
"Этот элемент уже входит в группу."
);
public static readonly Error FilterNotSet = Error.Validation( public static readonly Error FilterNotSet = Error.Validation(
"Groups.FilterNotSet", "Groups.FilterNotSet",
"У группы не задано правило набора." "У группы не задано правило набора."
@@ -0,0 +1,27 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddJunctionElementCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
var element = junction.AddElement(command.Kind);
await JunctionLoader.MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
return Result.Success(element.Id);
}
}
@@ -0,0 +1,25 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateJunctionCommand command,
CancellationToken cancellationToken
)
{
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
return Result.Failure<Guid>(ChannelErrors.NotFound);
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
dbContext.JunctionTemplates.Add(junction);
return Result.Success(junction.Id);
}
}
@@ -0,0 +1,39 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteJunctionCommand, Result>
{
public async Task<Result> Handle(
DeleteJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
// Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
var used = await dbContext.Slots.AnyAsync(
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
cancellationToken
);
if (used)
return Result.Failure(TemplateErrors.JunctionInUse);
dbContext.JunctionTemplates.Remove(junction);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -1,297 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListJunctionsQuery, IReadOnlyList<JunctionTemplateDto>>
{
public async Task<IReadOnlyList<JunctionTemplateDto>> Handle(
ListJunctionsQuery query,
CancellationToken cancellationToken
)
{
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == query.ChannelId)
.OrderBy(j => j.Name)
.ToListAsync(cancellationToken);
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
var groupIds = junctions
.SelectMany(j => j.Elements)
.Select(e => e.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var bumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == query.ChannelId)
.SelectMany(c => c.BumperTemplates)
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
return junctions
.Select(j => new JunctionTemplateDto(
j.Id,
j.Name,
j.Elements.OrderBy(e => e.Position)
.Select(e => new JunctionElementDto(
e.Id,
e.Position,
e.Kind,
e.GroupId,
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
e.BumperTemplateId,
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
? bname
: null,
e.AmountMode,
e.AmountValue,
e.IsRequired,
JunctionConditions.FromJson(e.ConditionsJson)
))
.ToList()
))
.ToList();
}
}
public sealed class CreateJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateJunctionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateJunctionCommand command,
CancellationToken cancellationToken
)
{
if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken))
return Result.Failure<Guid>(ChannelErrors.NotFound);
var junction = JunctionTemplate.Create(command.ChannelId, command.Name);
dbContext.JunctionTemplates.Add(junction);
return Result.Success(junction.Id);
}
}
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RenameJunctionCommand, Result>
{
public async Task<Result> Handle(
RenameJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Rename(command.Name);
return await MarkTemplateChangedAsync(dbContext, junction, cancellationToken);
}
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
internal static async Task<Result> MarkTemplateChangedAsync(
IAppDbContext dbContext,
JunctionTemplate junction,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.ChannelId == junction.ChannelId,
cancellationToken
);
template?.MarkChanged();
return Result.Success();
}
}
public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteJunctionCommand, Result>
{
public async Task<Result> Handle(
DeleteJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
// Слот, ссылающийся на удалённый стык, молча остался бы без врезок — проверяем заранее.
var used = await dbContext.Slots.AnyAsync(
s => s.JunctionBetweenId == junction.Id || s.JunctionAfterId == junction.Id,
cancellationToken
);
if (used)
return Result.Failure(TemplateErrors.JunctionInUse);
dbContext.JunctionTemplates.Remove(junction);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
public sealed class AddJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddJunctionElementCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure<Guid>(TemplateErrors.JunctionNotFound);
var element = junction.AddElement(command.Kind);
await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
return Result.Success(element.Id);
}
}
public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateJunctionElementCommand, Result>
{
public async Task<Result> Handle(
UpdateJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
var element = junction?.FindElement(command.ElementId);
if (junction is null || element is null)
return Result.Failure(TemplateErrors.JunctionElementNotFound);
var input = command.Input;
if (input.Kind == JunctionElementKind.Bumper)
{
var known = await dbContext
.Channels.Where(c => c.Id == junction.ChannelId)
.SelectMany(c => c.BumperTemplates)
.AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
if (!known)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
}
else
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.JunctionGroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
element.Update(
input.Kind,
input.GroupId,
input.BumperTemplateId,
input.AmountMode,
input.AmountValue,
input.IsRequired,
input.Conditions?.ToJson()
);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveJunctionElementCommand, Result>
{
public async Task<Result> Handle(
RemoveJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null || !junction.RemoveElement(command.ElementId))
return Result.Failure(TemplateErrors.JunctionElementNotFound);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ReorderJunctionCommand, Result>
{
public async Task<Result> Handle(
ReorderJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Reorder(command.ElementIdsInOrder);
return await RenameJunctionCommandHandler.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
internal static class JunctionLoader
{
public static Task<JunctionTemplate?> LoadAsync(
IAppDbContext dbContext,
Guid junctionId,
CancellationToken cancellationToken
) =>
dbContext
.JunctionTemplates.Include(j => j.Elements)
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
}
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
/// <summary>Общее для всех команд стыка: загрузка шаблона и отметка правил эфира изменёнными.</summary>
internal static class JunctionLoader
{
public static Task<JunctionTemplate?> LoadAsync(
IAppDbContext dbContext,
Guid junctionId,
CancellationToken cancellationToken
) =>
dbContext
.JunctionTemplates.Include(j => j.Elements)
.FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken);
/// <summary>Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым.</summary>
public static async Task<Result> MarkTemplateChangedAsync(
IAppDbContext dbContext,
JunctionTemplate junction,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.ChannelId == junction.ChannelId,
cancellationToken
);
template?.MarkChanged();
return Result.Success();
}
}
@@ -0,0 +1,67 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class ListJunctionsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListJunctionsQuery, IReadOnlyList<JunctionTemplateDto>>
{
public async Task<IReadOnlyList<JunctionTemplateDto>> Handle(
ListJunctionsQuery query,
CancellationToken cancellationToken
)
{
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == query.ChannelId)
.OrderBy(j => j.Name)
.ToListAsync(cancellationToken);
// Имена групп и блоков заставок резолвим одним проходом — редактор показывает их сразу.
var groupIds = junctions
.SelectMany(j => j.Elements)
.Select(e => e.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var bumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == query.ChannelId)
.SelectMany(c => c.BumperTemplates)
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
return junctions
.Select(j => new JunctionTemplateDto(
j.Id,
j.Name,
j.Elements.OrderBy(e => e.Position)
.Select(e => new JunctionElementDto(
e.Id,
e.Position,
e.Kind,
e.GroupId,
e.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
e.BumperTemplateId,
e.BumperTemplateId is { } bid && bumperNames.TryGetValue(bid, out var bname)
? bname
: null,
e.AmountMode,
e.AmountValue,
e.IsRequired,
JunctionConditions.FromJson(e.ConditionsJson)
))
.ToList()
))
.ToList();
}
}
@@ -0,0 +1,29 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class RemoveJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveJunctionElementCommand, Result>
{
public async Task<Result> Handle(
RemoveJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null || !junction.RemoveElement(command.ElementId))
return Result.Failure(TemplateErrors.JunctionElementNotFound);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -0,0 +1,30 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class RenameJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RenameJunctionCommand, Result>
{
public async Task<Result> Handle(
RenameJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Rename(command.Name);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -0,0 +1,30 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class ReorderJunctionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ReorderJunctionCommand, Result>
{
public async Task<Result> Handle(
ReorderJunctionCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
if (junction is null)
return Result.Failure(TemplateErrors.JunctionNotFound);
junction.Reorder(command.ElementIdsInOrder);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -0,0 +1,62 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Junctions;
public sealed class UpdateJunctionElementCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateJunctionElementCommand, Result>
{
public async Task<Result> Handle(
UpdateJunctionElementCommand command,
CancellationToken cancellationToken
)
{
var junction = await JunctionLoader.LoadAsync(
dbContext,
command.JunctionId,
cancellationToken
);
var element = junction?.FindElement(command.ElementId);
if (junction is null || element is null)
return Result.Failure(TemplateErrors.JunctionElementNotFound);
var input = command.Input;
if (input.Kind == JunctionElementKind.Bumper)
{
var known = await dbContext
.Channels.Where(c => c.Id == junction.ChannelId)
.SelectMany(c => c.BumperTemplates)
.AnyAsync(t => t.Id == input.BumperTemplateId, cancellationToken);
if (!known)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
}
else
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.JunctionGroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
element.Update(
input.Kind,
input.GroupId,
input.BumperTemplateId,
input.AmountMode,
input.AmountValue,
input.IsRequired,
input.Conditions?.ToJson()
);
return await JunctionLoader.MarkTemplateChangedAsync(
dbContext,
junction,
cancellationToken
);
}
}
@@ -281,9 +281,13 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
.Select(i => new { i.CollectionId, i.ShowId }) .Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds.Concat(partsByCollection.Select(p => p.ShowId)).Distinct().ToList();
var audiences = await dbContext var audiences = await dbContext
.Shows.AsNoTracking() .Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id)) .Where(s => neededShowIds.Contains(s.Id))
.Select(s => new { s.Id, s.Audience }) .Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken); .ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
@@ -8,10 +8,6 @@ using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.Scheduling; using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Infrastructure.Broadcast; using TeleWave.Infrastructure.Broadcast;
@@ -113,15 +109,6 @@ public static class DependencyInjection
services.AddScoped<ISiteSettings, SiteSettings>(); services.AddScoped<ISiteSettings, SiteSettings>();
services.AddScoped<DbInitializer>(); services.AddScoped<DbInitializer>();
services.AddScoped<GenreSeeder>(); services.AddScoped<GenreSeeder>();
services.AddScoped<GenreMatcher>();
services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupStatsService>();
services.AddScoped<GroupMembershipCleaner>();
services.AddScoped<SlotWriter>();
services.AddScoped<GroupExpander>();
services.AddScoped<BumperResolver>();
services.AddScoped<PostCheckRunner>();
services.AddScoped<GridScheduleGenerator>();
AddMedia(services, configuration); AddMedia(services, configuration);
AddBroadcast(services, configuration); AddBroadcast(services, configuration);
@@ -1,143 +1,64 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary>Захваченная на рендер заставка: спецификация собирается уже в самой работе.</summary>
internal sealed record BumperRenderJob(Guid AssetId);
/// <summary> /// <summary>
/// Асинхронно рендерит ТВ-заставки расписания: планировщик лишь создаёт ассет (Source=Generated) в /// Асинхронно рендерит ТВ-заставки расписания: планировщик лишь создаёт ассет (Source=Generated) в
/// статусе Pending и кэш-строку <see cref="Domain.Broadcast.BumperAsset"/>, а сам ffmpeg крутится здесь, /// статусе Pending и кэш-строку <see cref="Domain.Broadcast.BumperAsset"/>, а сам ffmpeg крутится здесь,
/// вне тика планировщика и его транзакции. Источник истины — статус в БД (последовательно берём /// вне тика планировщика и его транзакции. Захват работы и устойчивость к рестарту — в
/// следующий Pending c Source=Generated, помечаем Processing), поэтому рестарт/краш ничего не теряет /// <see cref="MediaClaimingBackgroundService{TJob}"/>. До готовности ассета плейлист отдаёт филлер.
/// (прерванные Processing сбрасываются в Pending на старте). До готовности ассета плейлист отдаёт филлер. ///
/// Рендер строго последовательный: ffmpeg заставки короткий, а параллелить его смысла нет —
/// очередь разбирается быстрее, чем планировщик успевает её пополнять.
/// </summary> /// </summary>
public sealed class BumperRenderBackgroundService( internal sealed class BumperRenderBackgroundService(
IBumperRenderQueue queue, IBumperRenderQueue queue,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IBumperRenderer renderer, IBumperRenderer renderer,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions,
ILogger<BumperRenderBackgroundService> logger ILogger<BumperRenderBackgroundService> logger
) : BackgroundService ) : MediaClaimingBackgroundService<BumperRenderJob>(scopeFactory, logger)
{ {
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30); protected override bool HandlesGenerated => true;
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override string LoopErrorMessage => "Ошибка цикла рендера заставок";
{
await ResetInterruptedAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested) protected override ValueTask WaitForWorkAsync(CancellationToken cancellationToken) =>
{ queue.WaitAsync(cancellationToken);
try
{
// Разбираем всю накопившуюся работу из БД.
while (!stoppingToken.IsCancellationRequested)
{
var assetId = await ClaimNextAsync(stoppingToken);
if (assetId is not { } id)
break;
await RenderClaimedAsync(id, stoppingToken);
}
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); protected override BumperRenderJob ToJob(MediaAsset asset) => new(asset.Id);
wake.CancelAfter(IdlePoll);
try
{
await queue.WaitAsync(wake.Token);
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
// Тайм-аут опроса — просто перепроверяем БД.
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка цикла рендера заставок");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
/// <summary>Сброс прерванных рестартом заставок (Generated Processing → Pending) на старте.</summary> protected override async Task ProcessAsync(
private async Task ResetInterruptedAsync(CancellationToken cancellationToken) BumperRenderJob job,
{ CancellationToken cancellationToken
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var interrupted = await db
.MediaAssets.Where(x =>
x.Status == MediaAssetStatus.Processing && x.Source == MediaSource.Generated
) )
.ToListAsync(cancellationToken);
if (interrupted.Count == 0)
return;
foreach (var asset in interrupted)
asset.ResetToPending();
await db.SaveChangesAsync(cancellationToken);
}
/// <summary>Атомарно захватывает самую раннюю Pending-заставку (Generated): Pending → Processing.</summary>
private async Task<Guid?> ClaimNextAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db
.MediaAssets.Where(x =>
x.Status == MediaAssetStatus.Pending && x.Source == MediaSource.Generated
)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (asset is null)
return null;
asset.MarkProcessing();
await db.SaveChangesAsync(cancellationToken);
return asset.Id;
}
private async Task RenderClaimedAsync(Guid assetId, CancellationToken cancellationToken)
{ {
try try
{ {
await using var scope = scopeFactory.CreateAsyncScope(); var spec = await WithScopeAsync<BumperSpecLoader, BumperRenderSpec?>(
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>(); loader => loader.LoadAsync(job.AssetId, cancellationToken)
);
var spec = await BuildSpecAsync(db, assetId, cancellationToken);
if (spec is null) if (spec is null)
{ {
await FailAsync( await FailAsync(
assetId, job.AssetId,
"Не удалось восстановить спецификацию заставки", "Не удалось восстановить спецификацию заставки",
cancellationToken cancellationToken
); );
return; return;
} }
var render = await renderer.RenderAsync(assetId, spec, cancellationToken); var render = await renderer.RenderAsync(job.AssetId, spec, cancellationToken);
var asset = await db.MediaAssets.FirstOrDefaultAsync( await WithAssetAsync(
a => a.Id == assetId, job.AssetId,
cancellationToken asset =>
);
if (asset is null)
return;
asset.MarkReady( asset.MarkReady(
render.Duration, render.Duration,
render.SegmentSeconds, render.SegmentSeconds,
@@ -147,8 +68,9 @@ public sealed class BumperRenderBackgroundService(
"h264", "h264",
"aac", "aac",
render.RelativePath render.RelativePath
),
cancellationToken
); );
await db.SaveChangesAsync(cancellationToken);
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
@@ -156,126 +78,8 @@ public sealed class BumperRenderBackgroundService(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Рендер заставки {AssetId} провалился", assetId); logger.LogError(ex, "Рендер заставки {AssetId} провалился", job.AssetId);
await FailAsync(assetId, ex.Message, CancellationToken.None); await FailAsync(job.AssetId, ex.Message, CancellationToken.None);
} }
} }
/// <summary>Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки (канал/блок/подблок).</summary>
private async Task<BumperRenderSpec?> BuildSpecAsync(
IAppDbContext db,
Guid assetId,
CancellationToken cancellationToken
)
{
var cache = await db
.BumperAssets.AsNoTracking()
.Where(b => b.MediaAssetId == assetId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (cache is null)
return null;
var channel = await db
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
if (channel is null || template is null || variant is null)
return null;
var names = await db
.Shows.AsNoTracking()
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var fromName = names.GetValueOrDefault(cache.FromShowId, "…");
var toName = names.GetValueOrDefault(cache.ToShowId, "…");
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
string? posterPath = null;
if (variant.Kind == Domain.Broadcast.BumperTextKind.NowNext)
posterPath = await ResolveShowPosterAsync(db, cache.ToShowId, cancellationToken);
var bgPath = await ResolveTemplateBackgroundAsync(
db,
template.BackgroundImageId,
cancellationToken
);
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
);
var audioPath = bumperStorage.AudioPath(template.Id, template.AudioExtension);
return BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
fromName,
toName,
audioPath,
posterPath,
bgPath
);
}
private async Task<string?> ResolveShowPosterAsync(
IAppDbContext db,
Guid showId,
CancellationToken cancellationToken
)
{
var posterImageId = await db
.Shows.AsNoTracking()
.Where(s => s.Id == showId && s.PosterImageId != null)
.Select(s => s.PosterImageId!.Value)
.FirstOrDefaultAsync(cancellationToken);
if (posterImageId == Guid.Empty)
return null;
return await ResolveImagePathAsync(db, posterImageId, cancellationToken);
}
private async Task<string?> ResolveTemplateBackgroundAsync(
IAppDbContext db,
Guid? backgroundImageId,
CancellationToken cancellationToken
)
{
if (backgroundImageId is not { } bgId)
return null;
return await ResolveImagePathAsync(db, bgId, cancellationToken);
}
private async Task<string?> ResolveImagePathAsync(
IAppDbContext db,
Guid imageId,
CancellationToken cancellationToken
)
{
var ext = await db
.Images.AsNoTracking()
.Where(i => i.Id == imageId)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return ext is null ? null : imageStore.ResolvePath(imageId, ext);
}
private async Task FailAsync(Guid assetId, string error, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db.MediaAssets.FirstOrDefaultAsync(
a => a.Id == assetId,
cancellationToken
);
if (asset is null)
return;
asset.MarkFailed(error);
await db.SaveChangesAsync(cancellationToken);
}
} }
@@ -0,0 +1,231 @@
using System.Collections.Concurrent;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Каркас воркера, разбирающего очередь ассетов из БД. Источник истины — статус: единственный
/// диспетчер последовательно и атомарно захватывает следующий Pending (помечает Processing), поэтому
/// два воркера никогда не возьмут один ассет; сама работа идёт в фоне с ограничением по числу слотов.
/// Рестарт/краш ничего не теряет — прерванные Processing сбрасываются в Pending на старте.
/// БД-контекст держится короткими отрезками (пометить статус), сама работа идёт вне scope, чтобы не
/// держать соединение минутами.
///
/// Пространство ассетов делится по <see cref="HandlesGenerated"/>: ТВ-заставки (Source=Generated)
/// рендерит один воркер, всё остальное транскодирует другой, и пересечься они не могут.
/// </summary>
internal abstract class MediaClaimingBackgroundService<TJob>(
IServiceScopeFactory scopeFactory,
ILogger logger
) : BackgroundService
where TJob : class
{
// Периодически перепроверяем БД, даже если сигнал не пришёл — страховка на любой случай.
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
/// <summary>true — воркер обслуживает только сгенерированные ассеты, false — только остальные.</summary>
protected abstract bool HandlesGenerated { get; }
/// <summary>Сколько работ выполняется одновременно. 1 — строго последовательно.</summary>
protected virtual int MaxParallel => 1;
/// <summary>Что писать в лог при сбое цикла (не самой работы).</summary>
protected abstract string LoopErrorMessage { get; }
/// <summary>Ожидание сигнала о новой работе — у каждого воркера своя очередь-будильник.</summary>
protected abstract ValueTask WaitForWorkAsync(CancellationToken cancellationToken);
/// <summary>Что из захваченного ассета нужно воркеру: работа идёт уже без БД-контекста.</summary>
protected abstract TJob ToJob(MediaAsset asset);
/// <summary>Сама работа над захваченным (уже Processing) ассетом.</summary>
protected abstract Task ProcessAsync(TJob job, CancellationToken cancellationToken);
/// <summary>Разовая подготовка перед первым тиком (например, создание каталогов).</summary>
protected virtual void OnStarting() { }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
OnStarting();
await ResetInterruptedAsync(stoppingToken);
// Слоты параллелизма: не запускаем больше MaxParallel работ одновременно.
using var slots = new SemaphoreSlim(MaxParallel, MaxParallel);
var inFlight = new ConcurrentDictionary<Task, byte>();
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Захватываем и раздаём по слотам всю накопившуюся работу из БД.
while (!stoppingToken.IsCancellationRequested)
{
await slots.WaitAsync(stoppingToken);
// Слот уже захвачен — любой сбой захвата ассета (транзиентная ошибка БД и т.п.)
// обязан вернуть слот, иначе после нескольких ошибок семафор исчерпается и
// диспетчер зависнет навсегда (сервис формально жив, но ничего не обрабатывает).
TJob? claim;
try
{
claim = await ClaimNextAsync(stoppingToken);
}
catch
{
slots.Release();
throw;
}
if (claim is not { } job)
{
slots.Release();
break;
}
var task = Task.Run(
async () =>
{
try
{
await ProcessAsync(job, stoppingToken);
}
finally
{
slots.Release();
}
},
CancellationToken.None
);
inFlight[task] = 0;
_ = task.ContinueWith(t => inFlight.TryRemove(t, out _), TaskScheduler.Default);
}
// Работы нет — ждём сигнала о новой либо периодического опроса.
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
wake.CancelAfter(IdlePoll);
try
{
await WaitForWorkAsync(wake.Token);
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
// Тайм-аут опроса — просто перепроверяем БД.
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "{Message}", LoopErrorMessage);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
// Даём уже запущенным работам корректно завершиться (или отмениться) на остановке.
try
{
await Task.WhenAll(inFlight.Keys.ToArray());
}
catch
{
// Ошибки/отмена отдельных задач уже залогированы внутри ProcessAsync.
}
}
/// <summary>Помечает ассет провалившимся. Вызывается воркером из его обработчика ошибок.</summary>
protected async Task FailAsync(
Guid assetId,
string error,
CancellationToken cancellationToken
) =>
await WithAssetAsync(
assetId,
asset => asset.MarkFailed(error),
cancellationToken
);
/// <summary>Находит ассет в свежем scope, применяет к нему изменение и сохраняет.</summary>
protected async Task WithAssetAsync(
Guid assetId,
Action<MediaAsset> change,
CancellationToken cancellationToken
)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db.MediaAssets.FirstOrDefaultAsync(
x => x.Id == assetId,
cancellationToken
);
if (asset is null)
return;
change(asset);
await db.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Выполняет что-то на scoped-сервисе в отдельном scope — для сборки данных под работу.
/// Scope живёт только на время вызова: соединение с БД не удерживается на весь рендер/транскод.
/// </summary>
protected async Task<T> WithScopeAsync<TService, T>(Func<TService, Task<T>> use)
where TService : notnull
{
await using var scope = scopeFactory.CreateAsyncScope();
return await use(scope.ServiceProvider.GetRequiredService<TService>());
}
/// <summary>Сброс прерванных рестартом задач (Processing → Pending) на старте.</summary>
private async Task ResetInterruptedAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var interrupted = await db
.MediaAssets.Where(Owned(MediaAssetStatus.Processing))
.ToListAsync(cancellationToken);
if (interrupted.Count == 0)
return;
foreach (var asset in interrupted)
asset.ResetToPending();
await db.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Атомарно захватывает самый ранний Pending этого воркера: помечает его Processing и возвращает
/// job, либо null если работы нет. Вызывается только диспетчером последовательно, поэтому две
/// работы не возьмут один ассет.
/// </summary>
private async Task<TJob?> ClaimNextAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db
.MediaAssets.Where(Owned(MediaAssetStatus.Pending))
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (asset is null)
return default;
asset.MarkProcessing();
await db.SaveChangesAsync(cancellationToken);
return ToJob(asset);
}
/// <summary>Ассеты этого воркера в заданном статусе — предикат переводится в SQL.</summary>
private Expression<Func<MediaAsset, bool>> Owned(MediaAssetStatus status) =>
HandlesGenerated
? x => x.Status == status && x.Source == MediaSource.Generated
: x => x.Status == status && x.Source != MediaSource.Generated;
}
@@ -1,7 +1,4 @@
using System.Collections.Concurrent;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
@@ -9,16 +6,15 @@ using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media; namespace TeleWave.Infrastructure.Media;
/// <summary>Захваченный на транскод ассет: расширение нужно ffmpeg и уже не требует БД.</summary>
internal sealed record MediaTranscodeJob(Guid AssetId, string Extension);
/// <summary> /// <summary>
/// Обработчик медиа: прогоняет ассеты через ffmpeg, до <see cref="MediaOptions.MaxParallelTranscodes"/> /// Обработчик медиа: прогоняет ассеты через ffmpeg, до <see cref="MediaOptions.MaxParallelTranscodes"/>
/// файлов одновременно. Источник истины — статус в БД: единственный диспетчер последовательно и /// файлов одновременно. Захват работы, устойчивость к рестарту и параллелизм — в
/// атомарно захватывает следующий Pending (помечает Processing), поэтому два транскода никогда не /// <see cref="MediaClaimingBackgroundService{TJob}"/>; здесь только сам транскод.
/// возьмут один ассет; сам транскод запускается в фоне с ограничением по числу слотов. Рестарт/краш
/// ничего не теряет — незавершённые подхватываются из БД (прерванные Processing на старте сбрасываются
/// в Pending). БД-контекст держится короткими отрезками (пометить статус), сам транскод идёт вне
/// scope, чтобы не держать соединение минутами.
/// </summary> /// </summary>
public sealed class MediaProcessingBackgroundService( internal sealed class MediaProcessingBackgroundService(
IMediaProcessingQueue queue, IMediaProcessingQueue queue,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
MediaPathResolver paths, MediaPathResolver paths,
@@ -26,167 +22,55 @@ public sealed class MediaProcessingBackgroundService(
IOptions<StorageOptions> storageOptions, IOptions<StorageOptions> storageOptions,
IOptions<MediaOptions> mediaOptions, IOptions<MediaOptions> mediaOptions,
ILogger<MediaProcessingBackgroundService> logger ILogger<MediaProcessingBackgroundService> logger
) : BackgroundService ) : MediaClaimingBackgroundService<MediaTranscodeJob>(scopeFactory, logger)
{ {
// Периодически перепроверяем БД, даже если сигнал не пришёл — страховка на любой случай.
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
private readonly StorageOptions _storage = storageOptions.Value; private readonly StorageOptions _storage = storageOptions.Value;
private readonly int _maxParallel = Math.Max(1, mediaOptions.Value.MaxParallelTranscodes);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
paths.EnsureDirectories();
await ResetInterruptedAsync(stoppingToken);
// Слоты параллелизма: не запускаем больше _maxParallel транскодов одновременно.
using var slots = new SemaphoreSlim(_maxParallel, _maxParallel);
var inFlight = new ConcurrentDictionary<Task, byte>();
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Захватываем и раздаём по слотам всю накопившуюся работу из БД.
while (!stoppingToken.IsCancellationRequested)
{
await slots.WaitAsync(stoppingToken);
// Слот уже захвачен — любой сбой захвата ассета (транзиентная ошибка БД и т.п.)
// обязан вернуть слот, иначе после нескольких ошибок семафор исчерпается и
// диспетчер зависнет навсегда (сервис формально жив, но ничего не обрабатывает).
(Guid Id, string Extension)? claim;
try
{
claim = await ClaimNextAsync(stoppingToken);
}
catch
{
slots.Release();
throw;
}
if (claim is not { } job)
{
slots.Release();
break;
}
var task = Task.Run(
async () =>
{
try
{
await ProcessClaimedAsync(job.Id, job.Extension, stoppingToken);
}
finally
{
slots.Release();
}
},
CancellationToken.None
);
inFlight[task] = 0;
_ = task.ContinueWith(t => inFlight.TryRemove(t, out _), TaskScheduler.Default);
}
// Работы нет — ждём сигнала о новой либо периодического опроса.
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
wake.CancelAfter(IdlePoll);
try
{
await queue.WaitAsync(wake.Token);
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
// Тайм-аут опроса — просто перепроверяем БД.
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка цикла обработки медиа");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
// Даём уже запущенным транскодам корректно завершиться (или отмениться) на остановке.
try
{
await Task.WhenAll(inFlight.Keys.ToArray());
}
catch
{
// Ошибки/отмена отдельных задач уже залогированы внутри ProcessClaimedAsync.
}
}
/// <summary>Сброс прерванных рестартом задач (Processing → Pending) на старте.</summary>
private async Task ResetInterruptedAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var interrupted = await db
.MediaAssets.Where(x =>
x.Status == MediaAssetStatus.Processing && x.Source != MediaSource.Generated
)
.ToListAsync(cancellationToken);
if (interrupted.Count == 0)
return;
foreach (var asset in interrupted)
asset.ResetToPending();
await db.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Атомарно захватывает самый ранний Pending: помечает его Processing и возвращает (id, расширение),
/// либо null если работы нет. Вызывается только диспетчером последовательно, поэтому два транскода
/// не возьмут один ассет.
/// </summary>
private async Task<(Guid Id, string Extension)?> ClaimNextAsync(
CancellationToken cancellationToken
)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
// Generated-ассеты (ТВ-заставки) обслуживает BumperRenderBackgroundService — их не берём. // Generated-ассеты (ТВ-заставки) обслуживает BumperRenderBackgroundService — их не берём.
var asset = await db protected override bool HandlesGenerated => false;
.MediaAssets.Where(x =>
x.Status == MediaAssetStatus.Pending && x.Source != MediaSource.Generated
)
.OrderBy(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (asset is null)
return null;
asset.MarkProcessing(); protected override int MaxParallel => Math.Max(1, mediaOptions.Value.MaxParallelTranscodes);
await db.SaveChangesAsync(cancellationToken);
return (asset.Id, asset.OriginalExtension);
}
/// <summary>Обрабатывает уже захваченный (Processing) ассет: транскод → Ready/Failed.</summary> protected override string LoopErrorMessage => "Ошибка цикла обработки медиа";
private async Task ProcessClaimedAsync(
Guid assetId, protected override void OnStarting() => paths.EnsureDirectories();
string extension,
protected override ValueTask WaitForWorkAsync(CancellationToken cancellationToken) =>
queue.WaitAsync(cancellationToken);
protected override MediaTranscodeJob ToJob(MediaAsset asset) =>
new(asset.Id, asset.OriginalExtension);
protected override async Task ProcessAsync(
MediaTranscodeJob job,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
try try
{ {
var result = await processor.ProcessAsync(assetId, extension, cancellationToken); var result = await processor.ProcessAsync(job.AssetId, job.Extension, cancellationToken);
await CompleteAsync(assetId, result, cancellationToken); await WithAssetAsync(
job.AssetId,
asset =>
asset.MarkReady(
result.Duration,
result.SegmentSeconds,
result.SegmentCount,
result.Width,
result.Height,
result.VideoCodec,
result.AudioCodec,
result.RelativePath
),
cancellationToken
);
if (!_storage.KeepOriginals) if (!_storage.KeepOriginals)
DeleteOriginal(assetId, extension); DeleteOriginal(job.AssetId, job.Extension);
logger.LogInformation( logger.LogInformation(
"Ассет {AssetId} обработан: {Segments} сегментов, {Seconds:0.#}с", "Ассет {AssetId} обработан: {Segments} сегментов, {Seconds:0.#}с",
assetId, job.AssetId,
result.SegmentCount, result.SegmentCount,
result.Duration.TotalSeconds result.Duration.TotalSeconds
); );
@@ -197,56 +81,11 @@ public sealed class MediaProcessingBackgroundService(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Обработка ассета {AssetId} провалилась", assetId); logger.LogError(ex, "Обработка ассета {AssetId} провалилась", job.AssetId);
await FailAsync(assetId, ex.Message, CancellationToken.None); await FailAsync(job.AssetId, ex.Message, CancellationToken.None);
} }
} }
private async Task CompleteAsync(
Guid assetId,
MediaProcessingResult result,
CancellationToken cancellationToken
)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db.MediaAssets.FirstOrDefaultAsync(
x => x.Id == assetId,
cancellationToken
);
if (asset is null)
return;
asset.MarkReady(
result.Duration,
result.SegmentSeconds,
result.SegmentCount,
result.Width,
result.Height,
result.VideoCodec,
result.AudioCodec,
result.RelativePath
);
await db.SaveChangesAsync(cancellationToken);
}
private async Task FailAsync(Guid assetId, string error, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var asset = await db.MediaAssets.FirstOrDefaultAsync(
x => x.Id == assetId,
cancellationToken
);
if (asset is null)
return;
asset.MarkFailed(error);
await db.SaveChangesAsync(cancellationToken);
}
private void DeleteOriginal(Guid assetId, string extension) private void DeleteOriginal(Guid assetId, string extension)
{ {
var original = paths.OriginalPath(assetId, extension); var original = paths.OriginalPath(assetId, extension);
@@ -1,46 +1,31 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { ChevronLeft, Plus, Send } from 'lucide-react' import { ChevronLeft, Send } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { listAllMedia } from '@/features/admin/media/api' import { listAllMedia } from '@/features/admin/media/api'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import type { GridLayerDto, SlotDto } from '@/shared/api/types' import { cn } from '@/shared/lib/cn'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card' import { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { cn } from '@/shared/lib/cn'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { import {
applyChannelTemplate, applyChannelTemplate,
copyTemplateTo,
createChannelTemplate,
createLayer,
createSlot,
deleteLayer,
getChannel, getChannel,
getChannelTemplate, getChannelTemplate,
getSchedule, getSchedule,
listChannels,
toSlotBody,
updateLayer,
updateSlot,
} from './api' } from './api'
import { ApplyDialog } from './components/ApplyDialog' import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard' import { BumperCard } from './components/BumperCard'
import { EntryTraceDialog } from './components/EntryTraceDialog' import { EntryTraceDialog } from './components/EntryTraceDialog'
import { GridTab } from './components/GridTab'
import { JunctionsCard } from './components/JunctionsCard' import { JunctionsCard } from './components/JunctionsCard'
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
import { SchedulePreview } from './components/SchedulePreview'
import { RulesCard } from './components/RulesCard' import { RulesCard } from './components/RulesCard'
import { SchedulePreview } from './components/SchedulePreview'
import { SettingsCard } from './components/SettingsCard' import { SettingsCard } from './components/SettingsCard'
import { TemplateIssues } from './components/TemplateIssues'
import { TemplatePreview } from './components/TemplatePreview'
import { ViewerCard } from './components/ViewerCard' import { ViewerCard } from './components/ViewerCard'
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format'
/** Вкладки экрана канала: настройки первыми — с них канал и начинается. */ /** Вкладки экрана канала: настройки первыми — с них канал и начинается. */
const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const
@@ -49,42 +34,31 @@ type ChannelTab = (typeof TABS)[number]
export function ChannelDetail({ channelId }: { channelId: string }) { export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [draft, setDraft] = useState<SlotDraft | null>(null)
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
const [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
const [applyOpen, setApplyOpen] = useState(false) const [applyOpen, setApplyOpen] = useState(false)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null) const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [copyFromChannel, setCopyFromChannel] = useState('')
const [tab, setTab] = useState<ChannelTab>('settings') const [tab, setTab] = useState<ChannelTab>('settings')
const { data: channel, isLoading } = useQuery({ const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId], queryKey: qk.channels.detail(channelId),
queryFn: () => getChannel(channelId), queryFn: () => getChannel(channelId),
}) })
const { data: template, error: templateError } = useQuery({ const { data: template, error: templateError } = useQuery({
queryKey: ['admin', 'channels', channelId, 'template'], queryKey: qk.channels.template(channelId),
queryFn: () => getChannelTemplate(channelId), queryFn: () => getChannelTemplate(channelId),
}) })
const { data: ready } = useQuery({ const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready', 'all'], queryKey: qk.media.ready,
queryFn: () => listAllMedia({ statuses: ['Ready'] }), queryFn: () => listAllMedia({ statuses: ['Ready'] }),
}) })
const { data: schedule } = useQuery({ const { data: schedule } = useQuery({
queryKey: ['admin', 'channels', channelId, 'schedule'], queryKey: qk.channels.schedule(channelId),
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)), queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)),
}) })
const invalidate = () => { const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] }) void queryClient.invalidateQueries({ queryKey: qk.channels.detail(channelId) })
} }
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
const applyMutation = useMutation({ const applyMutation = useMutation({
mutationFn: () => applyChannelTemplate(channelId), mutationFn: () => applyChannelTemplate(channelId),
@@ -99,142 +73,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onError, onError,
}) })
const addLayerMutation = useMutation({
mutationFn: () => {
const nextPriority = Math.max(0, ...(template?.layers.map((l) => l.priority) ?? [0])) + 10
return createLayer(template!.id, {
name: t('admin.channels.newLayerName'),
priority: nextPriority,
})
},
onSuccess: invalidate,
onError,
})
const deleteLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) => deleteLayer(layer.id),
onSuccess: invalidate,
onError,
})
const toggleLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) =>
updateLayer(layer.id, {
name: layer.name,
priority: layer.priority,
applicability: layer.applicability,
isEnabled: !layer.isEnabled,
}),
onSuccess: invalidate,
onError,
})
/**
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
*/
const reorderLayersMutation = useMutation({
mutationFn: async (layerIdsTopFirst: string[]) => {
const byId = new Map(template!.layers.map((l) => [l.id, l]))
const total = layerIdsTopFirst.length
await Promise.all(
layerIdsTopFirst.map((id, index) => {
const layer = byId.get(id)
const priority = (total - index) * 10
if (!layer || layer.priority === priority) return Promise.resolve()
return updateLayer(id, {
name: layer.name,
priority,
applicability: layer.applicability,
isEnabled: layer.isEnabled,
})
}),
)
},
onSuccess: invalidate,
onError,
})
// Канал без сетки — наследство старой ротации: заводим шаблон на месте, а не пересоздаём канал.
const createTemplateMutation = useMutation({
mutationFn: () => createChannelTemplate(channelId),
onSuccess: invalidate,
onError,
})
const copyTemplateMutation = useMutation({
mutationFn: (sourceChannelId: string) => copyTemplateTo(sourceChannelId, channelId),
onSuccess: (result) => {
setCopyFromChannel('')
toast.success(
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
)
if (result.droppedBumperRefs > 0)
toast.error(
t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }),
)
},
onError,
})
const moveSlotMutation = useMutation({
mutationFn: ({
slot,
weekday,
startMinutes,
}: {
slot: SlotDto
weekday: number
startMinutes: number
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
onSuccess: invalidate,
onError,
})
const resizeSlotMutation = useMutation({
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
onSuccess: invalidate,
onError,
})
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
const copyDayMutation = useMutation({
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
const sources = (template?.layers ?? []).flatMap((layer) =>
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
)
for (const weekday of to)
for (const { layer, slot } of sources)
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
},
onSuccess: () => {
setCopySource(null)
invalidate()
},
onError,
})
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p> if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
const layerForNewSlot =
activeLayerId ?? template?.layers.find((l) => !l.isBackground)?.id ?? template?.layers[0]?.id
const openNewSlot = (weekday: number, startMinutes: number) => {
if (!layerForNewSlot) return
const hh = Math.floor(startMinutes / 60)
.toString()
.padStart(2, '0')
const mm = (startMinutes % 60).toString().padStart(2, '0')
setDraft({
layerId: layerForNewSlot,
slot: null,
defaults: { weekday, targetStart: `${hh}:${mm}:00`, title: t('admin.channels.newSlot') },
})
}
const openSlot = (slot: SlotDto) => setDraft({ layerId: slot.layerId, slot })
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -289,187 +129,15 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
/> />
)} )}
{/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */} {tab === 'grid' && (
{tab === 'grid' && !template && ( <GridTab
<div className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">
{templateError instanceof HttpError
? templateError.detail
: t('admin.channels.noTemplate')}
</p>
<Button
size="sm"
disabled={createTemplateMutation.isPending}
onClick={() => createTemplateMutation.mutate()}
>
<Plus className="h-4 w-4" /> {t('admin.channels.createTemplate')}
</Button>
<p className="text-xs text-muted-foreground">{t('admin.channels.createTemplateHint')}</p>
</div>
)}
{tab === 'grid' && template && (
<Card>
<CardContent>
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.layers')}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => addLayerMutation.mutate()}
disabled={addLayerMutation.isPending}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<LayerList
template={template}
activeLayerId={layerForNewSlot ?? null}
viewDate={viewDate || null}
onSelect={(layer) => setActiveLayerId(layer.id)}
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
onReorder={(order) => reorderLayersMutation.mutate(order)}
onEditApplicability={setApplicabilityLayer}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
{/* Копия сетки на другой канал: группы общие, поэтому переносятся только правила. */}
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.copyTemplate')}
</span>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={copyFromChannel}
onChange={(e) => setCopyFromChannel(e.target.value)}
>
<option value="">{t('admin.channels.pickSourceChannel')}</option>
{(channels ?? [])
.filter((c) => c.id !== channelId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
disabled={!copyFromChannel || copyTemplateMutation.isPending}
onClick={() => {
// Замена своей сетки — необратимая правка, поэтому спрашиваем перед ней.
if (!window.confirm(t('admin.channels.copyTemplateConfirm'))) return
copyTemplateMutation.mutate(copyFromChannel)
}}
>
{t('admin.channels.copyHere')}
</Button>
<p className="text-xs text-muted-foreground">
{t('admin.channels.copyTemplateHint')}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<TemplateIssues
channelId={channelId} channelId={channelId}
slotsById={
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
}
onGoToSlot={openSlot}
/>
<TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input
type="date"
className="h-8 w-40"
value={viewDate}
onChange={(e) => setViewDate(e.target.value)}
/>
{viewDate && (
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
{t('admin.channels.allDates')}
</Button>
)}
</div>
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
{copySource !== null && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<span>
{t('admin.channels.copyDayFrom', {
day: t(`admin.channels.weekdays.${copySource}`),
})}
</span>
{[1, 2, 3, 4, 5, 6, 0]
.filter((day) => day !== copySource)
.map((day) => (
<label key={day} className="flex items-center gap-1">
<input
type="checkbox"
checked={copyTargets.includes(day)}
onChange={(e) =>
setCopyTargets((current) =>
e.target.checked
? [...current, day]
: current.filter((d) => d !== day),
)
}
/>
{t(`admin.channels.weekdays.${day}`)}
</label>
))}
<Button
size="sm"
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
onClick={() =>
copyDayMutation.mutate({ from: copySource, to: copyTargets })
}
>
{t('admin.channels.copy')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
{t('common.cancel')}
</Button>
</div>
)}
<ScheduleGrid
template={template} template={template}
selectedSlotId={draft?.slot?.id ?? null} templateError={templateError}
viewDate={viewDate || null}
onSelectSlot={openSlot}
onAddSlot={openNewSlot}
onMoveSlot={(slot, weekday, startMinutes) =>
moveSlotMutation.mutate({ slot, weekday, startMinutes })
}
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
onCopyDay={(weekday) => {
setCopySource(weekday)
setCopyTargets([])
}}
/>
{draft && (
<SlotInspector
channelId={channelId}
draft={draft}
onClose={() => setDraft(null)}
onChanged={invalidate} onChanged={invalidate}
onError={onError}
/> />
)} )}
</div>
</div>
</CardContent>
</Card>
)}
{tab === 'rules' && {tab === 'rules' &&
(template ? ( (template ? (
@@ -504,15 +172,6 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</Card> </Card>
)} )}
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
onClose={() => setApplicabilityLayer(null)}
onChanged={invalidate}
onError={onError}
/>
)}
{applyOpen && ( {applyOpen && (
<ApplyDialog <ApplyDialog
channelId={channelId} channelId={channelId}
@@ -2,11 +2,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { createChannel, listChannels } from './api' import { createChannel, listChannels } from './api'
function slugify(value: string) { function slugify(value: string) {
@@ -22,10 +22,9 @@ export function ChannelsPanel() {
const [name, setName] = useState('') const [name, setName] = useState('')
const [slug, setSlug] = useState('') const [slug, setSlug] = useState('')
const { data, isLoading } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels }) const { data, isLoading } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'channels'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.channels.all })
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => createChannel({ name: name.trim(), slug: slug || slugify(name) }), mutationFn: () => createChannel({ name: name.trim(), slug: slug || slugify(name) }),
+3 -3
View File
@@ -36,7 +36,7 @@ export function createChannel(body: { name: string; slug: string }) {
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body }) return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
} }
export type ChannelSettingsBody = { type ChannelSettingsBody = {
name: string name: string
isEnabled: boolean isEnabled: boolean
bumpersEnabled: boolean bumpersEnabled: boolean
@@ -230,7 +230,7 @@ export function reorderJunction(junctionId: string, elementIdsInOrder: string[])
}) })
} }
export type BumperTemplateStyleBody = { type BumperTemplateStyleBody = {
name: string name: string
backgroundColor: string backgroundColor: string
backgroundColor2: string backgroundColor2: string
@@ -297,7 +297,7 @@ export function uploadBumperTemplateAudio(id: string, templateId: string, file:
return uploadBumperTemplateFile(id, templateId, 'audio', file) return uploadBumperTemplateFile(id, templateId, 'audio', file)
} }
export type BumperVariantBody = { type BumperVariantBody = {
name: string name: string
kind: BumperTextKind kind: BumperTextKind
nowLabel: string nowLabel: string
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { import {
Dialog, Dialog,
@@ -33,7 +34,7 @@ export function ApplyDialog({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { data, isFetching } = useQuery({ const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'diff'], queryKey: qk.channels.diff(channelId),
queryFn: () => getApplyDiff(channelId), queryFn: () => getApplyDiff(channelId),
staleTime: 0, staleTime: 0,
gcTime: 0, gcTime: 0,
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { qk } from '@/shared/api/query-keys'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
Dialog, Dialog,
@@ -24,7 +25,7 @@ export function EntryTraceDialog({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['admin', 'entries', entryId, 'trace'], queryKey: qk.entries.trace(entryId),
queryFn: () => getEntryTrace(entryId), queryFn: () => getEntryTrace(entryId),
}) })
@@ -0,0 +1,383 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import {
copyTemplateTo,
createChannelTemplate,
createLayer,
createSlot,
deleteLayer,
listChannels,
toSlotBody,
updateLayer,
updateSlot,
} from '../api'
import { toTime } from '../lib/format'
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './ScheduleGrid'
import { SlotInspector, type SlotDraft } from './SlotInspector'
import { TemplateIssues } from './TemplateIssues'
import { TemplatePreview } from './TemplatePreview'
/**
* Вкладка «Сетка»: слои, слоты и всё, что их правит. Вынесена из экрана канала целиком со своим
* состоянием — остальным вкладкам ни черновик слота, ни выбранный день копирования не нужны, а
* держать их в родителе значило перерисовывать весь экран на каждое движение мыши по сетке.
*/
export function GridTab({
channelId,
template,
templateError,
onChanged,
onError,
}: {
channelId: string
template: ScheduleTemplateDto | undefined
templateError: unknown
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [draft, setDraft] = useState<SlotDraft | null>(null)
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
const [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
const [copyFromChannel, setCopyFromChannel] = useState('')
const { data: channels } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
const addLayerMutation = useMutation({
mutationFn: () => {
const nextPriority = Math.max(0, ...(template?.layers.map((l) => l.priority) ?? [0])) + 10
return createLayer(template!.id, {
name: t('admin.channels.newLayerName'),
priority: nextPriority,
})
},
onSuccess: onChanged,
onError,
})
const deleteLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) => deleteLayer(layer.id),
onSuccess: onChanged,
onError,
})
const toggleLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) =>
updateLayer(layer.id, {
name: layer.name,
priority: layer.priority,
applicability: layer.applicability,
isEnabled: !layer.isEnabled,
}),
onSuccess: onChanged,
onError,
})
/**
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
*/
const reorderLayersMutation = useMutation({
mutationFn: async (layerIdsTopFirst: string[]) => {
const byId = new Map(template!.layers.map((l) => [l.id, l]))
const total = layerIdsTopFirst.length
await Promise.all(
layerIdsTopFirst.map((id, index) => {
const layer = byId.get(id)
const priority = (total - index) * 10
if (!layer || layer.priority === priority) return Promise.resolve()
return updateLayer(id, {
name: layer.name,
priority,
applicability: layer.applicability,
isEnabled: layer.isEnabled,
})
}),
)
},
onSuccess: onChanged,
onError,
})
// Канал без сетки — наследство старой ротации: заводим шаблон на месте, а не пересоздаём канал.
const createTemplateMutation = useMutation({
mutationFn: () => createChannelTemplate(channelId),
onSuccess: onChanged,
onError,
})
const copyTemplateMutation = useMutation({
mutationFn: (sourceChannelId: string) => copyTemplateTo(sourceChannelId, channelId),
onSuccess: (result) => {
setCopyFromChannel('')
toast.success(
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
)
if (result.droppedBumperRefs > 0)
toast.error(t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }))
},
onError,
})
const moveSlotMutation = useMutation({
mutationFn: ({
slot,
weekday,
startMinutes,
}: {
slot: SlotDto
weekday: number
startMinutes: number
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
onSuccess: onChanged,
onError,
})
const resizeSlotMutation = useMutation({
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
onSuccess: onChanged,
onError,
})
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
const copyDayMutation = useMutation({
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
const sources = (template?.layers ?? []).flatMap((layer) =>
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
)
for (const weekday of to)
for (const { layer, slot } of sources)
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
},
onSuccess: () => {
setCopySource(null)
onChanged()
},
onError,
})
// Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой.
if (!template)
return (
<div className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">
{templateError instanceof HttpError
? templateError.detail
: t('admin.channels.noTemplate')}
</p>
<Button
size="sm"
disabled={createTemplateMutation.isPending}
onClick={() => createTemplateMutation.mutate()}
>
<Plus className="h-4 w-4" /> {t('admin.channels.createTemplate')}
</Button>
<p className="text-xs text-muted-foreground">{t('admin.channels.createTemplateHint')}</p>
</div>
)
const layerForNewSlot =
activeLayerId ?? template.layers.find((l) => !l.isBackground)?.id ?? template.layers[0]?.id
const openNewSlot = (weekday: number, startMinutes: number) => {
if (!layerForNewSlot) return
const hh = Math.floor(startMinutes / 60)
.toString()
.padStart(2, '0')
const mm = (startMinutes % 60).toString().padStart(2, '0')
setDraft({
layerId: layerForNewSlot,
slot: null,
defaults: { weekday, targetStart: `${hh}:${mm}:00`, title: t('admin.channels.newSlot') },
})
}
const openSlot = (slot: SlotDto) => setDraft({ layerId: slot.layerId, slot })
return (
<>
<Card>
<CardContent>
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.layers')}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => addLayerMutation.mutate()}
disabled={addLayerMutation.isPending}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<LayerList
template={template}
activeLayerId={layerForNewSlot ?? null}
viewDate={viewDate || null}
onSelect={(layer) => setActiveLayerId(layer.id)}
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
onReorder={(order) => reorderLayersMutation.mutate(order)}
onEditApplicability={setApplicabilityLayer}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
{/* Копия сетки с другого канала: группы общие, поэтому переносятся только правила. */}
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.copyTemplate')}
</span>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={copyFromChannel}
onChange={(e) => setCopyFromChannel(e.target.value)}
>
<option value="">{t('admin.channels.pickSourceChannel')}</option>
{(channels ?? [])
.filter((c) => c.id !== channelId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
disabled={!copyFromChannel || copyTemplateMutation.isPending}
onClick={() => {
// Замена своей сетки — необратимая правка, поэтому спрашиваем перед ней.
if (!window.confirm(t('admin.channels.copyTemplateConfirm'))) return
copyTemplateMutation.mutate(copyFromChannel)
}}
>
{t('admin.channels.copyHere')}
</Button>
<p className="text-xs text-muted-foreground">
{t('admin.channels.copyTemplateHint')}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<TemplateIssues
channelId={channelId}
slotsById={
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
}
onGoToSlot={openSlot}
/>
<TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input
type="date"
className="h-8 w-40"
value={viewDate}
onChange={(e) => setViewDate(e.target.value)}
/>
{viewDate && (
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
{t('admin.channels.allDates')}
</Button>
)}
</div>
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
{copySource !== null && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<span>
{t('admin.channels.copyDayFrom', {
day: t(`admin.channels.weekdays.${copySource}`),
})}
</span>
{[1, 2, 3, 4, 5, 6, 0]
.filter((day) => day !== copySource)
.map((day) => (
<label key={day} className="flex items-center gap-1">
<input
type="checkbox"
checked={copyTargets.includes(day)}
onChange={(e) =>
setCopyTargets((current) =>
e.target.checked
? [...current, day]
: current.filter((d) => d !== day),
)
}
/>
{t(`admin.channels.weekdays.${day}`)}
</label>
))}
<Button
size="sm"
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
onClick={() => copyDayMutation.mutate({ from: copySource, to: copyTargets })}
>
{t('admin.channels.copy')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
{t('common.cancel')}
</Button>
</div>
)}
<ScheduleGrid
template={template}
selectedSlotId={draft?.slot?.id ?? null}
viewDate={viewDate || null}
onSelectSlot={openSlot}
onAddSlot={openNewSlot}
onMoveSlot={(slot, weekday, startMinutes) =>
moveSlotMutation.mutate({ slot, weekday, startMinutes })
}
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
onCopyDay={(weekday) => {
setCopySource(weekday)
setCopyTargets([])
}}
/>
{draft && (
<SlotInspector
channelId={channelId}
draft={draft}
onClose={() => setDraft(null)}
onChanged={onChanged}
/>
)}
</div>
</div>
</CardContent>
</Card>
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
onClose={() => setApplicabilityLayer(null)}
onChanged={onChanged}
onError={onError}
/>
)}
</>
)
}
@@ -8,6 +8,7 @@ import type {
JunctionElementDto, JunctionElementDto,
JunctionElementKind, JunctionElementKind,
} from '@/shared/api/types' } from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { import {
Dialog, Dialog,
@@ -53,7 +54,7 @@ export function JunctionElementDialog({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [body, setBody] = useState<JunctionElementBody>(() => toBody(element)) const [body, setBody] = useState<JunctionElementBody>(() => toBody(element))
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups }) const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const patch = (part: Partial<JunctionElementBody>) => setBody((prev) => ({ ...prev, ...part })) const patch = (part: Partial<JunctionElementBody>) => setBody((prev) => ({ ...prev, ...part }))
@@ -12,6 +12,7 @@ import type {
JunctionTemplateDto, JunctionTemplateDto,
ScheduleTemplateDto, ScheduleTemplateDto,
} from '@/shared/api/types' } from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label' import { Label } from '@/shared/ui/label'
@@ -79,10 +80,10 @@ export function JunctionsCard({
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const { data: junctions } = useQuery({ const { data: junctions } = useQuery({
queryKey: ['admin', 'channels', channel.id, 'junctions'], queryKey: qk.channels.junctions(channel.id),
queryFn: () => listJunctions(channel.id), queryFn: () => listJunctions(channel.id),
}) })
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups }) const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => createJunction(channel.id, newName.trim()), mutationFn: () => createJunction(channel.id, newName.trim()),
@@ -3,7 +3,6 @@ import { Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { listGroups } from '@/features/admin/groups/api' import { listGroups } from '@/features/admin/groups/api'
import { HttpError } from '@/shared/api/client'
import type { import type {
Daypart, Daypart,
OverflowPolicy, OverflowPolicy,
@@ -12,10 +11,11 @@ import type {
SlotKind, SlotKind,
SlotStrategyType, SlotStrategyType,
} from '@/shared/api/types' } from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label' import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { import {
createSlot, createSlot,
deleteSlot, deleteSlot,
@@ -83,14 +83,13 @@ export function SlotInspector({
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults)) setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
}, [draft]) }, [draft])
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups }) const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const { data: junctions } = useQuery({ const { data: junctions } = useQuery({
queryKey: ['admin', 'channels', channelId, 'junctions'], queryKey: qk.channels.junctions(channelId),
queryFn: () => listJunctions(channelId), queryFn: () => listJunctions(channelId),
}) })
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const save = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: async () => {
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CircleAlert } from 'lucide-react' import { AlertTriangle, CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types' import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn' import { cn } from '@/shared/lib/cn'
import { getTemplateIssues } from '../api' import { getTemplateIssues } from '../api'
@@ -20,7 +21,7 @@ export function TemplateIssues({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { data: issues } = useQuery({ const { data: issues } = useQuery({
queryKey: ['admin', 'channels', channelId, 'issues'], queryKey: qk.channels.issues(channelId),
queryFn: () => getTemplateIssues(channelId), queryFn: () => getTemplateIssues(channelId),
}) })
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { Eye } from 'lucide-react' import { Eye } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types' import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
@@ -33,7 +34,7 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme') const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
const { data, isFetching } = useQuery({ const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'preview', days], queryKey: qk.channels.preview(channelId, days),
queryFn: () => previewTemplate(channelId, days), queryFn: () => previewTemplate(channelId, days),
enabled: open, enabled: open,
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет. // Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
@@ -1,38 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
export function NumberField({
label,
value,
onChange,
min,
}: {
label: string
value: number
onChange: (v: number) => void
min?: number
}) {
return (
<div className="flex flex-col gap-1.5">
<Label>{label}</Label>
<Input
type="number"
min={min}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-24"
/>
</div>
)
}
export function RemoveButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation()
return (
<Button size="sm" variant="destructive" onClick={onClick}>
{t('common.delete')}
</Button>
)
}
@@ -16,14 +16,6 @@ export function toTime(minutes: number): string {
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00` return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00`
} }
/** Минуты суток → «HH:MM». */
export function formatMinute(minute: number | null) {
if (minute == null) return '—'
const h = Math.floor(minute / 60)
const m = minute % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
/** /**
* Момент UTC во времени канала. Сетка задаётся в нём, поэтому предпросмотр показывается так же: * Момент UTC во времени канала. Сетка задаётся в нём, поэтому предпросмотр показывается так же:
* локальное время админа тут только запутало бы. * локальное время админа тут только запутало бы.
@@ -6,13 +6,13 @@ import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api' import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery' import { ImageGallery } from '@/features/admin/images/ImageGallery'
import { listShows } from '@/features/admin/shows/api' import { listShows } from '@/features/admin/shows/api'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label' import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { import {
addCollectionShow, addCollectionShow,
getCollection, getCollection,
@@ -32,16 +32,15 @@ export function CollectionDetail({ collectionId }: { collectionId: string }) {
const [description, setDescription] = useState<string | null>(null) const [description, setDescription] = useState<string | null>(null)
const { data: collection, isLoading } = useQuery({ const { data: collection, isLoading } = useQuery({
queryKey: ['admin', 'collections', collectionId], queryKey: qk.collections.detail(collectionId),
queryFn: () => getCollection(collectionId), queryFn: () => getCollection(collectionId),
}) })
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() }) const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
const invalidate = () => { const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] }) void queryClient.invalidateQueries({ queryKey: qk.collections.all })
} }
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -3,11 +3,11 @@ import { Link } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api' import { imageUrl } from '@/features/admin/images/api'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable' import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createCollection, deleteCollection, listCollections } from './api' import { createCollection, deleteCollection, listCollections } from './api'
export function CollectionsPanel() { export function CollectionsPanel() {
@@ -17,13 +17,12 @@ export function CollectionsPanel() {
const { sort, toggle } = useTableSort('name', false) const { sort, toggle } = useTableSort('name', false)
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'collections'], queryKey: qk.collections.all,
queryFn: listCollections, queryFn: listCollections,
}) })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.collections.all })
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => createCollection({ name: name.trim() }), mutationFn: () => createCollection({ name: name.trim() }),
@@ -5,8 +5,9 @@ import { useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { z } from 'zod' import { z } from 'zod'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import type { GenreDto } from '@/shared/api/types' import type { GenreDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { import {
@@ -19,7 +20,6 @@ import {
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label' import { Label } from '@/shared/ui/label'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable' import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createGenre, deleteGenre, listGenres, updateGenre } from './api' import { createGenre, deleteGenre, listGenres, updateGenre } from './api'
const createSchema = z.object({ const createSchema = z.object({
@@ -53,7 +53,7 @@ export function GenresPanel() {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: genres, isLoading } = useQuery({ const { data: genres, isLoading } = useQuery({
queryKey: ['admin', 'genres'], queryKey: qk.genres.all,
queryFn: listGenres, queryFn: listGenres,
}) })
const { sort, toggle } = useTableSort('sortOrder', false) const { sort, toggle } = useTableSort('sortOrder', false)
@@ -64,9 +64,8 @@ export function GenresPanel() {
showCount: (g) => g.showCount, showCount: (g) => g.showCount,
}) })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'genres'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.genres.all })
const reportError = (error: unknown) => const reportError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({ mutationFn: createGenre, onSuccess: invalidate }) const createMutation = useMutation({ mutationFn: createGenre, onSuccess: invalidate })
const updateMutation = useMutation({ const updateMutation = useMutation({
@@ -3,8 +3,9 @@ import { Link } from '@tanstack/react-router'
import { ChevronLeft, GripVertical, Search, Trash2 } from 'lucide-react' import { ChevronLeft, GripVertical, Search, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import type { GroupCandidateDto, GroupFilter } from '@/shared/api/types' import type { GroupCandidateDto, GroupFilter } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
@@ -36,7 +37,7 @@ export function GroupDetail({ groupId }: { groupId: string }) {
const [dragged, setDragged] = useState<string | null>(null) const [dragged, setDragged] = useState<string | null>(null)
const { data: group, isLoading } = useQuery({ const { data: group, isLoading } = useQuery({
queryKey: ['admin', 'groups', groupId], queryKey: qk.groups.detail(groupId),
queryFn: () => getGroup(groupId), queryFn: () => getGroup(groupId),
}) })
@@ -46,10 +47,9 @@ export function GroupDetail({ groupId }: { groupId: string }) {
}, [group, filter]) }, [group, filter])
const invalidate = () => { const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] }) void queryClient.invalidateQueries({ queryKey: qk.groups.all })
} }
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { listGenres } from '@/features/admin/genres/api' import { listGenres } from '@/features/admin/genres/api'
import { qk } from '@/shared/api/query-keys'
import type { GroupElementKind, GroupFilter, ShowAudience, ShowKind } from '@/shared/api/types' import type { GroupElementKind, GroupFilter, ShowAudience, ShowKind } from '@/shared/api/types'
import { SHOW_AUDIENCES } from '@/shared/api/types' import { SHOW_AUDIENCES } from '@/shared/api/types'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
@@ -18,7 +19,7 @@ export function GroupFilterPanel({
onChange: (next: GroupFilter) => void onChange: (next: GroupFilter) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres }) const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
const patch = (part: Partial<GroupFilter>) => onChange({ ...filter, ...part }) const patch = (part: Partial<GroupFilter>) => onChange({ ...filter, ...part })
@@ -2,12 +2,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable' import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createGroup, deleteGroup, listGroups } from './api' import { createGroup, deleteGroup, listGroups } from './api'
import { DurationLabel } from './DurationLabel' import { DurationLabel } from './DurationLabel'
@@ -17,11 +17,10 @@ export function GroupsPanel() {
const [name, setName] = useState('') const [name, setName] = useState('')
const { sort, toggle } = useTableSort('name', false) const { sort, toggle } = useTableSort('name', false)
const { data, isLoading } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups }) const { data, isLoading } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.groups.all })
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => createGroup({ name: name.trim() }), mutationFn: () => createGroup({ name: name.trim() }),
@@ -2,19 +2,19 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react' import { Trash2, Upload } from 'lucide-react'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import type { ImageCategory } from '@/shared/api/types' import type { ImageCategory } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { deleteImage, imageUrl, listImages, uploadImage } from './api' import { deleteImage, imageUrl, listImages, uploadImage } from './api'
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground'] const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
type ImageOrder = 'new' | 'old' | 'az' | 'za' type ImageOrder = 'new' | 'old' | 'az' | 'za'
export type ImagePick = { id: string; url: string } type ImagePick = { id: string; url: string }
/** /**
* Внутренность галереи: вкладки по категориям, загрузка и удаление. Если передан <c>onSelect</c> — * Внутренность галереи: вкладки по категориям, загрузка и удаление. Если передан <c>onSelect</c> —
@@ -35,13 +35,12 @@ export function GalleryBrowser({
const [active, setActive] = useState<ImageCategory>(category) const [active, setActive] = useState<ImageCategory>(category)
const fileInput = useRef<HTMLInputElement>(null) const fileInput = useRef<HTMLInputElement>(null)
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const [order, setOrder] = useState<ImageOrder>('new') const [order, setOrder] = useState<ImageOrder>('new')
const { data: images, isLoading } = useQuery({ const { data: images, isLoading } = useQuery({
queryKey: ['admin', 'images', active], queryKey: qk.images.byCategory(active),
queryFn: () => listImages(active), queryFn: () => listImages(active),
}) })
@@ -62,7 +61,7 @@ export function GalleryBrowser({
return arr return arr
}, [images, order]) }, [images, order])
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.images.byCategory(active) })
const pick = (id: string) => { const pick = (id: string) => {
if (!onSelect) return if (!onSelect) return
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { addGroupElements, createGroup, listGroups } from '@/features/admin/groups/api' import { addGroupElements, createGroup, listGroups } from '@/features/admin/groups/api'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
@@ -21,9 +22,9 @@ export function ClipGroupPanel({ onError }: { onError: (error: unknown) => void
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [over, setOver] = useState(false) const [over, setOver] = useState(false)
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups }) const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const invalidate = () => { const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] }) void queryClient.invalidateQueries({ queryKey: qk.groups.all })
} }
const createMutation = useMutation({ const createMutation = useMutation({
@@ -6,14 +6,14 @@ import { useTranslation } from 'react-i18next'
import { deleteCollection } from '@/features/admin/collections/api' import { deleteCollection } from '@/features/admin/collections/api'
import { useUploadStore } from '@/features/admin/media/upload-store' import { useUploadStore } from '@/features/admin/media/upload-store'
import { deleteShow, renameShow } from '@/features/admin/shows/api' import { deleteShow, renameShow } from '@/features/admin/shows/api'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import type { InterstitialDto } from '@/shared/api/types' import type { InterstitialDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { HlsVideo } from '@/shared/ui/hls-video' import { HlsVideo } from '@/shared/ui/hls-video'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { BlockBuilder } from './BlockBuilder' import { BlockBuilder } from './BlockBuilder'
import { ClipGroupPanel } from './ClipGroupPanel' import { ClipGroupPanel } from './ClipGroupPanel'
import { listInterstitialBlocks, listInterstitials, mediaPreviewUrl } from './api' import { listInterstitialBlocks, listInterstitials, mediaPreviewUrl } from './api'
@@ -34,19 +34,18 @@ export function InterstitialsPanel() {
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null) const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null)
const { data: clips, isLoading } = useQuery({ const { data: clips, isLoading } = useQuery({
queryKey: ['admin', 'interstitials'], queryKey: qk.interstitials.all,
queryFn: listInterstitials, queryFn: listInterstitials,
}) })
const { data: blocks } = useQuery({ const { data: blocks } = useQuery({
queryKey: ['admin', 'interstitials', 'blocks'], queryKey: qk.interstitials.blocks,
queryFn: listInterstitialBlocks, queryFn: listInterstitialBlocks,
}) })
const invalidate = () => { const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] }) void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
} }
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const renameMutation = useMutation({ const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => renameShow(id, name), mutationFn: ({ id, name }: { id: string; name: string }) => renameShow(id, name),
@@ -2,7 +2,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
@@ -15,14 +16,13 @@ export function MaintenancePanel() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [showId, setShowId] = useState('') const [showId, setShowId] = useState('')
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() }) const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
// Затрагиваются медиа/шоу/каналы — сбрасываем все связанные кэши. // Затрагиваются медиа/шоу/каналы — сбрасываем все связанные кэши.
const invalidateAll = () => { const invalidateAll = () => {
for (const key of [['admin', 'media'], ['admin', 'shows'], ['admin', 'channels']]) for (const key of [qk.media.all, qk.shows.all, qk.channels.all])
void queryClient.invalidateQueries({ queryKey: key }) void queryClient.invalidateQueries({ queryKey: key })
} }
@@ -3,8 +3,9 @@ import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { listShows } from '@/features/admin/shows/api' import { listShows } from '@/features/admin/shows/api'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import type { ManualInboxFileDto } from '@/shared/api/types' import type { ManualInboxFileDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { import {
@@ -57,10 +58,10 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const [collapsed, setCollapsed] = useState<string[]>([]) const [collapsed, setCollapsed] = useState<string[]>([])
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', 'manual'], queryKey: qk.media.manual,
queryFn: listManualInbox, queryFn: listManualInbox,
}) })
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() }) const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
const regexOk = isValidRegex(regexStr) const regexOk = isValidRegex(regexStr)
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
@@ -146,6 +147,8 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
(f) => parsedByPath.get(f.relativePath)?.episode != null, (f) => parsedByPath.get(f.relativePath)?.episode != null,
).length ).length
const onError = useApiError()
const importMutation = useMutation({ const importMutation = useMutation({
mutationFn: () => mutationFn: () =>
importManualInbox( importManualInbox(
@@ -167,12 +170,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
toast.error(`${failure.relativePath}: ${failure.reason}`) toast.error(`${failure.relativePath}: ${failure.reason}`)
setSelected([]) setSelected([])
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) void queryClient.invalidateQueries({ queryKey: qk.media.all })
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] }) void queryClient.invalidateQueries({ queryKey: qk.shows.all })
if (result.failed.length === 0) onClose() if (result.failed.length === 0) onClose()
}, },
onError: (error: unknown) => onError,
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
}) })
const toggle = (path: string) => const toggle = (path: string) =>
@@ -2,14 +2,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FolderInput, ListPlus, Upload } from 'lucide-react' import { FolderInput, ListPlus, Upload } from 'lucide-react'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge, type BadgeProps } from '@/shared/ui/badge' import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Pager } from '@/shared/ui/pager' import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, useTableSort } from '@/shared/ui/sortable' import { SortHeader, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, getMediaStats, listMedia } from './api' import { deleteMedia, getMediaStats, listMedia } from './api'
import { ManualInboxDialog } from './ManualInboxDialog' import { ManualInboxDialog } from './ManualInboxDialog'
import { UploadToShowDialog } from './UploadToShowDialog' import { UploadToShowDialog } from './UploadToShowDialog'
@@ -63,7 +63,7 @@ export function MediaPanel() {
} }
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'media', filter, page, sort.key, sort.desc], queryKey: qk.media.list(filter, page, sort.key, sort.desc),
queryFn: () => queryFn: () =>
listMedia({ listMedia({
page, page,
@@ -80,7 +80,7 @@ export function MediaPanel() {
}) })
const { data: stats } = useQuery({ const { data: stats } = useQuery({
queryKey: ['admin', 'media', 'stats'], queryKey: qk.media.stats,
queryFn: getMediaStats, queryFn: getMediaStats,
// Пока есть незавершённая работа — освежаем чипы очереди/обработки. // Пока есть незавершённая работа — освежаем чипы очереди/обработки.
refetchInterval: (query) => refetchInterval: (query) =>
@@ -101,9 +101,8 @@ export function MediaPanel() {
void refetch() void refetch()
}, [activity, refetch]) }, [activity, refetch])
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError }) const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { import {
@@ -30,7 +31,7 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
// Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение. // Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение.
const [overrides, setOverrides] = useState<Record<string, string>>({}) const [overrides, setOverrides] = useState<Record<string, string>>({})
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() }) const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
const regexOk = isValidRegex(regexStr) const regexOk = isValidRegex(regexStr)
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
PagedList, PagedList,
} from '@/shared/api/types' } from '@/shared/api/types'
export type ListMediaParams = { type ListMediaParams = {
page: number page: number
pageSize: number pageSize: number
statuses?: MediaAssetStatus[] statuses?: MediaAssetStatus[]
@@ -1,4 +1,4 @@
export type ParseOptions = { type ParseOptions = {
/** Ручной сезон — перебивает распознанный/дефолтный. */ /** Ручной сезон — перебивает распознанный/дефолтный. */
seasonOverride?: number | null seasonOverride?: number | null
/** Пользовательский regex для номера серии: 1 группа = серия, 2 группы = (сезон, серия). */ /** Пользовательский regex для номера серии: 1 группа = серия, 2 группы = (сезон, серия). */
@@ -5,7 +5,7 @@
* («Star Trek Discovery» важнее «Star Trek»). * («Star Trek Discovery» важнее «Star Trek»).
*/ */
export type ShowNameRef = { id: string; name: string; originalName?: string | null } type ShowNameRef = { id: string; name: string; originalName?: string | null }
/** Приводит строку к «словам через пробел»: буквы/цифры сохраняем, всё прочее — разделитель. */ /** Приводит строку к «словам через пробел»: буквы/цифры сохраняем, всё прочее — разделитель. */
function normalize(value: string): string { function normalize(value: string): string {
@@ -1,4 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { qk } from '@/shared/api/query-keys'
import { HttpError, refreshAccessToken } from '@/shared/api/client' import { HttpError, refreshAccessToken } from '@/shared/api/client'
import { queryClient } from '@/shared/api/query-client' import { queryClient } from '@/shared/api/query-client'
import { importInterstitials } from '@/features/admin/interstitials/api' import { importInterstitials } from '@/features/admin/interstitials/api'
@@ -31,7 +32,7 @@ type UploadStore = {
* <c>showId</c> — общий для всех файлов; <c>resolveShowId</c> — привязка на каждый файл (напр. * <c>showId</c> — общий для всех файлов; <c>resolveShowId</c> — привязка на каждый файл (напр.
* автоопределение шоу по имени релиза). Приоритет у <c>resolveShowId</c>, затем общий <c>showId</c>. * автоопределение шоу по имени релиза). Приоритет у <c>resolveShowId</c>, затем общий <c>showId</c>.
*/ */
export type EnqueueOptions = { type EnqueueOptions = {
showId?: string showId?: string
resolveShowId?: (file: File) => string | undefined resolveShowId?: (file: File) => string | undefined
/** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */ /** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */
@@ -105,13 +106,13 @@ async function pump() {
if (created) { if (created) {
patch(job.id, { status: 'done', percent: 100 }) patch(job.id, { status: 'done', percent: 100 })
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) void queryClient.invalidateQueries({ queryKey: qk.media.all })
// Привязка к шоу: добавляем свежий ассет серией (порядок — как в очереди). // Привязка к шоу: добавляем свежий ассет серией (порядок — как в очереди).
if (job.showId) { if (job.showId) {
try { try {
await addEpisode(job.showId, created.id) await addEpisode(job.showId, created.id)
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] }) void queryClient.invalidateQueries({ queryKey: qk.shows.all })
} catch { } catch {
toast.error(`${job.file.name}: не удалось добавить в шоу`) toast.error(`${job.file.name}: не удалось добавить в шоу`)
} }
@@ -119,7 +120,7 @@ async function pump() {
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается. // Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
try { try {
await importInterstitials([created.id]) await importInterstitials([created.id])
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] }) void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
} catch { } catch {
toast.error(`${job.file.name}: не удалось завести ролик`) toast.error(`${job.file.name}: не удалось завести ролик`)
} }
@@ -5,7 +5,8 @@ import { useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { z } from 'zod' import { z } from 'zod'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { import {
@@ -19,7 +20,6 @@ import {
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label' import { Label } from '@/shared/ui/label'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable' import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createRole, deleteRole, listRoles, updateRole } from './api' import { createRole, deleteRole, listRoles, updateRole } from './api'
const schema = z.object({ name: z.string().min(1).max(64) }) const schema = z.object({ name: z.string().min(1).max(64) })
@@ -27,14 +27,15 @@ const schema = z.object({ name: z.string().min(1).max(64) })
export function RolesPanel() { export function RolesPanel() {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles }) const { data: roles, isLoading } = useQuery({ queryKey: qk.roles.all, queryFn: listRoles })
const { sort, toggle } = useTableSort('name', false) const { sort, toggle } = useTableSort('name', false)
const sortedRoles = sortRows(roles ?? [], sort, { const sortedRoles = sortRows(roles ?? [], sort, {
name: (r) => r.name.toLowerCase(), name: (r) => r.name.toLowerCase(),
system: (r) => r.isSystem, system: (r) => r.isSystem,
}) })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.roles.all })
const onError = useApiError()
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (name: string) => createRole(name), mutationFn: (name: string) => createRole(name),
@@ -44,17 +45,13 @@ export function RolesPanel() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRole(id), mutationFn: (id: string) => deleteRole(id),
onSuccess: invalidate, onSuccess: invalidate,
onError: (error) => { onError,
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
},
}) })
const renameMutation = useMutation({ const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => updateRole(id, name), mutationFn: ({ id, name }: { id: string; name: string }) => updateRole(id, name),
onSuccess: invalidate, onSuccess: invalidate,
onError: (error) => { onError,
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
},
}) })
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -66,7 +63,7 @@ export function RolesPanel() {
reset() reset()
setOpen(false) setOpen(false)
} catch (error) { } catch (error) {
toast.error(error instanceof HttpError ? error.detail : t('common.error')) onError(error)
} }
} }
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
@@ -16,7 +17,7 @@ export function SettingsPanel() {
const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false) const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false)
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'settings'], queryKey: qk.settings.all,
queryFn: getSiteSettings, queryFn: getSiteSettings,
}) })
@@ -28,6 +29,8 @@ export function SettingsPanel() {
} }
}, [data]) }, [data])
const onError = useApiError()
const save = useMutation({ const save = useMutation({
mutationFn: () => mutationFn: () =>
updateSiteSettings({ updateSiteSettings({
@@ -37,10 +40,9 @@ export function SettingsPanel() {
}), }),
onSuccess: () => { onSuccess: () => {
toast.success(t('settings.saved')) toast.success(t('settings.saved'))
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }) void queryClient.invalidateQueries({ queryKey: qk.settings.all })
}, },
onError: (error: unknown) => onError,
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
}) })
return ( return (
@@ -3,8 +3,9 @@ import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { SHOW_AUDIENCES, type MediaAssetDto, type ShowAudience } from '@/shared/api/types' import { SHOW_AUDIENCES, type MediaAssetDto, type ShowAudience } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
@@ -38,17 +39,16 @@ export function ShowDetail({ showId }: { showId: string }) {
const [epPage, setEpPage] = useState(1) const [epPage, setEpPage] = useState(1)
const { data: show, isLoading } = useQuery({ const { data: show, isLoading } = useQuery({
queryKey: ['admin', 'shows', showId], queryKey: qk.shows.detail(showId),
queryFn: () => getShow(showId), queryFn: () => getShow(showId),
}) })
const { data: ready } = useQuery({ const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready', 'all'], queryKey: qk.media.ready,
queryFn: () => listAllMedia({ statuses: ['Ready'] }), queryFn: () => listAllMedia({ statuses: ['Ready'] }),
}) })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.detail(showId) })
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const audienceMutation = useMutation({ const audienceMutation = useMutation({
mutationFn: (audience: ShowAudience) => setShowAudience(showId, audience), mutationFn: (audience: ShowAudience) => setShowAudience(showId, audience),
@@ -3,12 +3,12 @@ import { Tag } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { listGenres } from '@/features/admin/genres/api' import { listGenres } from '@/features/admin/genres/api'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import type { ShowDto } from '@/shared/api/types' import type { ShowDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { toast } from '@/shared/ui/toast-store'
import { setShowGenres } from './api' import { setShowGenres } from './api'
/** /**
@@ -22,19 +22,20 @@ export function ShowGenresField({ show, onChanged }: { show: ShowDto; onChanged:
const [primary, setPrimary] = useState<string | null>(null) const [primary, setPrimary] = useState<string | null>(null)
const { data: genres } = useQuery({ const { data: genres } = useQuery({
queryKey: ['admin', 'genres'], queryKey: qk.genres.all,
queryFn: listGenres, queryFn: listGenres,
enabled: open, enabled: open,
}) })
const onError = useApiError()
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => setShowGenres(show.id, selected, primary), mutationFn: () => setShowGenres(show.id, selected, primary),
onSuccess: () => { onSuccess: () => {
onChanged() onChanged()
setOpen(false) setOpen(false)
}, },
onError: (error) => onError,
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
}) })
const openDialog = () => { const openDialog = () => {
@@ -2,8 +2,9 @@ import { useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types' import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
@@ -46,12 +47,11 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
}, [show.description, show.year]) }, [show.description, show.year])
const { data: providers } = useQuery({ const { data: providers } = useQuery({
queryKey: ['admin', 'metadata', 'providers'], queryKey: qk.metadata.providers,
queryFn: getMetadataProviders, queryFn: getMetadataProviders,
}) })
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const changed = () => onChanged() const changed = () => onChanged()
const setPoster = useMutation({ const setPoster = useMutation({
@@ -2,15 +2,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { SHOW_AUDIENCES, type ShowAudience, type ShowKind } from '@/shared/api/types' import { SHOW_AUDIENCES, type ShowAudience, type ShowKind } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Pager } from '@/shared/ui/pager' import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable' import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { listGenres } from '@/features/admin/genres/api' import { listGenres } from '@/features/admin/genres/api'
import { createShow, deleteShow, listShows } from './api' import { createShow, deleteShow, listShows } from './api'
@@ -33,9 +33,9 @@ export function ShowsPanel() {
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным. // Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
const [genreFilter, setGenreFilter] = useState('all') const [genreFilter, setGenreFilter] = useState('all')
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres }) const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'shows', { genreId: genreFilter }], queryKey: qk.shows.byGenre(genreFilter),
queryFn: () => listShows(genreFilter === 'all' ? undefined : genreFilter), queryFn: () => listShows(genreFilter === 'all' ? undefined : genreFilter),
}) })
@@ -60,9 +60,8 @@ export function ShowsPanel() {
}, [data, query, sort]) }, [data, query, sort])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.all })
const onError = (error: unknown) => const onError = useApiError()
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client' import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
@@ -40,9 +41,9 @@ export function UsersPanel() {
const [newRoleId, setNewRoleId] = useState('') const [newRoleId, setNewRoleId] = useState('')
const [resetTarget, setResetTarget] = useState<UserSummaryDto | null>(null) const [resetTarget, setResetTarget] = useState<UserSummaryDto | null>(null)
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles }) const { data: roles } = useQuery({ queryKey: qk.roles.all, queryFn: listRoles })
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', page, search, roleId, sort.key, sort.desc], queryKey: qk.users.list(page, search, roleId, sort.key, sort.desc),
queryFn: () => queryFn: () =>
listUsers({ listUsers({
page, page,
@@ -54,9 +55,9 @@ export function UsersPanel() {
}), }),
}) })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.users.all })
const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) const onError = useApiError()
const blockMutation = useMutation({ mutationFn: blockUser, onSuccess: invalidate, onError }) const blockMutation = useMutation({ mutationFn: blockUser, onSuccess: invalidate, onError })
const unblockMutation = useMutation({ mutationFn: unblockUser, onSuccess: invalidate, onError }) const unblockMutation = useMutation({ mutationFn: unblockUser, onSuccess: invalidate, onError })
+1 -1
View File
@@ -1,7 +1,7 @@
import { apiRequest } from '@/shared/api/client' import { apiRequest } from '@/shared/api/client'
import type { CreatedIdResponse, PagedList, UserSummaryDto } from '@/shared/api/types' import type { CreatedIdResponse, PagedList, UserSummaryDto } from '@/shared/api/types'
export type ListUsersParams = { type ListUsersParams = {
page: number page: number
pageSize: number pageSize: number
search?: string search?: string
+4 -3
View File
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Radio, RotateCw } from 'lucide-react' import { Radio, RotateCw } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { PublicEpgEntryDto } from '@/shared/api/types' import type { PublicEpgEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn' import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
@@ -28,10 +29,10 @@ export function AirPage() {
} }
const { data: channels, isLoading } = useQuery({ const { data: channels, isLoading } = useQuery({
queryKey: ['air', 'channels'], queryKey: qk.air.channels,
queryFn: listChannels, queryFn: listChannels,
}) })
const { data: features } = useQuery({ queryKey: ['air', 'features'], queryFn: getViewerFeatures }) const { data: features } = useQuery({ queryKey: qk.air.features, queryFn: getViewerFeatures })
const numbersEnabled = features?.channelNumbersEnabled ?? false const numbersEnabled = features?.channelNumbersEnabled ?? false
const currentChannel = channels?.find((c) => c.slug === selected) const currentChannel = channels?.find((c) => c.slug === selected)
@@ -116,7 +117,7 @@ export function AirPage() {
}, [selected, playerError]) }, [selected, playerError])
const { data: epg } = useQuery({ const { data: epg } = useQuery({
queryKey: ['air', 'epg', selected], queryKey: qk.air.epg(selected),
queryFn: () => queryFn: () =>
getEpg( getEpg(
selected!, selected!,
+2 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { LoginForm } from '@/features/auth/LoginForm' import { LoginForm } from '@/features/auth/LoginForm'
import { fetchRegistrationStatus } from '@/features/auth/api' import { fetchRegistrationStatus } from '@/features/auth/api'
import { useRequireGuest } from '@/features/auth/guards' import { useRequireGuest } from '@/features/auth/guards'
import { qk } from '@/shared/api/query-keys'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
export const Route = createFileRoute('/login')({ component: LoginPage }) export const Route = createFileRoute('/login')({ component: LoginPage })
@@ -14,7 +15,7 @@ function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: registration } = useQuery({ const { data: registration } = useQuery({
queryKey: ['auth', 'registration'], queryKey: qk.auth.registration,
queryFn: fetchRegistrationStatus, queryFn: fetchRegistrationStatus,
}) })
+2 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { RegisterForm } from '@/features/auth/RegisterForm' import { RegisterForm } from '@/features/auth/RegisterForm'
import { fetchRegistrationStatus } from '@/features/auth/api' import { fetchRegistrationStatus } from '@/features/auth/api'
import { useRequireGuest } from '@/features/auth/guards' import { useRequireGuest } from '@/features/auth/guards'
import { qk } from '@/shared/api/query-keys'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
export const Route = createFileRoute('/register')({ component: RegisterPage }) export const Route = createFileRoute('/register')({ component: RegisterPage })
@@ -14,7 +15,7 @@ function RegisterPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: registration, isLoading } = useQuery({ const { data: registration, isLoading } = useQuery({
queryKey: ['auth', 'registration'], queryKey: qk.auth.registration,
queryFn: fetchRegistrationStatus, queryFn: fetchRegistrationStatus,
}) })
+93
View File
@@ -0,0 +1,93 @@
/**
* Ключи TanStack Query в одном месте. Инвалидация работает по префиксу, поэтому ключи строятся
* иерархически: `qk.channels.detail(id)` начинается с `qk.channels.all`, и сброс списка каналов
* заодно сбрасывает все их подветки. Раньше ключи были строковыми литералами по всему коду —
* опечатка в одном месте тихо ломала инвалидацию в другом.
*/
export const qk = {
auth: {
registration: ['auth', 'registration'] as const,
},
air: {
channels: ['air', 'channels'] as const,
features: ['air', 'features'] as const,
epg: (slug: string | null) => ['air', 'epg', slug] as const,
},
channels: {
all: ['admin', 'channels'] as const,
detail: (id: string) => ['admin', 'channels', id] as const,
template: (id: string) => ['admin', 'channels', id, 'template'] as const,
schedule: (id: string) => ['admin', 'channels', id, 'schedule'] as const,
junctions: (id: string) => ['admin', 'channels', id, 'junctions'] as const,
issues: (id: string) => ['admin', 'channels', id, 'issues'] as const,
diff: (id: string) => ['admin', 'channels', id, 'diff'] as const,
preview: (id: string, days: number) => ['admin', 'channels', id, 'preview', days] as const,
},
entries: {
trace: (id: string) => ['admin', 'entries', id, 'trace'] as const,
},
shows: {
all: ['admin', 'shows'] as const,
byGenre: (genreId: string | null) => ['admin', 'shows', { genreId }] as const,
detail: (id: string) => ['admin', 'shows', id] as const,
},
groups: {
all: ['admin', 'groups'] as const,
detail: (id: string) => ['admin', 'groups', id] as const,
},
collections: {
all: ['admin', 'collections'] as const,
detail: (id: string) => ['admin', 'collections', id] as const,
},
genres: {
all: ['admin', 'genres'] as const,
},
interstitials: {
all: ['admin', 'interstitials'] as const,
blocks: ['admin', 'interstitials', 'blocks'] as const,
},
images: {
byCategory: (category: string) => ['admin', 'images', category] as const,
},
media: {
all: ['admin', 'media'] as const,
stats: ['admin', 'media', 'stats'] as const,
manual: ['admin', 'media', 'manual'] as const,
ready: ['admin', 'media', 'ready', 'all'] as const,
list: (filter: string, page: number, sortKey: string, sortDesc: boolean) =>
['admin', 'media', filter, page, sortKey, sortDesc] as const,
},
users: {
all: ['admin', 'users'] as const,
list: (
page: number,
search: string,
roleId: string | null,
sortKey: string,
sortDesc: boolean,
) => ['admin', 'users', page, search, roleId, sortKey, sortDesc] as const,
},
roles: {
all: ['admin', 'roles'] as const,
},
settings: {
all: ['admin', 'settings'] as const,
},
metadata: {
providers: ['admin', 'metadata', 'providers'] as const,
},
}
+15 -15
View File
@@ -62,7 +62,7 @@ export type CreatedIdResponse = { id: string }
// ── Медиа ──────────────────────────────────────────────────────────────── // ── Медиа ────────────────────────────────────────────────────────────────
export type MediaAssetStatus = 'Pending' | 'Processing' | 'Ready' | 'Failed' export type MediaAssetStatus = 'Pending' | 'Processing' | 'Ready' | 'Failed'
export type MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated' type MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated'
/** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */ /** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */
export type ManualInboxFileDto = { export type ManualInboxFileDto = {
@@ -149,7 +149,7 @@ export type ShowSummaryDto = {
primaryGenre: string | null primaryGenre: string | null
} }
export type ShowGenreDto = { type ShowGenreDto = {
id: string id: string
name: string name: string
isPrimary: boolean isPrimary: boolean
@@ -187,7 +187,7 @@ export type CollectionSummaryDto = {
createdAt: string createdAt: string
} }
export type CollectionItemDto = { type CollectionItemDto = {
showId: string showId: string
position: number position: number
showName: string showName: string
@@ -208,7 +208,7 @@ export type CollectionDto = {
} }
/** Коллекция, в которую входит шоу — для блока на экране шоу. */ /** Коллекция, в которую входит шоу — для блока на экране шоу. */
export type ShowCollectionRefDto = { type ShowCollectionRefDto = {
id: string id: string
name: string name: string
position: number position: number
@@ -241,7 +241,7 @@ export type GroupSummaryDto = {
createdAt: string createdAt: string
} }
export type GroupItemDto = { type GroupItemDto = {
id: string id: string
elementKind: GroupElementKind elementKind: GroupElementKind
elementId: string elementId: string
@@ -288,7 +288,7 @@ export type MetadataCandidate = {
posterUrl: string | null posterUrl: string | null
} }
export type SeasonGapDto = { type SeasonGapDto = {
season: number season: number
expected: number | null expected: number | null
loaded: number loaded: number
@@ -299,7 +299,7 @@ export type MissingEpisodesReport = {
seasons: SeasonGapDto[] seasons: SeasonGapDto[]
} }
export type EpisodeDto = { type EpisodeDto = {
id: string id: string
mediaAssetId: string mediaAssetId: string
position: number position: number
@@ -339,7 +339,7 @@ export type ShowDto = {
} }
// ── Каналы ──────────────────────────────────────────────────────────────── // ── Каналы ────────────────────────────────────────────────────────────────
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff' type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff'
export type BumperFont = 'Sans' | 'Serif' export type BumperFont = 'Sans' | 'Serif'
export type BumperSelection = 'Random' | 'AlwaysFirst' | 'WeightedRandom' export type BumperSelection = 'Random' | 'AlwaysFirst' | 'WeightedRandom'
export type BumperTextKind = 'NowNext' | 'Free' export type BumperTextKind = 'NowNext' | 'Free'
@@ -414,9 +414,9 @@ export type SlotKind = 'Content' | 'Repeat' | 'SignOff'
export type SlotBlockMode = 'Count' | 'Duration' | 'FillSlot' export type SlotBlockMode = 'Count' | 'Duration' | 'FillSlot'
export type OverflowPolicy = 'ContinueNext' | 'ExtendSlot' | 'SkipIfNotFits' export type OverflowPolicy = 'ContinueNext' | 'ExtendSlot' | 'SkipIfNotFits'
export type SlotStrategyType = 'Sequential' | 'RandomWithCooldown' | 'Fixed' export type SlotStrategyType = 'Sequential' | 'RandomWithCooldown' | 'Fixed'
export type CooldownFallback = 'OldestFirst' | 'IgnoreCooldown' type CooldownFallback = 'OldestFirst' | 'IgnoreCooldown'
export type SlotStrategy = { type SlotStrategy = {
type: SlotStrategyType type: SlotStrategyType
restartOnEnd: boolean restartOnEnd: boolean
cooldownDays: number cooldownDays: number
@@ -425,7 +425,7 @@ export type SlotStrategy = {
} }
/** Что повторяет слот-повтор: точка в уже записанной ленте того же канала. */ /** Что повторяет слот-повтор: точка в уже записанной ленте того же канала. */
export type RepeatSource = { daysAgo: number; time: string; durationMinutes: number } type RepeatSource = { daysAgo: number; time: string; durationMinutes: number }
export type SlotDto = { export type SlotDto = {
id: string id: string
@@ -536,7 +536,7 @@ export type ScheduleTemplateDto = {
layers: GridLayerDto[] layers: GridLayerDto[]
} }
export type PlanningWarningKind = type PlanningWarningKind =
| 'SlotEmpty' | 'SlotEmpty'
| 'DriftExceeded' | 'DriftExceeded'
| 'CooldownExhausted' | 'CooldownExhausted'
@@ -547,7 +547,7 @@ export type PlanningWarningKind =
| 'GenreShareExceeded' | 'GenreShareExceeded'
| 'FallbackShareExceeded' | 'FallbackShareExceeded'
export type PlanningWarningDto = { type PlanningWarningDto = {
kind: PlanningWarningKind kind: PlanningWarningKind
slotId: string | null slotId: string | null
details: string details: string
@@ -556,7 +556,7 @@ export type PlanningWarningDto = {
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] } export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
/** Что изменится в эфире, если применить правила сейчас (см. 6.6). */ /** Что изменится в эфире, если применить правила сейчас (см. 6.6). */
export type ScheduleChangeDto = { type ScheduleChangeDto = {
startsAtUtc: string startsAtUtc: string
before: string | null before: string | null
after: string | null after: string | null
@@ -606,7 +606,7 @@ export type CopyTemplateResultDto = {
} }
/** Проверки сетки по правилам, до генерации (см. 5.1). */ /** Проверки сетки по правилам, до генерации (см. 5.1). */
export type TemplateIssueKind = type TemplateIssueKind =
| 'GroupEmpty' | 'GroupEmpty'
| 'GroupTooSmall' | 'GroupTooSmall'
| 'GridGap' | 'GridGap'
File diff suppressed because it is too large Load Diff
+677
View File
@@ -0,0 +1,677 @@
/** Словарь локали «en». Плоская правка: ключи обеих локалей обязаны совпадать. */
export const en = {
appName: 'TeleWave',
nav: {
dashboard: 'On Air',
admin: 'Admin',
settings: 'Settings',
login: 'Log in',
register: 'Sign up',
logout: 'Log out',
},
theme: { light: 'Light', dark: 'Dark', system: 'System' },
lang: { ru: 'RU', en: 'EN' },
common: {
save: 'Save',
cancel: 'Cancel',
retry: 'Retry',
delete: 'Delete',
create: 'Create',
loading: 'Loading…',
error: 'Something went wrong',
search: 'Search',
actions: 'Actions',
prevPage: 'Previous page',
nextPage: 'Next page',
yes: 'Yes',
no: 'No',
},
home: {
title: 'TELEWAVE',
subtitle: 'BROADCAST CHANNEL GRID',
tagline: 'Your channels. Your broadcast. Anytime.',
cta: 'Go on air',
ctaRegister: 'Create account',
},
auth: {
userName: 'Username',
password: 'Password',
loginTitle: 'Sign in',
loginSubtitle: 'Enter your credentials to access the channel grid',
registerTitle: 'New viewer',
registerSubtitle: 'Create an account to set up your channel grid',
submitLogin: 'Log in',
submitRegister: 'Sign up',
noAccount: "Don't have an account?",
haveAccount: 'Already have an account?',
registrationClosed: 'Registration is closed',
registrationClosedHint: 'Public registration is disabled. An administrator can create an account for you.',
invalidCredentials: 'Invalid username or password',
userNameTaken: 'This username is already taken',
blocked: 'Account blocked by an administrator',
genericError: 'Could not sign in. Please try again',
},
air: {
now: 'Now',
next: 'Up next',
ad: 'Ad',
bumper: 'Bumper',
episode: 'Episode',
volume: 'Volume',
live: 'Live',
noChannels: 'No channels available yet. Check back later.',
offline: 'This channel is off the air',
offlineHint: 'No schedule or content yet. Check back later.',
retry: 'Retry',
numbersHint: '↑ / ↓ — switch channels by number',
},
settings: {
title: 'Account settings',
changeUserName: 'Change username',
newUserName: 'New username',
changePassword: 'Change password',
currentPassword: 'Current password',
newPassword: 'New password',
dangerZone: 'Danger zone',
deleteAccount: 'Delete account',
deleteAccountConfirm: 'The account and all its data will be permanently deleted. Continue?',
saved: 'Saved',
},
admin: {
groups: {
title: 'Groups',
hint: 'A group is what may go on air. Grid slots reference it; the strategy picks an element from it.',
name: 'Name',
description: 'Description',
items: 'Items',
units: 'Units',
duration: 'Runtime',
hoursShort: 'h',
minutesShort: 'min',
hasFilter: 'has rule',
composition: 'Composition',
empty: 'Group is empty',
orderHint: 'Drag to set the order — sequential strategies follow it.',
showAdvanced: 'Advanced',
hideAdvanced: 'Hide',
find: 'Find matches',
addFound: 'Add found',
found: 'Found: {{total}}, new: {{fresh}}',
added: 'Items added: {{count}}',
alreadyIn: 'already in group',
elementKinds: { Show: 'Show', Collection: 'Collection' },
filter: {
title: 'Selection rule',
hint: 'The rule only finds candidates — the group composition stays an explicit list.',
elementKinds: 'What to search',
showKinds: 'Show type',
genres: 'Genres',
genresHint: 'Any of the checked ones.',
maxAudience: 'No stricter than',
audienceHint: 'Categories are ordered by strictness: kids → family → … → adult.',
year: 'Year',
unitMinutes: 'Unit runtime, min',
unitMinutesHint:
'Average episode or film length. Items without ready assets are not filtered out.',
from: 'from',
to: 'to',
any: 'any',
},
},
collections: {
title: 'Collections',
hint: 'A franchise is an ordered set of films played as a single unit.',
name: 'Name',
description: 'Description',
parts: 'Parts',
units: 'Units',
addShow: 'Add show',
empty: 'Collection is empty',
orderHint: 'Drag to set the order of the parts — that is the order they air in.',
},
interstitials: {
title: 'Clips',
hint: 'Ads, promos and jingles. Drag clips into the block builder or a group on the right — a block is saved as a collection and airs as a whole.',
upload: 'Upload clips',
name: 'Name',
duration: 'Duration',
empty: 'No clips yet',
noAsset: 'No file',
blocks: 'Blocks',
noBlocks: 'No blocks yet',
clipsCount: '{{count}} clips',
blockBuilder: 'Block builder',
blockName: 'Block name',
blockTotal: 'Block duration',
dropHint: 'Drop clips here',
groups: 'Clip groups',
pickGroup: 'Pick a group',
pickGroupFirst: 'Pick a group first',
dropToGroup: 'Drop a clip or a block here',
openGroup: 'Open group',
newGroupName: 'New group',
},
genres: {
title: 'Genres',
hint: 'Genre reference: content groups are built from it, provider metadata is mapped into it.',
name: 'Name',
slug: 'Key',
slugHint: 'Latin letters, no spaces — the genre is matched by it when the reference updates.',
aliases: 'Spellings',
aliasesHint:
'Comma-separated. Maps provider genres onto yours: tmdb:28, action, боевик.',
order: 'Order',
usage: 'Shows',
system: 'System',
create: 'New genre',
edit: 'Edit genre',
},
roles: {
title: 'Roles',
name: 'Name',
system: 'System',
create: 'New role',
rename: 'Rename',
},
users: {
title: 'Users',
userName: 'Username',
role: 'Role',
status: 'Status',
createdAt: 'Joined',
blocked: 'Blocked',
active: 'Active',
block: 'Block',
unblock: 'Unblock',
resetPassword: 'Password',
resetPasswordFor: 'Reset password: {{name}}',
newPassword: 'New password',
passwordReset: 'Password changed',
filterAll: 'All roles',
createTitle: 'Create user',
password: 'Password',
passwordHint: 'At least 8 characters, including a digit and an uppercase letter.',
create: 'Create',
created: 'User created',
},
media: {
title: 'Media',
upload: 'Upload',
manualButton: 'From manual folder',
manualTitle: 'Manual pick from the manual folder',
manualHint:
'Files in manual/ are not picked up by the scanner — select the ones you need and choose a show. Imported files leave the folder, just like from inbox.',
manualSelectAll: 'Select all',
manualSelected: 'Selected: {{count}}',
manualEmpty: 'The manual folder is empty',
regexPickHint: 'Click a number in the file name — a rule for all files is built from it:',
regexPickTitle: 'This is the episode number',
regexPresets: 'Ready-made:',
regexPresetNames: {
seriesWord: 'Серия N',
episodeWord: 'Эпизод N',
seasonEpisode: 'SxxEyy',
afterDash: 'after a dash',
firstNumber: 'first number',
},
regexClear: 'clear',
manualAlready: 'already in the library',
manualRoot: 'manual/ root',
manualRecognized: 'Recognized: {{count}} of {{total}}',
manualCleanupHint:
'Files leave the folder; siblings (subtitles, nfo) and the emptied folder are removed.',
manualTruncated: 'Showing the first 500 files — there are more in the folder.',
manualShow: 'Show',
manualDetected: 'Detected from the release name — check it and change if wrong.',
manualPickShow: 'Pick a show',
manualImport: 'Import into show',
manualImported: 'Files imported: {{count}}',
uploadToShow: 'Upload to show',
toShowTitle: 'Upload and add to show',
autoDetectHint:
'Each file is linked to the show whose original (or display) name appears in the release name, e.g. “The.Simpsons.S33E01…” → The Simpsons.',
toShowLibrary: 'To library',
toShowSeason: 'Season (manual)',
toShowAuto: 'auto',
toShowRegex: 'Episode regex',
toShowRegexInvalid: 'invalid regex',
toShowHint:
'Season and regex are optional: numbers are usually detected automatically (see below). Regex: 1 group = episode, 2 groups = season and episode. Example: ^(\\d+) for “01. Title.mkv”.',
toShowPreview: 'What we detect',
toShowMatched: 'show detected for {{matched}} of {{total}}',
applyToAll: 'Set for all…',
toShowUnknown: '—',
toShowConfirm: 'Upload and add',
uploadedCount: 'Uploaded files: {{count}}',
uploadingCount: 'Uploading {{done}}/{{total}}',
cancelAll: 'Cancel all uploads',
skippedDuplicates: 'Skipped duplicates: {{count}}',
filterActive: 'Active',
filterAll: 'All',
name: 'File',
status: 'Status',
duration: 'Duration',
resolution: 'Resolution',
processingTime: 'Processing time',
empty: 'No uploaded files yet',
statuses: {
Pending: 'Queued',
Processing: 'Processing',
Ready: 'Ready',
Failed: 'Failed',
},
stats: {
queued: 'Currently queued',
queuedShort: 'Queued',
processing: 'Currently processing',
processingShort: 'Processing',
average: 'Average processing time (recent)',
averageShort: 'Avg time',
},
},
gallery: {
title: 'Gallery',
upload: 'Upload',
empty: 'No images in this category yet',
pickHint: 'Pick an image or upload a new one',
browseHint: 'All app images by category',
sort: {
newest: 'Newest first',
oldest: 'Oldest first',
nameAsc: 'Name: AZ',
nameDesc: 'Name: ZA',
},
categories: {
Library: 'Library',
ShowPoster: 'Show posters',
EpisodeStill: 'Episode stills',
BumperBackground: 'Bumper backgrounds',
},
},
shows: {
title: 'Shows',
name: 'Name',
originalName: 'Original name (eng)',
kind: 'Kind',
kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' },
audience: 'Category',
audiences: {
Kids: 'Kids',
Family: 'Family',
Teen: 'Teen',
General: 'General',
Adult: 'Adult',
},
seasons: 'Seasons',
loadedSeasons: 'Loaded seasons',
genre: 'Genre',
allGenres: 'All genres',
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',
inCollections: 'Part of collections',
episodes: 'Episodes',
episode: 'Episode',
noEpisodes: 'No episodes yet',
filterAssets: 'Filter by name, e.g. Family.Guy.S16',
selectAll: 'Select all',
deselectAll: 'Clear',
addSelected: 'Add selected',
noMatches: 'No matching files',
addedCount: 'Episodes added: {{count}}',
candidatesTruncated:
'Not all files are shown (too many ready assets) — narrow the filter to see the rest.',
},
channels: {
number: 'Number',
numberPlaceholder: 'not set',
utcOffset: 'Time zone, h',
utcOffsetHint:
'Whole hours: 3 is Moscow, 0 is UTC, 5 is New York. The whole grid and the schedule are expressed in this time.',
dayStart: 'Broadcast day starts',
dayStartHint: 'The night block before this time belongs to the previous day.',
disabled: 'disabled',
layers: 'Layers',
layersHint: 'Higher priority wins. The background layer sits below all and fills gaps.',
background: 'background',
newLayerName: 'New layer',
addSlotHere: 'Add slot',
newSlot: 'New slot',
editSlot: 'Slot',
slotTitle: 'Block title',
slotStart: 'Start',
slotDuration: 'Budget, min',
weekday: 'Weekday',
everyDay: 'every day',
daypart: 'Daypart',
slotKind: 'Slot type',
group: 'Group',
pickGroup: 'pick a group',
strategy: 'Strategy',
cooldownDays: 'Cooldown, days',
cooldownHint: 'Skip what already aired within this period.',
blockMode: 'Block',
blockValue: 'Value',
overflow: 'If it does not fit',
overflowHint: 'What to do with an element that exceeds the slot budget.',
repeatDaysAgo: 'Days ago',
repeatTime: 'Time',
repeatDuration: 'Duration, min',
anchor: 'Anchor',
anchorHint:
'An anchor keeps the start hard: nothing that would cross it is started. Snapping is soft — it shifts the start to a round time if that fits the allowance.',
maxDrift: 'Allowance, min',
snap: 'Snap',
snapOff: 'off',
bumperConditionsHint:
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
resizeSlot: 'Drag the edge to change duration',
copyDay: 'Copy day',
copyDayFrom: 'Copy {{day}} to:',
copy: 'Copy',
layerVisible: 'Show layer',
layerName: 'Layer name',
layerApplicability: 'When it applies',
applicabilityHint:
'Sections are OR-ed: the layer applies when the date matches at least one condition. Nothing filled in — it always applies.',
applicabilityWeekdays: 'Weekdays',
applicabilityDateRanges: 'Date ranges',
applicabilityAnnual: 'Yearly (month / day)',
applicabilityDates: 'Specific dates',
applicabilityNone: 'not set',
showForDate: 'Grid for date',
allDates: 'All layers',
tabs: {
grid: 'Grid',
rules: 'Rules',
junctions: 'Junctions',
bumpers: 'Bumpers',
viewer: 'Viewer',
settings: 'Settings',
air: 'On air',
},
noTemplate: 'The channel grid failed to load',
createTemplate: 'Create the grid',
createTemplateHint:
'An empty grid with a background layer appears — then add layers and slots.',
rules: 'Candidate rules',
rulesHint:
'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.',
audienceWindows: 'Family hours',
noAudienceWindows: 'No windows — the age is not limited.',
audienceWindowsHint:
'Inside a window only content no stricter than the chosen category airs. A window may cross midnight. Content with no category is never dropped.',
from: 'From',
to: 'To',
maxAudience: 'No stricter than',
repeatLimit: 'Repeat cap',
repeatWindowDays: 'Window, days',
repeatMax: 'At most, times',
repeatLimitHint:
'Counted against the already recorded tape. If every candidate hits the cap the slot is still filled: empty air is worse than an early repeat.',
preview: 'Preview',
previewHide: 'Hide preview',
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
previewDays_one: '{{count}} day',
previewDays_other: '{{count}} days',
previewTabs: { programme: 'Programme', tape: 'Tape', problems: 'Problems' },
noProblems: 'No problems',
andMore: 'and {{count}} more',
heatmap: 'Repeats: show × day',
heatmapTotal: 'total',
issues: 'Checks: {{errors}} errors, {{warnings}} warnings',
goToSlot: 'to slot',
issueKinds: {
GroupEmpty: 'Empty group',
GroupTooSmall: 'Too little content',
GridGap: 'Gap in the grid',
SlotOverlap: 'Slots overlap',
CooldownUnreachable: 'Unreachable cooldown',
AudienceConflict: 'Age conflict',
GroupMissing: 'No group selected',
},
viewer: 'How viewers see it',
viewerHint:
'Overlays are drawn on the client on top of the picture — the video is not re-encoded. Everything is off by default.',
logo: 'Logo',
noLogo: 'none',
pickLogo: 'Pick a logo',
logoCorner: 'Corner',
logoOpacity: 'Opacity',
corners: {
TopLeft: 'Top left',
TopRight: 'Top right',
BottomLeft: 'Bottom left',
BottomRight: 'Bottom right',
},
showClock: 'Show a clock',
analogFilter: 'Analog filter',
analogFilterHint: 'Strength 0..1. Zero is off; it is very easy to overdo.',
whyHere: 'Why is this here',
priority: 'priority',
traceLayer: 'Layer',
traceSlot: 'Slot',
traceGroup: 'Group',
traceCollection: 'Collection',
traceStrategy: 'Strategy',
traceJunction: 'Breaks',
traceDrift: 'drift {{minutes}} min',
traceSnapped: 'start snapped',
traceCooldown: 'cooldown {{days}} d.',
traceCandidates: 'candidates after cooldown: {{count}}',
diffSummary: 'Affects {{total}} entries, {{changed}} will change',
diffSoon: 'Entries changing within 24 hours: {{count}}',
diffNoChanges: 'The air will not change',
copyTemplate: 'Copy a grid from a channel',
pickSourceChannel: 'Pick the source channel',
copyHere: 'Copy here',
copyTemplateHint:
'Layers, slots, junctions and rules of the chosen channel replace this channel grid. Groups are shared and not copied.',
copyTemplateConfirm:
'This channel grid will be replaced with the chosen channel grid. Continue?',
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
postChecks: 'Post-checks',
breakLimit: 'Breaks per hour cap, min',
genreShare: 'Genre share per day cap, %',
fallbackShare: 'Background share cap, %',
postChecksHint:
'Post-checks run against the finished tape and only warn — nothing is replanned.',
previewKinds: {
Program: 'Programme',
Fallback: 'Background',
SignOff: 'Sign-off',
Ad: 'Ad',
Promo: 'Promo',
Bumper: 'Bumper',
},
previewLoad: 'Breaks per hour, peak — {{peak}} min',
junctions: 'Junctions',
junctionsHint:
'What plays between programmes: ads, promos, bumpers. A slot may pick its own junction, otherwise the default one is used.',
defaultJunction: 'Default junction',
noJunction: 'no junction',
newJunctionName: 'New junction',
addJunctionElement: '+ break',
junctionEmpty: 'empty',
junctionFrom: 'end',
junctionTo: 'start',
junctionElement: 'Break',
junctionKind: 'Kind',
junctionKinds: { Ad: 'Ad', Promo: 'Promo', Bumper: 'Bumper', Filler: 'Filler' },
junctionAmountMode: 'Measured in',
junctionAmountModes: { Count: 'Units', Duration: 'Minutes' },
junctionCount: 'How many units',
junctionMinutes: 'How many minutes',
junctionAmountHint:
'For a mixed group (clips and ready-made blocks) count in minutes: one "unit" there is either a clip or a whole block.',
junctionRequired: 'Required — never dropped when time runs short',
junctionOnlyOnChange: 'Only when the show changes',
junctionMinInterval: 'No more often than once per, min',
junctionMinIntervalHint: '0 — no limit.',
junctionBetween: 'Junction inside the slot',
junctionAfter: 'Junction after the slot',
junctionDefault: 'default',
bumperTemplate: 'Bumper block',
pickBumperTemplate: 'pick a block',
minutesShort: ' min',
pendingChanges: 'Rules changed — the air still follows the old ones.',
apply: 'Apply',
applied: 'Air rebuilt, entries: {{count}}',
weekdays: {
0: 'Sun',
1: 'Mon',
2: 'Tue',
3: 'Wed',
4: 'Thu',
5: 'Fri',
6: 'Sat',
},
dayparts: { Morning: 'Morning', Day: 'Day', Prime: 'Prime', Night: 'Night' },
slotKinds: { Content: 'Content', Repeat: 'Repeat', SignOff: 'Sign-off' },
blockModes: { Count: 'Units', Duration: 'Minutes', FillSlot: 'Whole slot' },
overflows: {
ContinueNext: 'Continue next time',
ExtendSlot: 'Extend the slot',
SkipIfNotFits: 'Do not start',
},
strategies: {
Sequential: 'In order',
RandomWithCooldown: 'Random with cooldown',
Fixed: 'Fixed element',
},
warnings: {
SlotEmpty: 'Slot produced no content',
DriftExceeded: 'Start drifted past the allowance',
CooldownExhausted: 'Cooldown ruled out every candidate',
RepeatSourceEmpty: 'Nothing to repeat',
FallbackEmpty: 'Nothing to fill pauses with',
CandidatesFiltered: 'The age cap ruled out every candidate',
BreakLimitExceeded: 'Breaks in an hour exceed the cap',
GenreShareExceeded: 'Genre share above the norm',
FallbackShareExceeded: 'Background share above the norm',
},
title: 'Channels',
name: 'Name',
slug: 'Slug',
state: 'State',
enabled: 'On air',
enabledLabel: 'Channel on air',
settings: 'Settings',
bumpers: 'TV bumpers',
bumpersLabel: 'Transition bumpers',
bumpersHint: 'Short “Now / Next” bumper between different shows',
bumperSelection: 'Block selection',
bumperSelectionRandom: 'Random',
bumperSelectionWeighted: 'Weighted random',
bumperSelectionAlwaysFirst: 'Always first',
bumperFont: 'Font',
bumperFontSans: 'Sans',
bumperFontSerif: 'Serif',
bumperNowLabel: '“Now” label',
bumperNextLabel: '“Next” label',
bumperBg: 'Background (color 1)',
bumperBg2: 'Background (color 2)',
bumperAccent: 'Accent',
bumperText: 'Text',
bumperTemplates: 'Bumper blocks',
bumperTemplatesHint:
'Each block has its own sound and style. The first block is the default and cannot be removed. Bumper length follows the sound length.',
bumperAddTemplate: 'Add block',
bumperTemplateName: 'Name',
bumperVariants: 'Sub-blocks (text)',
bumperVariantsHint:
'Different text over the same music and style. Each sub-block has its own show rule.',
bumperAddVariant: 'Add text',
bumperVariantName: 'Name',
bumperTextKind: 'Text mode',
bumperKindNowNext: 'Now / Next',
bumperKindFree: 'Free text',
bumperLine1: 'Line 1',
bumperLine2: 'Line 2',
bumperTrigger: 'Show on',
bumperTriggerOnShowChange: 'Show change',
bumperTriggerBetweenEpisodes: 'Between episodes',
bumperTriggerBoth: 'Both',
bumperVariantWeight: 'Weight',
bumperVariantWeightHint: 'For the “weighted random” strategy: higher = more often (0 — never picked)',
bumperDefault: 'default',
bumperSeconds: 's',
bumperDefaultDuration: '≈8 s (jingle)',
bumperAudio: 'Sound',
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
bumperPreview: 'Render samples',
bumperPreviewRendering: 'Rendering…',
bumperPreviewHint: 'Samples of all sub-blocks with sound and animation (example show names). Uses saved settings.',
bumperBackground: 'Background image',
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
bumperBackgroundPick: 'Pick from gallery',
bumperFileLoaded: 'loaded',
bumperFileDefault: 'default',
bumperUpload: 'Upload',
bumperReset: 'Reset',
filler: 'Filler',
noFiller: 'No filler',
noSchedule: 'Schedule not built yet',
},
maintenance: {
title: 'Maintenance',
warning: 'These actions are irreversible — data and files are deleted permanently.',
clearMedia: 'Clear all media',
clearMediaHint: 'Deletes all uploaded files and their segments, plus channel schedules.',
confirmClearMedia: 'Permanently delete ALL media files?',
clearShowMedia: 'Delete show media',
clearShowMediaHint: "Deletes files of the selected show's episodes and clears its episodes.",
confirmClearShowMedia: 'Permanently delete all media of the selected show?',
pickShow: 'Pick a show',
deleteShows: 'Delete all shows',
deleteShowsHint: 'Deletes all shows and their episodes. Media files stay in the library.',
confirmDeleteShows: 'Permanently delete ALL shows?',
doneCount: 'Deleted: {{count}}',
},
settings: {
title: 'Settings',
registration: 'Registration',
registrationHint:
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
registrationLabel: 'Allow public registration',
channelNumbers: 'Switch channels by number',
channelNumbersHint:
'Viewers switch channels with the arrow keys, like on a TV set. The channel grid stays available regardless.',
preferredAudio: 'Preferred audio tracks',
preferredAudioHint:
'Comma-separated language codes in priority order (e.g. "rus, eng"). If a file has a track in one of these languages, it is picked during processing (by order); otherwise ffmpeg default. Applies to new processing.',
},
metadata: {
title: 'Metadata',
pickPoster: 'From gallery',
name: 'Name',
originalName: 'Original name (eng)',
originalNamePlaceholder: 'e.g. Family Guy',
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',
sourceLabel: 'Source',
searchBtn: 'Search',
nothingFound: 'Nothing found',
apply: 'Apply',
applied: 'Metadata applied',
overview: 'Overview',
year: 'Year',
clear: 'Clear',
noPoster: 'No poster',
refreshEpisodes: 'Refresh episodes',
refreshing: 'Refreshing…',
refreshedCount: 'Episodes updated: {{count}}',
findMissing: 'Find missing episodes',
missingTitle: 'Missing episodes',
missingNoSeasons: 'No loaded episodes with recognized numbers.',
seasonN: 'Season {{n}}',
loadedOf: 'loaded {{loaded}} of {{total}}',
missingUnknown: 'The source did not return the episode count for this season.',
missingNone: 'All episodes present.',
missingList: 'Missing',
},
},
}
+683
View File
@@ -0,0 +1,683 @@
/** Словарь локали «ru». Плоская правка: ключи обеих локалей обязаны совпадать. */
export const ru = {
appName: 'TeleWave',
nav: {
dashboard: 'Эфир',
admin: 'Админка',
settings: 'Настройки',
login: 'Войти',
register: 'Регистрация',
logout: 'Выйти',
},
theme: { light: 'Светлая', dark: 'Тёмная', system: 'Системная' },
lang: { ru: 'RU', en: 'EN' },
common: {
save: 'Сохранить',
cancel: 'Отмена',
retry: 'Повторить',
delete: 'Удалить',
create: 'Создать',
loading: 'Загрузка…',
error: 'Что-то пошло не так',
search: 'Поиск',
actions: 'Действия',
prevPage: 'Предыдущая страница',
nextPage: 'Следующая страница',
yes: 'Да',
no: 'Нет',
},
home: {
title: 'TELEWAVE',
subtitle: 'ЭФИРНАЯ СЕТКА КАНАЛОВ',
tagline: 'Твои каналы. Твой эфир. В любое время.',
cta: 'Войти в эфир',
ctaRegister: 'Создать аккаунт',
},
auth: {
userName: 'Имя пользователя',
password: 'Пароль',
loginTitle: 'Вход в эфир',
loginSubtitle: 'Введите учётные данные для доступа к сетке каналов',
registerTitle: 'Новый зритель',
registerSubtitle: 'Создайте аккаунт, чтобы настроить свою сетку каналов',
submitLogin: 'Войти',
submitRegister: 'Зарегистрироваться',
noAccount: 'Нет аккаунта?',
haveAccount: 'Уже есть аккаунт?',
registrationClosed: 'Регистрация закрыта',
registrationClosedHint: 'Открытая регистрация отключена. Учётную запись может завести администратор.',
invalidCredentials: 'Неверное имя пользователя или пароль',
userNameTaken: 'Это имя пользователя уже занято',
blocked: 'Аккаунт заблокирован администратором',
genericError: 'Не удалось выполнить вход. Попробуйте ещё раз',
},
air: {
now: 'Сейчас',
next: 'Далее',
ad: 'Реклама',
bumper: 'Заставка',
episode: 'Серия',
live: 'В эфире',
volume: 'Громкость',
noChannels: 'Пока нет доступных каналов. Загляните позже.',
offline: 'Канал сейчас не в эфире',
offlineHint: 'Нет расписания или контента. Загляните позже.',
numbersHint: '↑ / ↓ — переключение каналов по номерам',
retry: 'Повторить',
},
settings: {
title: 'Настройки аккаунта',
changeUserName: 'Смена имени пользователя',
newUserName: 'Новое имя пользователя',
changePassword: 'Смена пароля',
currentPassword: 'Текущий пароль',
newPassword: 'Новый пароль',
dangerZone: 'Опасная зона',
deleteAccount: 'Удалить аккаунт',
deleteAccountConfirm: 'Аккаунт и все данные будут удалены безвозвратно. Продолжить?',
saved: 'Сохранено',
},
admin: {
groups: {
title: 'Группы',
hint: 'Группа — что может попасть в эфир. На неё ссылается слот сетки, из неё стратегия выбирает элемент.',
name: 'Название',
description: 'Описание',
items: 'Позиций',
units: 'Единиц',
duration: 'Объём',
hoursShort: 'ч',
minutesShort: 'мин',
hasFilter: 'с правилом',
composition: 'Состав',
empty: 'Группа пуста',
orderHint: 'Порядок задаётся перетаскиванием — по нему идут последовательные стратегии.',
showAdvanced: 'Дополнительно',
hideAdvanced: 'Скрыть',
find: 'Подобрать',
addFound: 'Добавить найденное',
found: 'Найдено: {{total}}, новых: {{fresh}}',
added: 'Добавлено позиций: {{count}}',
alreadyIn: 'уже в группе',
elementKinds: { Show: 'Шоу', Collection: 'Коллекция' },
filter: {
title: 'Правило набора',
hint: 'Правило только ищет кандидатов — состав группы остаётся явным списком.',
elementKinds: 'Что искать',
showKinds: 'Тип шоу',
genres: 'Жанры',
genresHint: 'Любой из отмеченных.',
maxAudience: 'Возраст не строже',
audienceHint: 'Категории упорядочены по строгости: детское → семейное → … → взрослое.',
year: 'Год',
unitMinutes: 'Длительность единицы, мин',
unitMinutesHint:
'Средняя длина серии или фильма. Позиции без готовых ассетов фильтр не отбрасывает.',
from: 'от',
to: 'до',
any: 'любой',
},
},
collections: {
title: 'Коллекции',
hint: 'Франшиза — упорядоченный набор фильмов, который играется как одно целое.',
name: 'Название',
description: 'Описание',
parts: 'Части',
units: 'Единиц',
addShow: 'Добавить шоу',
empty: 'Коллекция пуста',
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
},
interstitials: {
title: 'Ролики',
hint: 'Реклама, промо и джинглы. Перетащите ролики в сборку блока или в группу справа — блок сохранится коллекцией и пойдёт в эфир целиком.',
upload: 'Загрузить ролики',
name: 'Название',
duration: 'Длительность',
empty: 'Роликов пока нет',
noAsset: 'Без файла',
blocks: 'Блоки',
noBlocks: 'Блоков пока нет',
clipsCount: '{{count}} рол.',
blockBuilder: 'Сборка блока',
blockName: 'Название блока',
blockTotal: 'Длительность блока',
dropHint: 'Перетащите сюда ролики',
groups: 'Группы роликов',
pickGroup: 'Выберите группу',
pickGroupFirst: 'Сначала выберите группу',
dropToGroup: 'Перетащите сюда ролик или блок',
openGroup: 'Открыть группу',
newGroupName: 'Новая группа',
},
genres: {
title: 'Жанры',
hint: 'Справочник жанров: по нему собираются группы контента, в него сводятся жанры из метаданных.',
name: 'Название',
slug: 'Ключ',
slugHint: 'Латиницей, без пробелов — по нему жанр опознаётся при обновлении справочника.',
aliases: 'Варианты написания',
aliasesHint:
'Через запятую. Так жанры провайдеров сводятся к вашему: tmdb:28, action, боевик.',
order: 'Порядок',
usage: 'Шоу',
system: 'Системный',
create: 'Новый жанр',
edit: 'Изменить жанр',
},
roles: {
title: 'Роли',
name: 'Название',
system: 'Системная',
create: 'Новая роль',
rename: 'Переименовать',
},
users: {
title: 'Пользователи',
userName: 'Имя пользователя',
role: 'Роль',
status: 'Статус',
createdAt: 'Регистрация',
blocked: 'Заблокирован',
active: 'Активен',
block: 'Заблокировать',
unblock: 'Разблокировать',
resetPassword: 'Пароль',
resetPasswordFor: 'Сменить пароль: {{name}}',
newPassword: 'Новый пароль',
passwordReset: 'Пароль изменён',
filterAll: 'Все роли',
createTitle: 'Создать пользователя',
password: 'Пароль',
passwordHint: 'Минимум 8 символов, хотя бы одна цифра и заглавная буква.',
create: 'Создать',
created: 'Пользователь создан',
},
media: {
title: 'Медиа',
upload: 'Загрузить',
manualButton: 'Из папки manual',
manualTitle: 'Ручной разбор папки manual',
manualHint:
'Файлы из manual/ не разбираются сканером — выберите нужные и укажите шоу. Импортированные файлы уходят из папки, как и из inbox.',
manualSelectAll: 'Выбрать все',
manualSelected: 'Выбрано: {{count}}',
manualEmpty: 'В папке manual пусто',
regexPickHint: 'Кликните число в имени файла — по нему соберётся правило для всех файлов:',
regexPickTitle: 'Это номер серии',
regexPresets: 'Готовые:',
regexPresetNames: {
seriesWord: 'Серия N',
episodeWord: 'Эпизод N',
seasonEpisode: 'SxxEyy',
afterDash: 'после тире',
firstNumber: 'первое число',
},
regexClear: 'сбросить',
manualAlready: 'уже в библиотеке',
manualRoot: 'корень manual/',
manualRecognized: 'Распознано: {{count}} из {{total}}',
manualCleanupHint:
'Файлы уйдут из папки, спутники (субтитры, nfo) и опустевший каталог будут удалены.',
manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.',
manualShow: 'Шоу',
manualDetected: 'Определено по имени релиза — проверьте и поправьте, если не то.',
manualPickShow: 'Выберите шоу',
manualImport: 'Забрать в шоу',
manualImported: 'Импортировано файлов: {{count}}',
uploadToShow: 'Загрузить в шоу',
toShowTitle: 'Загрузить и добавить в шоу',
autoDetectHint:
'Каждый файл привяжется к шоу, чьё оригинальное (или отображаемое) название есть в имени релиза, напр. «The.Simpsons.S33E01…» → The Simpsons.',
toShowLibrary: 'В библиотеку',
toShowSeason: 'Сезон (вручную)',
toShowAuto: 'авто',
toShowRegex: 'Regex серии',
toShowRegexInvalid: 'некорректный regex',
toShowHint:
'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».',
toShowPreview: 'Что распознаем',
toShowMatched: 'шоу распознано у {{matched}} из {{total}}',
applyToAll: 'Задать всем…',
toShowUnknown: '—',
toShowConfirm: 'Загрузить и добавить',
uploadedCount: 'Загружено файлов: {{count}}',
uploadingCount: 'Загрузка {{done}}/{{total}}',
cancelAll: 'Отменить все загрузки',
skippedDuplicates: 'Пропущено дубликатов: {{count}}',
filterActive: 'Активные',
filterAll: 'Все',
name: 'Файл',
status: 'Статус',
duration: 'Длительность',
resolution: 'Разрешение',
processingTime: 'Время обработки',
empty: 'Пока нет загруженных файлов',
statuses: {
Pending: 'В очереди',
Processing: 'Обработка',
Ready: 'Готов',
Failed: 'Ошибка',
},
stats: {
queued: 'Сейчас в очереди',
queuedShort: 'В очереди',
processing: 'Сейчас в обработке',
processingShort: 'В обработке',
average: 'Среднее время обработки (по недавним)',
averageShort: 'Ср. время',
},
},
gallery: {
title: 'Галерея',
upload: 'Загрузить',
empty: 'В этой категории пока нет изображений',
pickHint: 'Выберите изображение или загрузите новое',
browseHint: 'Все изображения приложения по категориям',
sort: {
newest: 'Сначала новые',
oldest: 'Сначала старые',
nameAsc: 'Имя: А–Я',
nameDesc: 'Имя: Я–А',
},
categories: {
Library: 'Библиотека',
ShowPoster: 'Постеры шоу',
EpisodeStill: 'Кадры серий',
BumperBackground: 'Фоны заставок',
},
},
shows: {
title: 'Шоу',
name: 'Название',
originalName: 'Оригинальное название (eng)',
kind: 'Тип',
kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' },
audience: 'Категория',
audiences: {
Kids: 'Детское',
Family: 'Семейное',
Teen: 'Подростковое',
General: 'Общее',
Adult: 'Взрослое',
},
seasons: 'Сезоны',
loadedSeasons: 'Загружены сезоны',
genre: 'Жанр',
allGenres: 'Все жанры',
genresEmpty: 'Жанры не проставлены',
genresEdit: 'Жанры',
genresHint:
'Отметьте жанры шоу. Основной показывается в списке; в отборе контента участвуют все.',
genrePrimary: 'основной',
inCollections: 'Входит в коллекции',
episodes: 'Серии',
episode: 'Серия',
noEpisodes: 'Серий пока нет',
filterAssets: 'Фильтр по имени, напр. Family.Guy.S16',
selectAll: 'Выбрать все',
deselectAll: 'Снять все',
addSelected: 'Добавить выбранные',
noMatches: 'Нет подходящих файлов',
addedCount: 'Добавлено серий: {{count}}',
candidatesTruncated:
'Показаны не все файлы (слишком много готовых ассетов) — уточните фильтр, чтобы увидеть остальные.',
},
channels: {
number: 'Номер',
numberPlaceholder: 'не задан',
utcOffset: 'Часовой пояс, ч',
utcOffsetHint:
'Целыми часами: 3 — Москва, 0 — UTC, −5 — Нью-Йорк. В этом времени задаётся вся сетка и показывается расписание.',
dayStart: 'Начало вещательных суток',
dayStartHint: 'Ночной блок до этого времени относится к предыдущему дню.',
disabled: 'выключен',
layers: 'Слои',
layersHint: 'Побеждает слой с большим приоритетом. Фоновый лежит под всеми и закрывает дыры.',
background: 'фон',
newLayerName: 'Новый слой',
addSlotHere: 'Добавить слот',
newSlot: 'Новый слот',
editSlot: 'Слот',
slotTitle: 'Название блока',
slotStart: 'Начало',
slotDuration: 'Бюджет, мин',
weekday: 'День недели',
everyDay: 'каждый день',
daypart: 'Дейпарт',
slotKind: 'Тип слота',
group: 'Группа',
pickGroup: 'выберите группу',
strategy: 'Стратегия',
cooldownDays: 'Остывание, дней',
cooldownHint: 'Не брать то, что уже выходило за этот срок.',
blockMode: 'Блок',
blockValue: 'Значение',
overflow: 'Если не помещается',
overflowHint: 'Как поступить с элементом, который не влезает в бюджет слота.',
repeatDaysAgo: 'Суток назад',
repeatTime: 'Время',
repeatDuration: 'Длительность, мин',
anchor: 'Якорь',
anchorHint:
'Якорь держит старт жёстко: через него не переносят то, что не влезает. Округление мягкое — сдвигает старт до круглого времени, если это укладывается в допуск.',
maxDrift: 'Допуск, мин',
snap: 'Округление',
snapOff: 'нет',
bumperConditionsHint:
'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.',
resizeSlot: 'Потянуть за край — длительность',
copyDay: 'Копировать день',
copyDayFrom: 'Копировать {{day}} в:',
copy: 'Копировать',
layerVisible: 'Показывать слой',
layerName: 'Название слоя',
layerApplicability: 'Когда действует',
applicabilityHint:
'Разделы объединяются по ИЛИ: слой действует, если дата подходит хотя бы под одно условие. Ничего не заполнено — действует всегда.',
applicabilityWeekdays: 'Дни недели',
applicabilityDateRanges: 'Диапазоны дат',
applicabilityAnnual: 'Ежегодно (месяц / день)',
applicabilityDates: 'Конкретные даты',
applicabilityNone: 'не задано',
showForDate: 'Сетка на дату',
allDates: 'Все слои',
tabs: {
grid: 'Сетка',
rules: 'Правила',
junctions: 'Стыки',
bumpers: 'Заставки',
viewer: 'Зритель',
settings: 'Настройки',
air: 'Эфир',
},
noTemplate: 'Сетка канала не загрузилась',
createTemplate: 'Создать сетку',
createTemplateHint:
'Появится пустая сетка с фоновым слоем — дальше добавляйте слои и слоты.',
rules: 'Правила отбора',
rulesHint:
'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.',
audienceWindows: 'Детское время',
noAudienceWindows: 'Окон нет — возраст ничем не ограничен.',
audienceWindowsHint:
'В окне в эфир идёт контент не строже выбранной категории. Окно может переходить через полночь. Контент без категории не отсекается.',
from: 'С',
to: 'До',
maxAudience: 'Не строже',
repeatLimit: 'Потолок повторов',
repeatWindowDays: 'Окно, суток',
repeatMax: 'Не чаще, раз',
repeatLimitHint:
'Считается по уже записанной ленте. Если потолка достигли все кандидаты, слот всё равно заполняется: пустой эфир хуже раннего повтора.',
preview: 'Предпросмотр',
previewHide: 'Свернуть предпросмотр',
previewHint: 'Прогон по текущим правилам: ничего не пишется, курсоры слотов не двигаются.',
previewDays_one: '{{count}} сутки',
previewDays_few: '{{count}} суток',
previewDays_many: '{{count}} суток',
previewTabs: { programme: 'Программа', tape: 'Лента', problems: 'Проблемы' },
noProblems: 'Проблем нет',
andMore: 'и ещё {{count}}',
heatmap: 'Повторы: шоу × сутки',
heatmapTotal: 'всего',
issues: 'Проверки: ошибок {{errors}}, предупреждений {{warnings}}',
goToSlot: 'к слоту',
issueKinds: {
GroupEmpty: 'Пустая группа',
GroupTooSmall: 'Мало контента',
GridGap: 'Дыра в сетке',
SlotOverlap: 'Слоты пересекаются',
CooldownUnreachable: 'Недостижимое остывание',
AudienceConflict: 'Возрастной конфликт',
GroupMissing: 'Группа не выбрана',
},
viewer: 'Как выглядит у зрителя',
viewerHint:
'Оверлеи рисуются поверх картинки на клиенте — видео не перекодируется. Всё по умолчанию выключено.',
logo: 'Логотип',
noLogo: 'нет',
pickLogo: 'Выбрать логотип',
logoCorner: 'Угол',
logoOpacity: 'Прозрачность',
corners: {
TopLeft: 'Слева вверху',
TopRight: 'Справа вверху',
BottomLeft: 'Слева внизу',
BottomRight: 'Справа внизу',
},
showClock: 'Показывать часы',
analogFilter: 'Аналоговый фильтр',
analogFilterHint: 'Сила 0..1. Ноль — выключен; переборщить очень легко.',
whyHere: 'Почему это здесь',
priority: 'приоритет',
traceLayer: 'Слой',
traceSlot: 'Слот',
traceGroup: 'Группа',
traceCollection: 'Коллекция',
traceStrategy: 'Стратегия',
traceJunction: 'Врезки',
traceDrift: 'дрейф {{minutes}} мин',
traceSnapped: 'старт округлён',
traceCooldown: 'остывание {{days}} дн.',
traceCandidates: 'кандидатов после остывания: {{count}}',
diffSummary: 'Затронет {{total}} записей, изменятся {{changed}}',
diffSoon: 'В ближайшие сутки изменится записей: {{count}}',
diffNoChanges: 'Эфир не изменится',
copyTemplate: 'Скопировать сетку с канала',
pickSourceChannel: 'Выберите канал-источник',
copyHere: 'Скопировать сюда',
copyTemplateHint:
'Слои, слоты, стыки и правила выбранного канала заменят сетку этого канала. Группы общие и не копируются.',
copyTemplateConfirm:
'Текущая сетка этого канала будет заменена сеткой выбранного канала. Продолжить?',
templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}',
copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками',
postChecks: 'Пост-проверки',
breakLimit: 'Потолок врезок в час, мин',
genreShare: 'Потолок доли жанра за сутки, %',
fallbackShare: 'Потолок доли фона, %',
postChecksHint:
'Пост-проверки считаются по готовой ленте и только предупреждают — ничего не переигрывается.',
previewKinds: {
Program: 'Программа',
Fallback: 'Фон',
SignOff: 'Конец вещания',
Ad: 'Реклама',
Promo: 'Анонс',
Bumper: 'Заставка',
},
previewLoad: 'Врезки по часам, пик — {{peak}} мин',
junctions: 'Стыки',
junctionsHint:
'Что играет между программами: реклама, анонсы, заставки. Слот может взять свой стык, иначе берётся стык по умолчанию.',
defaultJunction: 'Стык по умолчанию',
noJunction: 'без стыка',
newJunctionName: 'Новый стык',
addJunctionElement: '+ врезка',
junctionEmpty: 'пусто',
junctionFrom: 'конец',
junctionTo: 'начало',
junctionElement: 'Врезка',
junctionKind: 'Тип',
junctionKinds: {
Ad: 'Реклама',
Promo: 'Анонс',
Bumper: 'Заставка',
Filler: 'Заполнитель',
},
junctionAmountMode: 'Чем меряется',
junctionAmountModes: { Count: 'Единиц', Duration: 'Минут' },
junctionCount: 'Сколько единиц',
junctionMinutes: 'Сколько минут',
junctionAmountHint:
'В смешанной группе (ролики и готовые блоки) считайте минутами: одна «единица» там — то ли ролик, то ли блок.',
junctionRequired: 'Обязательная — не выбрасывать при нехватке времени',
junctionOnlyOnChange: 'Только при смене шоу',
junctionMinInterval: 'Не чаще, чем раз в, мин',
junctionMinIntervalHint: '0 — без ограничения.',
junctionBetween: 'Стык внутри слота',
junctionAfter: 'Стык после слота',
junctionDefault: 'по умолчанию',
bumperTemplate: 'Блок заставки',
pickBumperTemplate: 'выберите блок',
minutesShort: ' мин',
pendingChanges: 'Правила изменены — эфир идёт по старым.',
apply: 'Применить',
applied: 'Эфир пересобран, записей: {{count}}',
weekdays: {
0: 'Вс',
1: 'Пн',
2: 'Вт',
3: 'Ср',
4: 'Чт',
5: 'Пт',
6: 'Сб',
},
dayparts: { Morning: 'Утро', Day: 'День', Prime: 'Прайм', Night: 'Ночь' },
slotKinds: { Content: 'Контент', Repeat: 'Повтор', SignOff: 'Конец вещания' },
blockModes: { Count: 'Единиц', Duration: 'Минут', FillSlot: 'Весь слот' },
overflows: {
ContinueNext: 'Доиграть в следующий раз',
ExtendSlot: 'Растянуть слот',
SkipIfNotFits: 'Не начинать',
},
strategies: {
Sequential: 'По порядку',
RandomWithCooldown: 'Случайно с остыванием',
Fixed: 'Фиксированный элемент',
},
warnings: {
SlotEmpty: 'Слот не дал контента',
DriftExceeded: 'Старт ушёл за допуск',
CooldownExhausted: 'Остывание отсекло всех',
RepeatSourceEmpty: 'Нечего повторять',
FallbackEmpty: 'Нечем закрыть паузы',
CandidatesFiltered: 'Возрастной потолок отсёк всех',
BreakLimitExceeded: 'Врезок в часе больше потолка',
GenreShareExceeded: 'Доля жанра выше нормы',
FallbackShareExceeded: 'Фона в эфире больше нормы',
},
title: 'Каналы',
name: 'Название',
slug: 'Slug',
state: 'Состояние',
enabled: 'В эфире',
enabledLabel: 'Канал в эфире',
settings: 'Настройки',
bumpers: 'ТВ-заставки',
bumpersLabel: 'Заставки на переходах',
bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу',
bumperSelection: 'Выбор блока',
bumperSelectionRandom: 'Случайно',
bumperSelectionWeighted: 'Случайно взвешенный',
bumperSelectionAlwaysFirst: 'Всегда первый',
bumperFont: 'Шрифт',
bumperFontSans: 'Гротеск',
bumperFontSerif: 'Антиква',
bumperNowLabel: 'Подпись «Сейчас»',
bumperNextLabel: 'Подпись «Далее»',
bumperBg: 'Фон (цвет 1)',
bumperBg2: 'Фон (цвет 2)',
bumperAccent: 'Акцент',
bumperText: 'Текст',
bumperTemplates: 'Блоки заставок',
bumperTemplatesHint:
'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.',
bumperAddTemplate: 'Добавить блок',
bumperTemplateName: 'Название',
bumperVariants: 'Подблоки (текст)',
bumperVariantsHint:
'Разный текст на одной музыке и оформлении блока. Правило показа — у каждого подблока своё.',
bumperAddVariant: 'Добавить текст',
bumperVariantName: 'Название',
bumperTextKind: 'Режим текста',
bumperKindNowNext: 'Сейчас / Далее',
bumperKindFree: 'Свободный текст',
bumperLine1: 'Строка 1',
bumperLine2: 'Строка 2',
bumperTrigger: 'Показывать',
bumperTriggerOnShowChange: 'При смене шоу',
bumperTriggerBetweenEpisodes: 'Между сериями',
bumperTriggerBoth: 'Оба',
bumperVariantWeight: 'Вес',
bumperVariantWeightHint: 'Для стратегии «случайно взвешенный»: чем больше — тем чаще (0 — не выбирается)',
bumperDefault: 'по умолчанию',
bumperSeconds: 'с',
bumperDefaultDuration: '≈8 с (джингл)',
bumperAudio: 'Звук',
bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл',
bumperPreview: 'Отрендерить примеры',
bumperPreviewRendering: 'Рендерим…',
bumperPreviewHint: 'Примеры всех подблоков со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
bumperBackground: 'Фон-картинка',
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
bumperBackgroundPick: 'Выбрать из галереи',
bumperFileLoaded: 'загружено',
bumperFileDefault: 'по умолчанию',
bumperUpload: 'Загрузить',
bumperReset: 'Сбросить',
filler: 'Заглушка',
noFiller: 'Без заглушки',
noSchedule: 'Расписание ещё не построено',
},
maintenance: {
title: 'Обслуживание',
warning: 'Операции необратимы — удаляют данные и файлы навсегда.',
clearMedia: 'Очистить все медиа',
clearMediaHint: 'Удалит все загруженные файлы и их нарезку, а также расписание каналов.',
confirmClearMedia: 'Удалить ВСЕ медиа-файлы безвозвратно?',
clearShowMedia: 'Удалить медиа шоу',
clearShowMediaHint: 'Удалит файлы всех серий выбранного шоу и очистит его серии.',
confirmClearShowMedia: 'Удалить все медиа выбранного шоу безвозвратно?',
pickShow: 'Выберите шоу',
deleteShows: 'Удалить все шоу',
deleteShowsHint: 'Удалит все шоу и их серии. Медиа-файлы останутся в библиотеке.',
confirmDeleteShows: 'Удалить ВСЕ шоу безвозвратно?',
doneCount: 'Удалено: {{count}}',
},
settings: {
title: 'Настройки',
registration: 'Регистрация',
registrationHint:
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
registrationLabel: 'Разрешить регистрацию на сайте',
preferredAudio: 'Предпочитаемые озвучки',
channelNumbers: 'Переключение каналов по номерам',
channelNumbersHint:
'Зритель переключает каналы стрелками, как на телевизоре. Сетка каналов остаётся всегда.',
preferredAudioHint:
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
},
metadata: {
title: 'Метаданные',
pickPoster: 'Из галереи',
name: 'Название',
originalName: 'Оригинальное название (eng)',
originalNamePlaceholder: 'Например: Family Guy',
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
sourceLabel: 'Источник',
searchBtn: 'Искать',
nothingFound: 'Ничего не найдено',
apply: 'Применить',
applied: 'Метаданные применены',
overview: 'Описание',
year: 'Год',
clear: 'Очистить',
noPoster: 'Нет постера',
refreshEpisodes: 'Обновить серии',
refreshing: 'Обновляем…',
refreshedCount: 'Обновлено серий: {{count}}',
findMissing: 'Найти отсутствующие серии',
missingTitle: 'Отсутствующие серии',
missingNoSeasons: 'В шоу нет загруженных серий с распознанными номерами.',
seasonN: 'Сезон {{n}}',
loadedOf: 'загружено {{loaded}} из {{total}}',
missingUnknown: 'Источник не отдал число серий этого сезона.',
missingNone: 'Все серии на месте.',
missingList: 'Не хватает',
},
},
}
+17
View File
@@ -0,0 +1,17 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { toast } from '@/shared/ui/toast-store'
/**
* Показывает ошибку запроса тостом: у `problem+json` берём человеческий `detail`, всё остальное
* (сеть, неожиданный статус) закрываем общей фразой. Подходит прямо в `onError` мутации.
*/
export function useApiError() {
const { t } = useTranslation()
return useCallback(
(error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
[t],
)
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { type ButtonHTMLAttributes, forwardRef } from 'react' import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn' import { cn } from '@/shared/lib/cn'
export const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium uppercase tracking-wide transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium uppercase tracking-wide transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
{ {
variants: { variants: {
@@ -25,7 +25,7 @@ export const buttonVariants = cva(
}, },
) )
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & { asChild?: boolean } VariantProps<typeof buttonVariants> & { asChild?: boolean }
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
+1 -1
View File
@@ -6,7 +6,7 @@ import { cn } from '@/shared/lib/cn'
export const Dialog = DialogPrimitive.Root export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger export const DialogTrigger = DialogPrimitive.Trigger
export const DialogOverlay = forwardRef< const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>, ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react' import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/shared/lib/cn' import { cn } from '@/shared/lib/cn'
export type SortState = { key: string; desc: boolean } type SortState = { key: string; desc: boolean }
/** /**
* Состояние сортировки таблицы. Клик по новому столбцу — по возрастанию; повторный клик по тому же — * Состояние сортировки таблицы. Клик по новому столбцу — по возрастанию; повторный клик по тому же —
+1 -1
View File
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react' import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
export type ToastVariant = 'default' | 'success' | 'error' type ToastVariant = 'default' | 'success' | 'error'
export type ToastItem = { id: number; message: string; variant: ToastVariant } export type ToastItem = { id: number; message: string; variant: ToastVariant }
let nextId = 1 let nextId = 1
+1 -1
View File
@@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
export type Theme = 'light' | 'dark' | 'system' type Theme = 'light' | 'dark' | 'system'
type ThemeContextValue = { type ThemeContextValue = {
theme: Theme theme: Theme