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-media/
# Отчёты покрытия (артефакт dotnet test / CI)
coverage/
TestResults/
+3 -1
View File
@@ -117,7 +117,9 @@
Backend (из `backend/`):
```bash
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; без него пропускаются:
dotnet test tests/TeleWave.Integration.Tests
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)
[![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)
[![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** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере, админ управляет каналами и пользователями.
@@ -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.RegularExpressions;
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
@@ -13,11 +12,6 @@ namespace TeleWave.Api.Endpoints;
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
public static partial class ChannelEndpoints
{
private static readonly Regex BumperSegmentFileName = new(
@"^seg\d{1,6}\.ts$",
RegexOptions.Compiled
);
private static async Task<IResult> AddBumperTemplate(
Guid id,
AddBumperTemplateBody body,
@@ -229,16 +223,7 @@ public static partial class ChannelEndpoints
)
{
var previewId = BumperPreview.AssetId(variantId);
string indexPath;
try
{
indexPath = paths.SegmentPath(previewId, "index.m3u8");
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(indexPath))
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
return Results.NotFound();
var baseUrl =
@@ -265,20 +250,11 @@ public static partial class ChannelEndpoints
MediaPathResolver paths
)
{
if (!BumperSegmentFileName.IsMatch(file))
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
var previewId = BumperPreview.AssetId(variantId);
string path;
try
{
path = paths.SegmentPath(previewId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
if (SegmentFiles.TryResolveExisting(paths, previewId, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
@@ -1,6 +1,5 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
@@ -17,7 +16,6 @@ namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
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)
{
@@ -143,20 +141,10 @@ public static class StreamingEndpoints
{
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized();
if (!SegmentFileName.IsMatch(file))
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
string path;
try
{
path = paths.SegmentPath(assetId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
return Results.NotFound();
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.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using TeleWave.Application.Broadcast.Bumpers;
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;
@@ -26,6 +31,19 @@ public static class DependencyInjection
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;
}
@@ -16,11 +16,6 @@ public static class GroupErrors
"Шоу или коллекция не найдены."
);
public static readonly Error ElementAlreadyAdded = Error.Conflict(
"Groups.ElementAlreadyAdded",
"Этот элемент уже входит в группу."
);
public static readonly Error FilterNotSet = Error.Validation(
"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 })
.ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds.Concat(partsByCollection.Select(p => p.ShowId)).Distinct().ToList();
var audiences = await dbContext
.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 })
.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.Scheduling;
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.Domain.Broadcast.Scheduling;
using TeleWave.Infrastructure.Broadcast;
@@ -113,15 +109,6 @@ public static class DependencyInjection
services.AddScoped<ISiteSettings, SiteSettings>();
services.AddScoped<DbInitializer>();
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);
AddBroadcast(services, configuration);
@@ -1,143 +1,64 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
/// <summary>Захваченная на рендер заставка: спецификация собирается уже в самой работе.</summary>
internal sealed record BumperRenderJob(Guid AssetId);
/// <summary>
/// Асинхронно рендерит ТВ-заставки расписания: планировщик лишь создаёт ассет (Source=Generated) в
/// статусе Pending и кэш-строку <see cref="Domain.Broadcast.BumperAsset"/>, а сам ffmpeg крутится здесь,
/// вне тика планировщика и его транзакции. Источник истины — статус в БД (последовательно берём
/// следующий Pending c Source=Generated, помечаем Processing), поэтому рестарт/краш ничего не теряет
/// (прерванные Processing сбрасываются в Pending на старте). До готовности ассета плейлист отдаёт филлер.
/// вне тика планировщика и его транзакции. Захват работы и устойчивость к рестарту — в
/// <see cref="MediaClaimingBackgroundService{TJob}"/>. До готовности ассета плейлист отдаёт филлер.
///
/// Рендер строго последовательный: ffmpeg заставки короткий, а параллелить его смысла нет —
/// очередь разбирается быстрее, чем планировщик успевает её пополнять.
/// </summary>
public sealed class BumperRenderBackgroundService(
internal sealed class BumperRenderBackgroundService(
IBumperRenderQueue queue,
IServiceScopeFactory scopeFactory,
IBumperRenderer renderer,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions,
ILogger<BumperRenderBackgroundService> logger
) : BackgroundService
) : MediaClaimingBackgroundService<BumperRenderJob>(scopeFactory, logger)
{
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
protected override bool HandlesGenerated => true;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await ResetInterruptedAsync(stoppingToken);
protected override string LoopErrorMessage => "Ошибка цикла рендера заставок";
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Разбираем всю накопившуюся работу из БД.
while (!stoppingToken.IsCancellationRequested)
{
var assetId = await ClaimNextAsync(stoppingToken);
if (assetId is not { } id)
break;
await RenderClaimedAsync(id, stoppingToken);
}
protected override ValueTask WaitForWorkAsync(CancellationToken cancellationToken) =>
queue.WaitAsync(cancellationToken);
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);
}
}
}
protected override BumperRenderJob ToJob(MediaAsset asset) => new(asset.Id);
/// <summary>Сброс прерванных рестартом заставок (Generated 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
protected override async Task ProcessAsync(
BumperRenderJob job,
CancellationToken cancellationToken
)
.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
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var spec = await BuildSpecAsync(db, assetId, cancellationToken);
var spec = await WithScopeAsync<BumperSpecLoader, BumperRenderSpec?>(
loader => loader.LoadAsync(job.AssetId, cancellationToken)
);
if (spec is null)
{
await FailAsync(
assetId,
job.AssetId,
"Не удалось восстановить спецификацию заставки",
cancellationToken
);
return;
}
var render = await renderer.RenderAsync(assetId, spec, cancellationToken);
var render = await renderer.RenderAsync(job.AssetId, spec, cancellationToken);
var asset = await db.MediaAssets.FirstOrDefaultAsync(
a => a.Id == assetId,
cancellationToken
);
if (asset is null)
return;
await WithAssetAsync(
job.AssetId,
asset =>
asset.MarkReady(
render.Duration,
render.SegmentSeconds,
@@ -147,8 +68,9 @@ public sealed class BumperRenderBackgroundService(
"h264",
"aac",
render.RelativePath
),
cancellationToken
);
await db.SaveChangesAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -156,126 +78,8 @@ public sealed class BumperRenderBackgroundService(
}
catch (Exception ex)
{
logger.LogError(ex, "Рендер заставки {AssetId} провалился", assetId);
await FailAsync(assetId, ex.Message, CancellationToken.None);
logger.LogError(ex, "Рендер заставки {AssetId} провалился", job.AssetId);
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.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
@@ -9,16 +6,15 @@ using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
/// <summary>Захваченный на транскод ассет: расширение нужно ffmpeg и уже не требует БД.</summary>
internal sealed record MediaTranscodeJob(Guid AssetId, string Extension);
/// <summary>
/// Обработчик медиа: прогоняет ассеты через ffmpeg, до <see cref="MediaOptions.MaxParallelTranscodes"/>
/// файлов одновременно. Источник истины — статус в БД: единственный диспетчер последовательно и
/// атомарно захватывает следующий Pending (помечает Processing), поэтому два транскода никогда не
/// возьмут один ассет; сам транскод запускается в фоне с ограничением по числу слотов. Рестарт/краш
/// ничего не теряет — незавершённые подхватываются из БД (прерванные Processing на старте сбрасываются
/// в Pending). БД-контекст держится короткими отрезками (пометить статус), сам транскод идёт вне
/// scope, чтобы не держать соединение минутами.
/// файлов одновременно. Захват работы, устойчивость к рестарту и параллелизм — в
/// <see cref="MediaClaimingBackgroundService{TJob}"/>; здесь только сам транскод.
/// </summary>
public sealed class MediaProcessingBackgroundService(
internal sealed class MediaProcessingBackgroundService(
IMediaProcessingQueue queue,
IServiceScopeFactory scopeFactory,
MediaPathResolver paths,
@@ -26,167 +22,55 @@ public sealed class MediaProcessingBackgroundService(
IOptions<StorageOptions> storageOptions,
IOptions<MediaOptions> mediaOptions,
ILogger<MediaProcessingBackgroundService> logger
) : BackgroundService
) : MediaClaimingBackgroundService<MediaTranscodeJob>(scopeFactory, logger)
{
// Периодически перепроверяем БД, даже если сигнал не пришёл — страховка на любой случай.
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
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 — их не берём.
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;
protected override bool HandlesGenerated => false;
asset.MarkProcessing();
await db.SaveChangesAsync(cancellationToken);
return (asset.Id, asset.OriginalExtension);
}
protected override int MaxParallel => Math.Max(1, mediaOptions.Value.MaxParallelTranscodes);
/// <summary>Обрабатывает уже захваченный (Processing) ассет: транскод → Ready/Failed.</summary>
private async Task ProcessClaimedAsync(
Guid assetId,
string extension,
protected override string LoopErrorMessage => "Ошибка цикла обработки медиа";
protected override void OnStarting() => paths.EnsureDirectories();
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
)
{
try
{
var result = await processor.ProcessAsync(assetId, extension, cancellationToken);
await CompleteAsync(assetId, result, cancellationToken);
var result = await processor.ProcessAsync(job.AssetId, job.Extension, 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)
DeleteOriginal(assetId, extension);
DeleteOriginal(job.AssetId, job.Extension);
logger.LogInformation(
"Ассет {AssetId} обработан: {Segments} сегментов, {Seconds:0.#}с",
assetId,
job.AssetId,
result.SegmentCount,
result.Duration.TotalSeconds
);
@@ -197,56 +81,11 @@ public sealed class MediaProcessingBackgroundService(
}
catch (Exception ex)
{
logger.LogError(ex, "Обработка ассета {AssetId} провалилась", assetId);
await FailAsync(assetId, ex.Message, CancellationToken.None);
logger.LogError(ex, "Обработка ассета {AssetId} провалилась", job.AssetId);
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)
{
var original = paths.OriginalPath(assetId, extension);
@@ -1,46 +1,31 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronLeft, Plus, Send } from 'lucide-react'
import { ChevronLeft, Send } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listAllMedia } from '@/features/admin/media/api'
import { HttpError } from '@/shared/api/client'
import type { GridLayerDto, SlotDto } from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { cn } from '@/shared/lib/cn'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
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 {
applyChannelTemplate,
copyTemplateTo,
createChannelTemplate,
createLayer,
createSlot,
deleteLayer,
getChannel,
getChannelTemplate,
getSchedule,
listChannels,
toSlotBody,
updateLayer,
updateSlot,
} from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { GridTab } from './components/GridTab'
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 { SchedulePreview } from './components/SchedulePreview'
import { SettingsCard } from './components/SettingsCard'
import { TemplateIssues } from './components/TemplateIssues'
import { TemplatePreview } from './components/TemplatePreview'
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
@@ -49,42 +34,31 @@ type ChannelTab = (typeof TABS)[number]
export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation()
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 [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [copyFromChannel, setCopyFromChannel] = useState('')
const [tab, setTab] = useState<ChannelTab>('settings')
const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId],
queryKey: qk.channels.detail(channelId),
queryFn: () => getChannel(channelId),
})
const { data: template, error: templateError } = useQuery({
queryKey: ['admin', 'channels', channelId, 'template'],
queryKey: qk.channels.template(channelId),
queryFn: () => getChannelTemplate(channelId),
})
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready', 'all'],
queryKey: qk.media.ready,
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
})
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)),
})
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] })
void queryClient.invalidateQueries({ queryKey: qk.channels.detail(channelId) })
}
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
const onError = useApiError()
const applyMutation = useMutation({
mutationFn: () => applyChannelTemplate(channelId),
@@ -99,142 +73,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
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>
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 (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -289,187 +129,15 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
/>
)}
{/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */}
{tab === 'grid' && !template && (
<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
{tab === 'grid' && (
<GridTab
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)}
templateError={templateError}
onChanged={invalidate}
onError={onError}
/>
)}
</div>
</div>
</CardContent>
</Card>
)}
{tab === 'rules' &&
(template ? (
@@ -504,15 +172,6 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</Card>
)}
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
onClose={() => setApplicabilityLayer(null)}
onChanged={invalidate}
onError={onError}
/>
)}
{applyOpen && (
<ApplyDialog
channelId={channelId}
@@ -2,11 +2,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
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 { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { createChannel, listChannels } from './api'
function slugify(value: string) {
@@ -22,10 +22,9 @@ export function ChannelsPanel() {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const { data, isLoading } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'channels'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const { data, isLoading } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.channels.all })
const onError = useApiError()
const createMutation = useMutation({
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 })
}
export type ChannelSettingsBody = {
type ChannelSettingsBody = {
name: string
isEnabled: boolean
bumpersEnabled: boolean
@@ -230,7 +230,7 @@ export function reorderJunction(junctionId: string, elementIdsInOrder: string[])
})
}
export type BumperTemplateStyleBody = {
type BumperTemplateStyleBody = {
name: string
backgroundColor: string
backgroundColor2: string
@@ -297,7 +297,7 @@ export function uploadBumperTemplateAudio(id: string, templateId: string, file:
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
export type BumperVariantBody = {
type BumperVariantBody = {
name: string
kind: BumperTextKind
nowLabel: string
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import {
Dialog,
@@ -33,7 +34,7 @@ export function ApplyDialog({
}) {
const { t } = useTranslation()
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'diff'],
queryKey: qk.channels.diff(channelId),
queryFn: () => getApplyDiff(channelId),
staleTime: 0,
gcTime: 0,
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { qk } from '@/shared/api/query-keys'
import { useTranslation } from 'react-i18next'
import {
Dialog,
@@ -24,7 +25,7 @@ export function EntryTraceDialog({
}) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: ['admin', 'entries', entryId, 'trace'],
queryKey: qk.entries.trace(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,
JunctionElementKind,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import {
Dialog,
@@ -53,7 +54,7 @@ export function JunctionElementDialog({
}) {
const { t } = useTranslation()
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 }))
@@ -12,6 +12,7 @@ import type {
JunctionTemplateDto,
ScheduleTemplateDto,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
@@ -79,10 +80,10 @@ export function JunctionsCard({
const [newName, setNewName] = useState('')
const { data: junctions } = useQuery({
queryKey: ['admin', 'channels', channel.id, 'junctions'],
queryKey: qk.channels.junctions(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({
mutationFn: () => createJunction(channel.id, newName.trim()),
@@ -3,7 +3,6 @@ import { Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGroups } from '@/features/admin/groups/api'
import { HttpError } from '@/shared/api/client'
import type {
Daypart,
OverflowPolicy,
@@ -12,10 +11,11 @@ import type {
SlotKind,
SlotStrategyType,
} 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 { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import {
createSlot,
deleteSlot,
@@ -83,14 +83,13 @@ export function SlotInspector({
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
}, [draft])
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const { data: junctions } = useQuery({
queryKey: ['admin', 'channels', channelId, 'junctions'],
queryKey: qk.channels.junctions(channelId),
queryFn: () => listJunctions(channelId),
})
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const save = useMutation({
mutationFn: async () => {
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { getTemplateIssues } from '../api'
@@ -20,7 +21,7 @@ export function TemplateIssues({
}) {
const { t } = useTranslation()
const { data: issues } = useQuery({
queryKey: ['admin', 'channels', channelId, 'issues'],
queryKey: qk.channels.issues(channelId),
queryFn: () => getTemplateIssues(channelId),
})
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { Eye } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
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 { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'preview', days],
queryKey: qk.channels.preview(channelId, days),
queryFn: () => previewTemplate(channelId, days),
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`
}
/** Минуты суток → «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 во времени канала. Сетка задаётся в нём, поэтому предпросмотр показывается так же:
* локальное время админа тут только запутало бы.
@@ -6,13 +6,13 @@ import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
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 { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import {
addCollectionShow,
getCollection,
@@ -32,16 +32,15 @@ export function CollectionDetail({ collectionId }: { collectionId: string }) {
const [description, setDescription] = useState<string | null>(null)
const { data: collection, isLoading } = useQuery({
queryKey: ['admin', 'collections', collectionId],
queryKey: qk.collections.detail(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 = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
void queryClient.invalidateQueries({ queryKey: qk.collections.all })
}
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const saveMutation = useMutation({
mutationFn: () =>
@@ -3,11 +3,11 @@ import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { Input } from '@/shared/ui/input'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createCollection, deleteCollection, listCollections } from './api'
export function CollectionsPanel() {
@@ -17,13 +17,12 @@ export function CollectionsPanel() {
const { sort, toggle } = useTableSort('name', false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'collections'],
queryKey: qk.collections.all,
queryFn: listCollections,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.collections.all })
const onError = useApiError()
const createMutation = useMutation({
mutationFn: () => createCollection({ name: name.trim() }),
@@ -5,8 +5,9 @@ import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
@@ -19,7 +20,6 @@ import {
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createGenre, deleteGenre, listGenres, updateGenre } from './api'
const createSchema = z.object({
@@ -53,7 +53,7 @@ export function GenresPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: genres, isLoading } = useQuery({
queryKey: ['admin', 'genres'],
queryKey: qk.genres.all,
queryFn: listGenres,
})
const { sort, toggle } = useTableSort('sortOrder', false)
@@ -64,9 +64,8 @@ export function GenresPanel() {
showCount: (g) => g.showCount,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'genres'] })
const reportError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.genres.all })
const reportError = useApiError()
const createMutation = useMutation({ mutationFn: createGenre, onSuccess: invalidate })
const updateMutation = useMutation({
@@ -3,8 +3,9 @@ import { Link } from '@tanstack/react-router'
import { ChevronLeft, GripVertical, Search, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
@@ -36,7 +37,7 @@ export function GroupDetail({ groupId }: { groupId: string }) {
const [dragged, setDragged] = useState<string | null>(null)
const { data: group, isLoading } = useQuery({
queryKey: ['admin', 'groups', groupId],
queryKey: qk.groups.detail(groupId),
queryFn: () => getGroup(groupId),
})
@@ -46,10 +47,9 @@ export function GroupDetail({ groupId }: { groupId: string }) {
}, [group, filter])
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
void queryClient.invalidateQueries({ queryKey: qk.groups.all })
}
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const saveMutation = useMutation({
mutationFn: () =>
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
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 { SHOW_AUDIENCES } from '@/shared/api/types'
import { Input } from '@/shared/ui/input'
@@ -18,7 +19,7 @@ export function GroupFilterPanel({
onChange: (next: GroupFilter) => void
}) {
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 })
@@ -2,12 +2,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
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 { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createGroup, deleteGroup, listGroups } from './api'
import { DurationLabel } from './DurationLabel'
@@ -17,11 +17,10 @@ export function GroupsPanel() {
const [name, setName] = useState('')
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 onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.groups.all })
const onError = useApiError()
const createMutation = useMutation({
mutationFn: () => createGroup({ name: name.trim() }),
@@ -2,19 +2,19 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
type ImageOrder = 'new' | 'old' | 'az' | 'za'
export type ImagePick = { id: string; url: string }
type ImagePick = { id: string; url: string }
/**
* Внутренность галереи: вкладки по категориям, загрузка и удаление. Если передан <c>onSelect</c> —
@@ -35,13 +35,12 @@ export function GalleryBrowser({
const [active, setActive] = useState<ImageCategory>(category)
const fileInput = useRef<HTMLInputElement>(null)
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const [order, setOrder] = useState<ImageOrder>('new')
const { data: images, isLoading } = useQuery({
queryKey: ['admin', 'images', active],
queryKey: qk.images.byCategory(active),
queryFn: () => listImages(active),
})
@@ -62,7 +61,7 @@ export function GalleryBrowser({
return arr
}, [images, order])
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] })
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.images.byCategory(active) })
const pick = (id: string) => {
if (!onSelect) return
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { addGroupElements, createGroup, listGroups } from '@/features/admin/groups/api'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
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 [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 = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
void queryClient.invalidateQueries({ queryKey: qk.groups.all })
}
const createMutation = useMutation({
@@ -6,14 +6,14 @@ import { useTranslation } from 'react-i18next'
import { deleteCollection } from '@/features/admin/collections/api'
import { useUploadStore } from '@/features/admin/media/upload-store'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { HlsVideo } from '@/shared/ui/hls-video'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { BlockBuilder } from './BlockBuilder'
import { ClipGroupPanel } from './ClipGroupPanel'
import { listInterstitialBlocks, listInterstitials, mediaPreviewUrl } from './api'
@@ -34,19 +34,18 @@ export function InterstitialsPanel() {
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null)
const { data: clips, isLoading } = useQuery({
queryKey: ['admin', 'interstitials'],
queryKey: qk.interstitials.all,
queryFn: listInterstitials,
})
const { data: blocks } = useQuery({
queryKey: ['admin', 'interstitials', 'blocks'],
queryKey: qk.interstitials.blocks,
queryFn: listInterstitialBlocks,
})
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] })
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
}
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const renameMutation = useMutation({
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 { useTranslation } from 'react-i18next'
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
@@ -15,14 +16,13 @@ export function MaintenancePanel() {
const queryClient = useQueryClient()
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) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
// Затрагиваются медиа/шоу/каналы — сбрасываем все связанные кэши.
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 })
}
@@ -3,8 +3,9 @@ import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
@@ -57,10 +58,10 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const [collapsed, setCollapsed] = useState<string[]>([])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', 'manual'],
queryKey: qk.media.manual,
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 seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
@@ -146,6 +147,8 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
(f) => parsedByPath.get(f.relativePath)?.episode != null,
).length
const onError = useApiError()
const importMutation = useMutation({
mutationFn: () =>
importManualInbox(
@@ -167,12 +170,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
toast.error(`${failure.relativePath}: ${failure.reason}`)
setSelected([])
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
void queryClient.invalidateQueries({ queryKey: qk.media.all })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
if (result.failed.length === 0) onClose()
},
onError: (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
onError,
})
const toggle = (path: string) =>
@@ -2,14 +2,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, getMediaStats, listMedia } from './api'
import { ManualInboxDialog } from './ManualInboxDialog'
import { UploadToShowDialog } from './UploadToShowDialog'
@@ -63,7 +63,7 @@ export function MediaPanel() {
}
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: () =>
listMedia({
page,
@@ -80,7 +80,7 @@ export function MediaPanel() {
})
const { data: stats } = useQuery({
queryKey: ['admin', 'media', 'stats'],
queryKey: qk.media.stats,
queryFn: getMediaStats,
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
refetchInterval: (query) =>
@@ -101,9 +101,8 @@ export function MediaPanel() {
void refetch()
}, [activity, refetch])
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
const onError = useApiError()
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
@@ -30,7 +31,7 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
// Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение.
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 seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
PagedList,
} from '@/shared/api/types'
export type ListMediaParams = {
type ListMediaParams = {
page: number
pageSize: number
statuses?: MediaAssetStatus[]
@@ -1,4 +1,4 @@
export type ParseOptions = {
type ParseOptions = {
/** Ручной сезон — перебивает распознанный/дефолтный. */
seasonOverride?: number | null
/** Пользовательский regex для номера серии: 1 группа = серия, 2 группы = (сезон, серия). */
@@ -5,7 +5,7 @@
* («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 {
@@ -1,4 +1,5 @@
import { create } from 'zustand'
import { qk } from '@/shared/api/query-keys'
import { HttpError, refreshAccessToken } from '@/shared/api/client'
import { queryClient } from '@/shared/api/query-client'
import { importInterstitials } from '@/features/admin/interstitials/api'
@@ -31,7 +32,7 @@ type UploadStore = {
* <c>showId</c> — общий для всех файлов; <c>resolveShowId</c> — привязка на каждый файл (напр.
* автоопределение шоу по имени релиза). Приоритет у <c>resolveShowId</c>, затем общий <c>showId</c>.
*/
export type EnqueueOptions = {
type EnqueueOptions = {
showId?: string
resolveShowId?: (file: File) => string | undefined
/** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */
@@ -105,13 +106,13 @@ async function pump() {
if (created) {
patch(job.id, { status: 'done', percent: 100 })
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
void queryClient.invalidateQueries({ queryKey: qk.media.all })
// Привязка к шоу: добавляем свежий ассет серией (порядок — как в очереди).
if (job.showId) {
try {
await addEpisode(job.showId, created.id)
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
} catch {
toast.error(`${job.file.name}: не удалось добавить в шоу`)
}
@@ -119,7 +120,7 @@ async function pump() {
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
try {
await importInterstitials([created.id])
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] })
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
} catch {
toast.error(`${job.file.name}: не удалось завести ролик`)
}
@@ -5,7 +5,8 @@ import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
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 { Button } from '@/shared/ui/button'
import {
@@ -19,7 +20,6 @@ import {
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createRole, deleteRole, listRoles, updateRole } from './api'
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() {
const { t } = useTranslation()
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 sortedRoles = sortRows(roles ?? [], sort, {
name: (r) => r.name.toLowerCase(),
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({
mutationFn: (name: string) => createRole(name),
@@ -44,17 +45,13 @@ export function RolesPanel() {
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRole(id),
onSuccess: invalidate,
onError: (error) => {
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
},
onError,
})
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => updateRole(id, name),
onSuccess: invalidate,
onError: (error) => {
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
},
onError,
})
const [open, setOpen] = useState(false)
@@ -66,7 +63,7 @@ export function RolesPanel() {
reset()
setOpen(false)
} 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 { useEffect, useState } from 'react'
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
@@ -16,7 +17,7 @@ export function SettingsPanel() {
const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'settings'],
queryKey: qk.settings.all,
queryFn: getSiteSettings,
})
@@ -28,6 +29,8 @@ export function SettingsPanel() {
}
}, [data])
const onError = useApiError()
const save = useMutation({
mutationFn: () =>
updateSiteSettings({
@@ -37,10 +40,9 @@ export function SettingsPanel() {
}),
onSuccess: () => {
toast.success(t('settings.saved'))
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
void queryClient.invalidateQueries({ queryKey: qk.settings.all })
},
onError: (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
onError,
})
return (
@@ -3,8 +3,9 @@ import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
@@ -38,17 +39,16 @@ export function ShowDetail({ showId }: { showId: string }) {
const [epPage, setEpPage] = useState(1)
const { data: show, isLoading } = useQuery({
queryKey: ['admin', 'shows', showId],
queryKey: qk.shows.detail(showId),
queryFn: () => getShow(showId),
})
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready', 'all'],
queryKey: qk.media.ready,
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.detail(showId) })
const onError = useApiError()
const audienceMutation = useMutation({
mutationFn: (audience: ShowAudience) => setShowAudience(showId, audience),
@@ -3,12 +3,12 @@ import { Tag } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { toast } from '@/shared/ui/toast-store'
import { setShowGenres } from './api'
/**
@@ -22,19 +22,20 @@ export function ShowGenresField({ show, onChanged }: { show: ShowDto; onChanged:
const [primary, setPrimary] = useState<string | null>(null)
const { data: genres } = useQuery({
queryKey: ['admin', 'genres'],
queryKey: qk.genres.all,
queryFn: listGenres,
enabled: open,
})
const onError = useApiError()
const mutation = useMutation({
mutationFn: () => setShowGenres(show.id, selected, primary),
onSuccess: () => {
onChanged()
setOpen(false)
},
onError: (error) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
onError,
})
const openDialog = () => {
@@ -2,8 +2,9 @@ import { useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
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])
const { data: providers } = useQuery({
queryKey: ['admin', 'metadata', 'providers'],
queryKey: qk.metadata.providers,
queryFn: getMetadataProviders,
})
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const changed = () => onChanged()
const setPoster = useMutation({
@@ -2,15 +2,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
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 { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { listGenres } from '@/features/admin/genres/api'
import { createShow, deleteShow, listShows } from './api'
@@ -33,9 +33,9 @@ export function ShowsPanel() {
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
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({
queryKey: ['admin', 'shows', { genreId: genreFilter }],
queryKey: qk.shows.byGenre(genreFilter),
queryFn: () => listShows(genreFilter === 'all' ? undefined : genreFilter),
})
@@ -60,9 +60,8 @@ export function ShowsPanel() {
}, [data, query, sort])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.all })
const onError = useApiError()
const createMutation = useMutation({
mutationFn: () =>
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
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 { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
@@ -40,9 +41,9 @@ export function UsersPanel() {
const [newRoleId, setNewRoleId] = useState('')
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({
queryKey: ['admin', 'users', page, search, roleId, sort.key, sort.desc],
queryKey: qk.users.list(page, search, roleId, sort.key, sort.desc),
queryFn: () =>
listUsers({
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 unblockMutation = useMutation({ mutationFn: unblockUser, onSuccess: invalidate, onError })
+1 -1
View File
@@ -1,7 +1,7 @@
import { apiRequest } from '@/shared/api/client'
import type { CreatedIdResponse, PagedList, UserSummaryDto } from '@/shared/api/types'
export type ListUsersParams = {
type ListUsersParams = {
page: number
pageSize: number
search?: string
+4 -3
View File
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio, RotateCw } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { PublicEpgEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
@@ -28,10 +29,10 @@ export function AirPage() {
}
const { data: channels, isLoading } = useQuery({
queryKey: ['air', 'channels'],
queryKey: qk.air.channels,
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 currentChannel = channels?.find((c) => c.slug === selected)
@@ -116,7 +117,7 @@ export function AirPage() {
}, [selected, playerError])
const { data: epg } = useQuery({
queryKey: ['air', 'epg', selected],
queryKey: qk.air.epg(selected),
queryFn: () =>
getEpg(
selected!,
+2 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { LoginForm } from '@/features/auth/LoginForm'
import { fetchRegistrationStatus } from '@/features/auth/api'
import { useRequireGuest } from '@/features/auth/guards'
import { qk } from '@/shared/api/query-keys'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
export const Route = createFileRoute('/login')({ component: LoginPage })
@@ -14,7 +15,7 @@ function LoginPage() {
const navigate = useNavigate()
const { data: registration } = useQuery({
queryKey: ['auth', 'registration'],
queryKey: qk.auth.registration,
queryFn: fetchRegistrationStatus,
})
+2 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { RegisterForm } from '@/features/auth/RegisterForm'
import { fetchRegistrationStatus } from '@/features/auth/api'
import { useRequireGuest } from '@/features/auth/guards'
import { qk } from '@/shared/api/query-keys'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
export const Route = createFileRoute('/register')({ component: RegisterPage })
@@ -14,7 +15,7 @@ function RegisterPage() {
const navigate = useNavigate()
const { data: registration, isLoading } = useQuery({
queryKey: ['auth', 'registration'],
queryKey: qk.auth.registration,
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 MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated'
type MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated'
/** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */
export type ManualInboxFileDto = {
@@ -149,7 +149,7 @@ export type ShowSummaryDto = {
primaryGenre: string | null
}
export type ShowGenreDto = {
type ShowGenreDto = {
id: string
name: string
isPrimary: boolean
@@ -187,7 +187,7 @@ export type CollectionSummaryDto = {
createdAt: string
}
export type CollectionItemDto = {
type CollectionItemDto = {
showId: string
position: number
showName: string
@@ -208,7 +208,7 @@ export type CollectionDto = {
}
/** Коллекция, в которую входит шоу — для блока на экране шоу. */
export type ShowCollectionRefDto = {
type ShowCollectionRefDto = {
id: string
name: string
position: number
@@ -241,7 +241,7 @@ export type GroupSummaryDto = {
createdAt: string
}
export type GroupItemDto = {
type GroupItemDto = {
id: string
elementKind: GroupElementKind
elementId: string
@@ -288,7 +288,7 @@ export type MetadataCandidate = {
posterUrl: string | null
}
export type SeasonGapDto = {
type SeasonGapDto = {
season: number
expected: number | null
loaded: number
@@ -299,7 +299,7 @@ export type MissingEpisodesReport = {
seasons: SeasonGapDto[]
}
export type EpisodeDto = {
type EpisodeDto = {
id: string
mediaAssetId: string
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 BumperSelection = 'Random' | 'AlwaysFirst' | 'WeightedRandom'
export type BumperTextKind = 'NowNext' | 'Free'
@@ -414,9 +414,9 @@ export type SlotKind = 'Content' | 'Repeat' | 'SignOff'
export type SlotBlockMode = 'Count' | 'Duration' | 'FillSlot'
export type OverflowPolicy = 'ContinueNext' | 'ExtendSlot' | 'SkipIfNotFits'
export type SlotStrategyType = 'Sequential' | 'RandomWithCooldown' | 'Fixed'
export type CooldownFallback = 'OldestFirst' | 'IgnoreCooldown'
type CooldownFallback = 'OldestFirst' | 'IgnoreCooldown'
export type SlotStrategy = {
type SlotStrategy = {
type: SlotStrategyType
restartOnEnd: boolean
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 = {
id: string
@@ -536,7 +536,7 @@ export type ScheduleTemplateDto = {
layers: GridLayerDto[]
}
export type PlanningWarningKind =
type PlanningWarningKind =
| 'SlotEmpty'
| 'DriftExceeded'
| 'CooldownExhausted'
@@ -547,7 +547,7 @@ export type PlanningWarningKind =
| 'GenreShareExceeded'
| 'FallbackShareExceeded'
export type PlanningWarningDto = {
type PlanningWarningDto = {
kind: PlanningWarningKind
slotId: string | null
details: string
@@ -556,7 +556,7 @@ export type PlanningWarningDto = {
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
/** Что изменится в эфире, если применить правила сейчас (см. 6.6). */
export type ScheduleChangeDto = {
type ScheduleChangeDto = {
startsAtUtc: string
before: string | null
after: string | null
@@ -606,7 +606,7 @@ export type CopyTemplateResultDto = {
}
/** Проверки сетки по правилам, до генерации (см. 5.1). */
export type TemplateIssueKind =
type TemplateIssueKind =
| 'GroupEmpty'
| 'GroupTooSmall'
| '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 { 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',
{
variants: {
@@ -25,7 +25,7 @@ export const buttonVariants = cva(
},
)
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & { asChild?: boolean }
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 DialogTrigger = DialogPrimitive.Trigger
export const DialogOverlay = forwardRef<
const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
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'
export type ToastVariant = 'default' | 'success' | 'error'
type ToastVariant = 'default' | 'success' | 'error'
export type ToastItem = { id: number; message: string; variant: ToastVariant }
let nextId = 1
+1 -1
View File
@@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
export type Theme = 'light' | 'dark' | 'system'
type Theme = 'light' | 'dark' | 'system'
type ThemeContextValue = {
theme: Theme