diff --git a/.gitea/workflows/sonar.yml b/.gitea/workflows/sonar.yml new file mode 100644 index 0000000..19dcef7 --- /dev/null +++ b/.gitea/workflows/sonar.yml @@ -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 не задан — анализ пропущен." diff --git a/.gitignore b/.gitignore index 00a99ee..059f522 100644 --- a/.gitignore +++ b/.gitignore @@ -1,82 +1,86 @@ -# ---> VisualStudioCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -# ---> VisualStudio -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -.vs/ - -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -*.VisualState.xml -TestResult.xml -nunit-*.xml - -BenchmarkDotNet.Artifacts/ - -project.lock.json -project.fragment.lock.json -artifacts/ - -*.pdb -*.log -*.tlog - -# NuGet Packages -*.nupkg -*.snupkg -**/[Pp]ackages/* -!**/[Pp]ackages/build/ - -# Others -*.pfx -*.publishsettings - -# Node -node_modules/ -dist/ -dist-ssr/ - -# Local environment files (secrets) — keep .env.example, ignore real .env -.env -.env.local -.env.*.local - -# Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json) -.dev-media/ +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# ---> VisualStudio +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +.vs/ + +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +*.VisualState.xml +TestResult.xml +nunit-*.xml + +BenchmarkDotNet.Artifacts/ + +project.lock.json +project.fragment.lock.json +artifacts/ + +*.pdb +*.log +*.tlog + +# NuGet Packages +*.nupkg +*.snupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ + +# Others +*.pfx +*.publishsettings + +# Node +node_modules/ +dist/ +dist-ssr/ + +# Local environment files (secrets) — keep .env.example, ignore real .env +.env +.env.local +.env.*.local + +# Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json) +.dev-media/ + +# Отчёты покрытия (артефакт dotnet test / CI) +coverage/ +TestResults/ diff --git a/CLAUDE.md b/CLAUDE.md index f83697f..99ab2a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index c748342..7417e3a 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,66 @@ -# TeleWave - -[![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) - -**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из -хранилища на сервере, админ управляет каналами и пользователями. - -> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка; -> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS), метаданные (TMDb/OMDb), каталог -> каналов, планировщик эфира (реклама, ТВ-заставки, weekly-override'ы) и live-раздача HLS с публичным -> просмотром сетки. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL — -> **внешний**, не поднимается через compose. - -## Стек - -| Слой | Технологии | -| -------- | ---------------------------------------------------------------------------------------- | -| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core | -| БД | PostgreSQL (Npgsql) | -| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) | -| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) | -| Упаковка | Единый Docker-образ (API + статика SPA); PostgreSQL — внешний сервер | - -## Развёртывание - -Postgres не входит в compose — база и пользователь создаются заранее на внешнем сервере БД: - -```sql --- на сервере БД (psql -h <хост БД> -U postgres) -CREATE USER telewave WITH PASSWORD 'change-me-strong-db-password'; -CREATE DATABASE telewave OWNER telewave; -``` - -Затем на хосте с приложением: - -```bash -cp .env.example .env -# заполнить ConnectionStrings__Default (хост/база/юзер/пароль БД), Jwt__SigningKey, AdminSeed__* -docker compose up -d --build -# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password) -``` - -## Локальная разработка - -```bash -# backend, из backend/ -dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests -dotnet run --project src/TeleWave.Api - -# frontend, из frontend/ -pnpm install -pnpm dev # проксирует /api на localhost:8080 -``` - -## Документация - -Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example). -Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные -правила, доменные инварианты, соглашения по коду). - -## Лицензия - -Не определена. +# TeleWave + +[![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** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из +хранилища на сервере, админ управляет каналами и пользователями. + +> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка; +> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS), метаданные (TMDb/OMDb), каталог +> каналов, планировщик эфира (реклама, ТВ-заставки, weekly-override'ы) и live-раздача HLS с публичным +> просмотром сетки. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL — +> **внешний**, не поднимается через compose. + +## Стек + +| Слой | Технологии | +| -------- | ---------------------------------------------------------------------------------------- | +| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core | +| БД | PostgreSQL (Npgsql) | +| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) | +| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) | +| Упаковка | Единый Docker-образ (API + статика SPA); PostgreSQL — внешний сервер | + +## Развёртывание + +Postgres не входит в compose — база и пользователь создаются заранее на внешнем сервере БД: + +```sql +-- на сервере БД (psql -h <хост БД> -U postgres) +CREATE USER telewave WITH PASSWORD 'change-me-strong-db-password'; +CREATE DATABASE telewave OWNER telewave; +``` + +Затем на хосте с приложением: + +```bash +cp .env.example .env +# заполнить ConnectionStrings__Default (хост/база/юзер/пароль БД), Jwt__SigningKey, AdminSeed__* +docker compose up -d --build +# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password) +``` + +## Локальная разработка + +```bash +# backend, из backend/ +dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests +dotnet run --project src/TeleWave.Api + +# frontend, из frontend/ +pnpm install +pnpm dev # проксирует /api на localhost:8080 +``` + +## Документация + +Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example). +Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные +правила, доменные инварианты, соглашения по коду). + +## Лицензия + +Не определена. diff --git a/backend/src/TeleWave.Api/Common/SegmentFiles.cs b/backend/src/TeleWave.Api/Common/SegmentFiles.cs new file mode 100644 index 0000000..f026d19 --- /dev/null +++ b/backend/src/TeleWave.Api/Common/SegmentFiles.cs @@ -0,0 +1,38 @@ +using System.Text.RegularExpressions; +using TeleWave.Infrastructure.Media; + +namespace TeleWave.Api.Common; + +/// +/// Общая проверка файлов нарезки для всех эндпоинтов, отдающих HLS: эфир, превью заставок. +/// Имя сегмента сверяется с allowlist, а путь резолвится через , +/// который бросает на попытку выйти за пределы каталога — +/// наружу это должно выглядеть как обычный 404, а не как ошибка сервера. +/// +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); + + /// Путь к существующему файлу нарезки, либо null — если имя опасно или файла нет. + 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; + } +} diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs index c22ffe8..4368f9b 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs @@ -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; /// Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью. public static partial class ChannelEndpoints { - private static readonly Regex BumperSegmentFileName = new( - @"^seg\d{1,6}\.ts$", - RegexOptions.Compiled - ); - private static async Task 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); diff --git a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs index 64759c3..ecd8de3 100644 --- a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs @@ -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"; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs new file mode 100644 index 0000000..c52420a --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs @@ -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; + +/// +/// Восстанавливает по кэш-строке заставки: планировщик сохранил только +/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку, +/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, +/// воркер лишь крутит ffmpeg. +/// +public sealed class BumperSpecLoader( + IAppDbContext dbContext, + IBumperTemplateStorage bumperStorage, + IImageStore imageStore, + IOptions bumperOptions, + IOptions streamingOptions +) +{ + private readonly BumperOptions _bumper = bumperOptions.Value; + private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); + + /// Спецификация заставки для ассета, либо null если восстановить её уже нельзя. + public async Task 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 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 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); + } +} diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 5ac60eb..6572d46 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -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(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; } diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs index 8a1e7e0..e663ded 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs @@ -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", "У группы не задано правило набора." diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs new file mode 100644 index 0000000..a0f5650 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/AddJunctionElementCommandHandler.cs @@ -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> +{ + public async Task> Handle( + AddJunctionElementCommand command, + CancellationToken cancellationToken + ) + { + var junction = await JunctionLoader.LoadAsync( + dbContext, + command.JunctionId, + cancellationToken + ); + if (junction is null) + return Result.Failure(TemplateErrors.JunctionNotFound); + + var element = junction.AddElement(command.Kind); + await JunctionLoader.MarkTemplateChangedAsync(dbContext, junction, cancellationToken); + return Result.Success(element.Id); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs new file mode 100644 index 0000000..52eca80 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/CreateJunctionCommandHandler.cs @@ -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> +{ + public async Task> Handle( + CreateJunctionCommand command, + CancellationToken cancellationToken + ) + { + if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken)) + return Result.Failure(ChannelErrors.NotFound); + + var junction = JunctionTemplate.Create(command.ChannelId, command.Name); + dbContext.JunctionTemplates.Add(junction); + return Result.Success(junction.Id); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs new file mode 100644 index 0000000..b388182 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs @@ -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 +{ + public async Task 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 + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionHandlers.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionHandlers.cs deleted file mode 100644 index 3f6f193..0000000 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionHandlers.cs +++ /dev/null @@ -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> -{ - public async Task> 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> -{ - public async Task> Handle( - CreateJunctionCommand command, - CancellationToken cancellationToken - ) - { - if (!await dbContext.Channels.AnyAsync(c => c.Id == command.ChannelId, cancellationToken)) - return Result.Failure(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 -{ - public async Task 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); - } - - /// Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым. - internal static async Task 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 -{ - public async Task 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> -{ - public async Task> Handle( - AddJunctionElementCommand command, - CancellationToken cancellationToken - ) - { - var junction = await JunctionLoader.LoadAsync( - dbContext, - command.JunctionId, - cancellationToken - ); - if (junction is null) - return Result.Failure(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 -{ - public async Task 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 -{ - public async Task 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 -{ - public async Task 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 LoadAsync( - IAppDbContext dbContext, - Guid junctionId, - CancellationToken cancellationToken - ) => - dbContext - .JunctionTemplates.Include(j => j.Elements) - .FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken); -} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs new file mode 100644 index 0000000..2c44303 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/JunctionLoader.cs @@ -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; + +/// Общее для всех команд стыка: загрузка шаблона и отметка правил эфира изменёнными. +internal static class JunctionLoader +{ + public static Task LoadAsync( + IAppDbContext dbContext, + Guid junctionId, + CancellationToken cancellationToken + ) => + dbContext + .JunctionTemplates.Include(j => j.Elements) + .FirstOrDefaultAsync(j => j.Id == junctionId, cancellationToken); + + /// Правка стыка — тоже правка правил эфира: шаблон канала помечается изменённым. + public static async Task MarkTemplateChangedAsync( + IAppDbContext dbContext, + JunctionTemplate junction, + CancellationToken cancellationToken + ) + { + var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync( + t => t.ChannelId == junction.ChannelId, + cancellationToken + ); + template?.MarkChanged(); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs new file mode 100644 index 0000000..7711210 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ListJunctionsQueryHandler.cs @@ -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> +{ + public async Task> 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(); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs new file mode 100644 index 0000000..7910852 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/RemoveJunctionElementCommandHandler.cs @@ -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 +{ + public async Task 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 + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/RenameJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/RenameJunctionCommandHandler.cs new file mode 100644 index 0000000..ad9e705 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/RenameJunctionCommandHandler.cs @@ -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 +{ + public async Task 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 + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs new file mode 100644 index 0000000..b58bb9b --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/ReorderJunctionCommandHandler.cs @@ -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 +{ + public async Task 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 + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs new file mode 100644 index 0000000..8aa4df1 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/UpdateJunctionElementCommandHandler.cs @@ -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 +{ + public async Task 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 + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs index 30b2abd..0f0d83d 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Validate/ValidateTemplateQueryHandler.cs @@ -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); diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index d73fd63..e31740b 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); AddMedia(services, configuration); AddBroadcast(services, configuration); diff --git a/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs index e46aaea..304875c 100644 --- a/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Media/BumperRenderBackgroundService.cs @@ -1,154 +1,76 @@ -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; +/// Захваченная на рендер заставка: спецификация собирается уже в самой работе. +internal sealed record BumperRenderJob(Guid AssetId); + /// /// Асинхронно рендерит ТВ-заставки расписания: планировщик лишь создаёт ассет (Source=Generated) в /// статусе Pending и кэш-строку , а сам ffmpeg крутится здесь, -/// вне тика планировщика и его транзакции. Источник истины — статус в БД (последовательно берём -/// следующий Pending c Source=Generated, помечаем Processing), поэтому рестарт/краш ничего не теряет -/// (прерванные Processing сбрасываются в Pending на старте). До готовности ассета плейлист отдаёт филлер. +/// вне тика планировщика и его транзакции. Захват работы и устойчивость к рестарту — в +/// . До готовности ассета плейлист отдаёт филлер. +/// +/// Рендер строго последовательный: ffmpeg заставки короткий, а параллелить его смысла нет — +/// очередь разбирается быстрее, чем планировщик успевает её пополнять. /// -public sealed class BumperRenderBackgroundService( +internal sealed class BumperRenderBackgroundService( IBumperRenderQueue queue, IServiceScopeFactory scopeFactory, IBumperRenderer renderer, - IBumperTemplateStorage bumperStorage, - IImageStore imageStore, - IOptions bumperOptions, - IOptions streamingOptions, ILogger logger -) : BackgroundService +) : MediaClaimingBackgroundService(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); - /// Сброс прерванных рестартом заставок (Generated Processing → Pending) на старте. - private async Task ResetInterruptedAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - 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); - } - - /// Атомарно захватывает самую раннюю Pending-заставку (Generated): Pending → Processing. - private async Task ClaimNextAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - 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) + protected override async Task ProcessAsync( + BumperRenderJob job, + CancellationToken cancellationToken + ) { try { - await using var scope = scopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var spec = await BuildSpecAsync(db, assetId, cancellationToken); + var spec = await WithScopeAsync( + 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, + await WithAssetAsync( + job.AssetId, + asset => + asset.MarkReady( + render.Duration, + render.SegmentSeconds, + render.SegmentCount, + render.Width, + render.Height, + "h264", + "aac", + render.RelativePath + ), cancellationToken ); - if (asset is null) - return; - asset.MarkReady( - render.Duration, - render.SegmentSeconds, - render.SegmentCount, - render.Width, - render.Height, - "h264", - "aac", - render.RelativePath - ); - 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); } } - - /// Восстанавливает по кэш-строке заставки (канал/блок/подблок). - private async Task 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 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 ResolveTemplateBackgroundAsync( - IAppDbContext db, - Guid? backgroundImageId, - CancellationToken cancellationToken - ) - { - if (backgroundImageId is not { } bgId) - return null; - return await ResolveImagePathAsync(db, bgId, cancellationToken); - } - - private async Task 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(); - var asset = await db.MediaAssets.FirstOrDefaultAsync( - a => a.Id == assetId, - cancellationToken - ); - if (asset is null) - return; - asset.MarkFailed(error); - await db.SaveChangesAsync(cancellationToken); - } } diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaClaimingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/MediaClaimingBackgroundService.cs new file mode 100644 index 0000000..80c1dd5 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Media/MediaClaimingBackgroundService.cs @@ -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; + +/// +/// Каркас воркера, разбирающего очередь ассетов из БД. Источник истины — статус: единственный +/// диспетчер последовательно и атомарно захватывает следующий Pending (помечает Processing), поэтому +/// два воркера никогда не возьмут один ассет; сама работа идёт в фоне с ограничением по числу слотов. +/// Рестарт/краш ничего не теряет — прерванные Processing сбрасываются в Pending на старте. +/// БД-контекст держится короткими отрезками (пометить статус), сама работа идёт вне scope, чтобы не +/// держать соединение минутами. +/// +/// Пространство ассетов делится по : ТВ-заставки (Source=Generated) +/// рендерит один воркер, всё остальное транскодирует другой, и пересечься они не могут. +/// +internal abstract class MediaClaimingBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger logger +) : BackgroundService + where TJob : class +{ + // Периодически перепроверяем БД, даже если сигнал не пришёл — страховка на любой случай. + private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30); + + /// true — воркер обслуживает только сгенерированные ассеты, false — только остальные. + protected abstract bool HandlesGenerated { get; } + + /// Сколько работ выполняется одновременно. 1 — строго последовательно. + protected virtual int MaxParallel => 1; + + /// Что писать в лог при сбое цикла (не самой работы). + protected abstract string LoopErrorMessage { get; } + + /// Ожидание сигнала о новой работе — у каждого воркера своя очередь-будильник. + protected abstract ValueTask WaitForWorkAsync(CancellationToken cancellationToken); + + /// Что из захваченного ассета нужно воркеру: работа идёт уже без БД-контекста. + protected abstract TJob ToJob(MediaAsset asset); + + /// Сама работа над захваченным (уже Processing) ассетом. + protected abstract Task ProcessAsync(TJob job, CancellationToken cancellationToken); + + /// Разовая подготовка перед первым тиком (например, создание каталогов). + 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(); + + 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. + } + } + + /// Помечает ассет провалившимся. Вызывается воркером из его обработчика ошибок. + protected async Task FailAsync( + Guid assetId, + string error, + CancellationToken cancellationToken + ) => + await WithAssetAsync( + assetId, + asset => asset.MarkFailed(error), + cancellationToken + ); + + /// Находит ассет в свежем scope, применяет к нему изменение и сохраняет. + protected async Task WithAssetAsync( + Guid assetId, + Action change, + CancellationToken cancellationToken + ) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var asset = await db.MediaAssets.FirstOrDefaultAsync( + x => x.Id == assetId, + cancellationToken + ); + if (asset is null) + return; + + change(asset); + await db.SaveChangesAsync(cancellationToken); + } + + /// + /// Выполняет что-то на scoped-сервисе в отдельном scope — для сборки данных под работу. + /// Scope живёт только на время вызова: соединение с БД не удерживается на весь рендер/транскод. + /// + protected async Task WithScopeAsync(Func> use) + where TService : notnull + { + await using var scope = scopeFactory.CreateAsyncScope(); + return await use(scope.ServiceProvider.GetRequiredService()); + } + + /// Сброс прерванных рестартом задач (Processing → Pending) на старте. + private async Task ResetInterruptedAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// + /// Атомарно захватывает самый ранний Pending этого воркера: помечает его Processing и возвращает + /// job, либо null если работы нет. Вызывается только диспетчером последовательно, поэтому две + /// работы не возьмут один ассет. + /// + private async Task ClaimNextAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// Ассеты этого воркера в заданном статусе — предикат переводится в SQL. + private Expression> Owned(MediaAssetStatus status) => + HandlesGenerated + ? x => x.Status == status && x.Source == MediaSource.Generated + : x => x.Status == status && x.Source != MediaSource.Generated; +} diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs index 27b4d57..55df6e4 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaProcessingBackgroundService.cs @@ -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; +/// Захваченный на транскод ассет: расширение нужно ffmpeg и уже не требует БД. +internal sealed record MediaTranscodeJob(Guid AssetId, string Extension); + /// /// Обработчик медиа: прогоняет ассеты через ffmpeg, до -/// файлов одновременно. Источник истины — статус в БД: единственный диспетчер последовательно и -/// атомарно захватывает следующий Pending (помечает Processing), поэтому два транскода никогда не -/// возьмут один ассет; сам транскод запускается в фоне с ограничением по числу слотов. Рестарт/краш -/// ничего не теряет — незавершённые подхватываются из БД (прерванные Processing на старте сбрасываются -/// в Pending). БД-контекст держится короткими отрезками (пометить статус), сам транскод идёт вне -/// scope, чтобы не держать соединение минутами. +/// файлов одновременно. Захват работы, устойчивость к рестарту и параллелизм — в +/// ; здесь только сам транскод. /// -public sealed class MediaProcessingBackgroundService( +internal sealed class MediaProcessingBackgroundService( IMediaProcessingQueue queue, IServiceScopeFactory scopeFactory, MediaPathResolver paths, @@ -26,167 +22,55 @@ public sealed class MediaProcessingBackgroundService( IOptions storageOptions, IOptions mediaOptions, ILogger logger -) : BackgroundService +) : MediaClaimingBackgroundService(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); + // Generated-ассеты (ТВ-заставки) обслуживает BumperRenderBackgroundService — их не берём. + protected override bool HandlesGenerated => false; - // Слоты параллелизма: не запускаем больше _maxParallel транскодов одновременно. - using var slots = new SemaphoreSlim(_maxParallel, _maxParallel); - var inFlight = new ConcurrentDictionary(); + protected override int MaxParallel => Math.Max(1, mediaOptions.Value.MaxParallelTranscodes); - while (!stoppingToken.IsCancellationRequested) - { - try - { - // Захватываем и раздаём по слотам всю накопившуюся работу из БД. - while (!stoppingToken.IsCancellationRequested) - { - await slots.WaitAsync(stoppingToken); + protected override string LoopErrorMessage => "Ошибка цикла обработки медиа"; - // Слот уже захвачен — любой сбой захвата ассета (транзиентная ошибка БД и т.п.) - // обязан вернуть слот, иначе после нескольких ошибок семафор исчерпается и - // диспетчер зависнет навсегда (сервис формально жив, но ничего не обрабатывает). - (Guid Id, string Extension)? claim; - try - { - claim = await ClaimNextAsync(stoppingToken); - } - catch - { - slots.Release(); - throw; - } + protected override void OnStarting() => paths.EnsureDirectories(); - if (claim is not { } job) - { - slots.Release(); - break; - } + protected override ValueTask WaitForWorkAsync(CancellationToken cancellationToken) => + queue.WaitAsync(cancellationToken); - 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); - } + protected override MediaTranscodeJob ToJob(MediaAsset asset) => + new(asset.Id, asset.OriginalExtension); - // Работы нет — ждём сигнала о новой либо периодического опроса. - 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. - } - } - - /// Сброс прерванных рестартом задач (Processing → Pending) на старте. - private async Task ResetInterruptedAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - 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); - } - - /// - /// Атомарно захватывает самый ранний Pending: помечает его Processing и возвращает (id, расширение), - /// либо null если работы нет. Вызывается только диспетчером последовательно, поэтому два транскода - /// не возьмут один ассет. - /// - private async Task<(Guid Id, string Extension)?> ClaimNextAsync( - CancellationToken cancellationToken - ) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - // 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; - - asset.MarkProcessing(); - await db.SaveChangesAsync(cancellationToken); - return (asset.Id, asset.OriginalExtension); - } - - /// Обрабатывает уже захваченный (Processing) ассет: транскод → Ready/Failed. - private async Task ProcessClaimedAsync( - Guid assetId, - string extension, + 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(); - - 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(); - - 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); diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 99ccaf8..f87e7dc 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -1,535 +1,194 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Link } from '@tanstack/react-router' -import { ChevronLeft, Plus, 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 { 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 { 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 { 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 -type ChannelTab = (typeof TABS)[number] - -export function ChannelDetail({ channelId }: { channelId: string }) { - const { t } = useTranslation() - const queryClient = useQueryClient() - const [draft, setDraft] = useState(null) - const [activeLayerId, setActiveLayerId] = useState(null) - const [viewDate, setViewDate] = useState('') - const [applicabilityLayer, setApplicabilityLayer] = useState(null) - // День, который копируем, и отмеченные дни-приёмники. - const [copySource, setCopySource] = useState(null) - const [copyTargets, setCopyTargets] = useState([]) - const [applyOpen, setApplyOpen] = useState(false) - const [traceEntryId, setTraceEntryId] = useState(null) - const [copyFromChannel, setCopyFromChannel] = useState('') - const [tab, setTab] = useState('settings') - - const { data: channel, isLoading } = useQuery({ - queryKey: ['admin', 'channels', channelId], - queryFn: () => getChannel(channelId), - }) - const { data: template, error: templateError } = useQuery({ - queryKey: ['admin', 'channels', channelId, 'template'], - queryFn: () => getChannelTemplate(channelId), - }) - const { data: ready } = useQuery({ - queryKey: ['admin', 'media', 'ready', 'all'], - queryFn: () => listAllMedia({ statuses: ['Ready'] }), - }) - const { data: schedule } = useQuery({ - queryKey: ['admin', 'channels', channelId, 'schedule'], - queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)), - }) - - const invalidate = () => { - void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', 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 applyMutation = useMutation({ - mutationFn: () => applyChannelTemplate(channelId), - onSuccess: (result) => { - setApplyOpen(false) - toast.success(t('admin.channels.applied', { count: result.added })) - // Предупреждения показываем по одному: каждое указывает на конкретный слот. - for (const warning of result.warnings) - toast.error(`${t(`admin.channels.warnings.${warning.kind}`)}: ${warning.details}`) - invalidate() - }, - 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

{t('common.loading')}

- - 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 ( -
-
- - -
-

{channel.name}

- {channel.number !== null && № {channel.number}} - {!channel.isEnabled && {t('admin.channels.disabled')}} -
-
- - {/* Правка правил эфира не двигает — применение отдельной кнопкой. */} - {template?.hasPendingChanges && ( -
- {t('admin.channels.pendingChanges')} - -
- )} - - {/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */} - - - {tab === 'settings' && ( - - )} - - {/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */} - {tab === 'grid' && !template && ( -
-

- {templateError instanceof HttpError - ? templateError.detail - : t('admin.channels.noTemplate')} -

- -

{t('admin.channels.createTemplateHint')}

-
- )} - - {tab === 'grid' && template && ( - - -
-
-
-

- {t('admin.channels.layers')} -

- -
- setActiveLayerId(layer.id)} - onDelete={(layer) => deleteLayerMutation.mutate(layer)} - onToggle={(layer) => toggleLayerMutation.mutate(layer)} - onReorder={(order) => reorderLayersMutation.mutate(order)} - onEditApplicability={setApplicabilityLayer} - /> -

{t('admin.channels.layersHint')}

- - {/* Копия сетки на другой канал: группы общие, поэтому переносятся только правила. */} -
- - {t('admin.channels.copyTemplate')} - - - -

- {t('admin.channels.copyTemplateHint')} -

-
-
- -
- l.slots).map((slot) => [slot.id, slot])) - } - onGoToSlot={openSlot} - /> - - - {/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */} -
- {t('admin.channels.showForDate')} - setViewDate(e.target.value)} - /> - {viewDate && ( - - )} -
- - {/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */} - {copySource !== null && ( -
- - {t('admin.channels.copyDayFrom', { - day: t(`admin.channels.weekdays.${copySource}`), - })} - - {[1, 2, 3, 4, 5, 6, 0] - .filter((day) => day !== copySource) - .map((day) => ( - - ))} - - -
- )} - - - moveSlotMutation.mutate({ slot, weekday, startMinutes }) - } - onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })} - onCopyDay={(weekday) => { - setCopySource(weekday) - setCopyTargets([]) - }} - /> - {draft && ( - setDraft(null)} - onChanged={invalidate} - /> - )} -
-
-
-
- )} - - {tab === 'rules' && - (template ? ( - - ) : ( -

{t('admin.channels.noTemplate')}

- ))} - - {tab === 'junctions' && ( - - )} - - {tab === 'bumpers' && ( - - )} - - {tab === 'viewer' && ( - - )} - - {tab === 'air' && ( - - - - - - )} - - {applicabilityLayer && ( - setApplicabilityLayer(null)} - onChanged={invalidate} - onError={onError} - /> - )} - - {applyOpen && ( - applyMutation.mutate()} - onClose={() => setApplyOpen(false)} - /> - )} - - {traceEntryId && ( - setTraceEntryId(null)} - /> - )} -
- ) -} +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { ChevronLeft, Send } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { listAllMedia } from '@/features/admin/media/api' +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 { toast } from '@/shared/ui/toast-store' +import { + applyChannelTemplate, + getChannel, + getChannelTemplate, + getSchedule, +} 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 { RulesCard } from './components/RulesCard' +import { SchedulePreview } from './components/SchedulePreview' +import { SettingsCard } from './components/SettingsCard' +import { ViewerCard } from './components/ViewerCard' + +/** Вкладки экрана канала: настройки первыми — с них канал и начинается. */ +const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const +type ChannelTab = (typeof TABS)[number] + +export function ChannelDetail({ channelId }: { channelId: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [applyOpen, setApplyOpen] = useState(false) + const [traceEntryId, setTraceEntryId] = useState(null) + const [tab, setTab] = useState('settings') + + const { data: channel, isLoading } = useQuery({ + queryKey: qk.channels.detail(channelId), + queryFn: () => getChannel(channelId), + }) + const { data: template, error: templateError } = useQuery({ + queryKey: qk.channels.template(channelId), + queryFn: () => getChannelTemplate(channelId), + }) + const { data: ready } = useQuery({ + queryKey: qk.media.ready, + queryFn: () => listAllMedia({ statuses: ['Ready'] }), + }) + const { data: schedule } = useQuery({ + queryKey: qk.channels.schedule(channelId), + queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)), + }) + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: qk.channels.detail(channelId) }) + } + const onError = useApiError() + + const applyMutation = useMutation({ + mutationFn: () => applyChannelTemplate(channelId), + onSuccess: (result) => { + setApplyOpen(false) + toast.success(t('admin.channels.applied', { count: result.added })) + // Предупреждения показываем по одному: каждое указывает на конкретный слот. + for (const warning of result.warnings) + toast.error(`${t(`admin.channels.warnings.${warning.kind}`)}: ${warning.details}`) + invalidate() + }, + onError, + }) + + if (isLoading || !channel) return

{t('common.loading')}

+ + return ( +
+
+ + +
+

{channel.name}

+ {channel.number !== null && № {channel.number}} + {!channel.isEnabled && {t('admin.channels.disabled')}} +
+
+ + {/* Правка правил эфира не двигает — применение отдельной кнопкой. */} + {template?.hasPendingChanges && ( +
+ {t('admin.channels.pendingChanges')} + +
+ )} + + {/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */} + + + {tab === 'settings' && ( + + )} + + {tab === 'grid' && ( + + )} + + {tab === 'rules' && + (template ? ( + + ) : ( +

{t('admin.channels.noTemplate')}

+ ))} + + {tab === 'junctions' && ( + + )} + + {tab === 'bumpers' && ( + + )} + + {tab === 'viewer' && ( + + )} + + {tab === 'air' && ( + + + + + + )} + + {applyOpen && ( + applyMutation.mutate()} + onClose={() => setApplyOpen(false)} + /> + )} + + {traceEntryId && ( + setTraceEntryId(null)} + /> + )} +
+ ) +} diff --git a/frontend/src/features/admin/channels/ChannelsPanel.tsx b/frontend/src/features/admin/channels/ChannelsPanel.tsx index 4f005df..1444515 100644 --- a/frontend/src/features/admin/channels/ChannelsPanel.tsx +++ b/frontend/src/features/admin/channels/ChannelsPanel.tsx @@ -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) }), diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index e8da80c..dec17fe 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -36,7 +36,7 @@ export function createChannel(body: { name: string; slug: string }) { return apiRequest('/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 diff --git a/frontend/src/features/admin/channels/components/ApplyDialog.tsx b/frontend/src/features/admin/channels/components/ApplyDialog.tsx index e847fc8..576bf1e 100644 --- a/frontend/src/features/admin/channels/components/ApplyDialog.tsx +++ b/frontend/src/features/admin/channels/components/ApplyDialog.tsx @@ -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, diff --git a/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx b/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx index 18bfb7f..8d5fd04 100644 --- a/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx +++ b/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx @@ -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), }) diff --git a/frontend/src/features/admin/channels/components/GridTab.tsx b/frontend/src/features/admin/channels/components/GridTab.tsx new file mode 100644 index 0000000..227ba00 --- /dev/null +++ b/frontend/src/features/admin/channels/components/GridTab.tsx @@ -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(null) + const [activeLayerId, setActiveLayerId] = useState(null) + const [viewDate, setViewDate] = useState('') + const [applicabilityLayer, setApplicabilityLayer] = useState(null) + // День, который копируем, и отмеченные дни-приёмники. + const [copySource, setCopySource] = useState(null) + const [copyTargets, setCopyTargets] = useState([]) + 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 ( +
+

+ {templateError instanceof HttpError + ? templateError.detail + : t('admin.channels.noTemplate')} +

+ +

{t('admin.channels.createTemplateHint')}

+
+ ) + + 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 ( + <> + + +
+
+
+

+ {t('admin.channels.layers')} +

+ +
+ setActiveLayerId(layer.id)} + onDelete={(layer) => deleteLayerMutation.mutate(layer)} + onToggle={(layer) => toggleLayerMutation.mutate(layer)} + onReorder={(order) => reorderLayersMutation.mutate(order)} + onEditApplicability={setApplicabilityLayer} + /> +

{t('admin.channels.layersHint')}

+ + {/* Копия сетки с другого канала: группы общие, поэтому переносятся только правила. */} +
+ + {t('admin.channels.copyTemplate')} + + + +

+ {t('admin.channels.copyTemplateHint')} +

+
+
+ +
+ l.slots).map((slot) => [slot.id, slot])) + } + onGoToSlot={openSlot} + /> + + + {/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */} +
+ {t('admin.channels.showForDate')} + setViewDate(e.target.value)} + /> + {viewDate && ( + + )} +
+ + {/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */} + {copySource !== null && ( +
+ + {t('admin.channels.copyDayFrom', { + day: t(`admin.channels.weekdays.${copySource}`), + })} + + {[1, 2, 3, 4, 5, 6, 0] + .filter((day) => day !== copySource) + .map((day) => ( + + ))} + + +
+ )} + + + moveSlotMutation.mutate({ slot, weekday, startMinutes }) + } + onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })} + onCopyDay={(weekday) => { + setCopySource(weekday) + setCopyTargets([]) + }} + /> + {draft && ( + setDraft(null)} + onChanged={onChanged} + /> + )} +
+
+
+
+ + {applicabilityLayer && ( + setApplicabilityLayer(null)} + onChanged={onChanged} + onError={onError} + /> + )} + + ) +} diff --git a/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx b/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx index f43d357..9b02097 100644 --- a/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx +++ b/frontend/src/features/admin/channels/components/JunctionElementDialog.tsx @@ -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(() => 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) => setBody((prev) => ({ ...prev, ...part })) diff --git a/frontend/src/features/admin/channels/components/JunctionsCard.tsx b/frontend/src/features/admin/channels/components/JunctionsCard.tsx index 8949cbf..bc446f6 100644 --- a/frontend/src/features/admin/channels/components/JunctionsCard.tsx +++ b/frontend/src/features/admin/channels/components/JunctionsCard.tsx @@ -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()), diff --git a/frontend/src/features/admin/channels/components/SlotInspector.tsx b/frontend/src/features/admin/channels/components/SlotInspector.tsx index ca49aee..3baee36 100644 --- a/frontend/src/features/admin/channels/components/SlotInspector.tsx +++ b/frontend/src/features/admin/channels/components/SlotInspector.tsx @@ -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 () => { diff --git a/frontend/src/features/admin/channels/components/TemplateIssues.tsx b/frontend/src/features/admin/channels/components/TemplateIssues.tsx index 941d536..c769f0a 100644 --- a/frontend/src/features/admin/channels/components/TemplateIssues.tsx +++ b/frontend/src/features/admin/channels/components/TemplateIssues.tsx @@ -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), }) diff --git a/frontend/src/features/admin/channels/components/TemplatePreview.tsx b/frontend/src/features/admin/channels/components/TemplatePreview.tsx index 9b976a6..922f399 100644 --- a/frontend/src/features/admin/channels/components/TemplatePreview.tsx +++ b/frontend/src/features/admin/channels/components/TemplatePreview.tsx @@ -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, // Черновик правил может меняться между открытиями — кэшировать прогон смысла нет. diff --git a/frontend/src/features/admin/channels/components/fields.tsx b/frontend/src/features/admin/channels/components/fields.tsx deleted file mode 100644 index aa8344d..0000000 --- a/frontend/src/features/admin/channels/components/fields.tsx +++ /dev/null @@ -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 ( -
- - onChange(Number(e.target.value))} - className="w-24" - /> -
- ) -} - -export function RemoveButton({ onClick }: { onClick: () => void }) { - const { t } = useTranslation() - return ( - - ) -} diff --git a/frontend/src/features/admin/channels/lib/format.ts b/frontend/src/features/admin/channels/lib/format.ts index 0ef7e51..63d5ead 100644 --- a/frontend/src/features/admin/channels/lib/format.ts +++ b/frontend/src/features/admin/channels/lib/format.ts @@ -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 во времени канала. Сетка задаётся в нём, поэтому предпросмотр показывается так же: * локальное время админа тут только запутало бы. diff --git a/frontend/src/features/admin/collections/CollectionDetail.tsx b/frontend/src/features/admin/collections/CollectionDetail.tsx index dd45beb..fef2f71 100644 --- a/frontend/src/features/admin/collections/CollectionDetail.tsx +++ b/frontend/src/features/admin/collections/CollectionDetail.tsx @@ -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(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: () => diff --git a/frontend/src/features/admin/collections/CollectionsPanel.tsx b/frontend/src/features/admin/collections/CollectionsPanel.tsx index 9c37e4a..d1f2201 100644 --- a/frontend/src/features/admin/collections/CollectionsPanel.tsx +++ b/frontend/src/features/admin/collections/CollectionsPanel.tsx @@ -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() }), diff --git a/frontend/src/features/admin/genres/GenresPanel.tsx b/frontend/src/features/admin/genres/GenresPanel.tsx index 89318de..e8202fa 100644 --- a/frontend/src/features/admin/genres/GenresPanel.tsx +++ b/frontend/src/features/admin/genres/GenresPanel.tsx @@ -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({ diff --git a/frontend/src/features/admin/groups/GroupDetail.tsx b/frontend/src/features/admin/groups/GroupDetail.tsx index d51ab66..12be56a 100644 --- a/frontend/src/features/admin/groups/GroupDetail.tsx +++ b/frontend/src/features/admin/groups/GroupDetail.tsx @@ -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(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: () => diff --git a/frontend/src/features/admin/groups/GroupFilterPanel.tsx b/frontend/src/features/admin/groups/GroupFilterPanel.tsx index 348a59b..58134ce 100644 --- a/frontend/src/features/admin/groups/GroupFilterPanel.tsx +++ b/frontend/src/features/admin/groups/GroupFilterPanel.tsx @@ -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) => onChange({ ...filter, ...part }) diff --git a/frontend/src/features/admin/groups/GroupsPanel.tsx b/frontend/src/features/admin/groups/GroupsPanel.tsx index 63f344e..1a38f7f 100644 --- a/frontend/src/features/admin/groups/GroupsPanel.tsx +++ b/frontend/src/features/admin/groups/GroupsPanel.tsx @@ -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() }), diff --git a/frontend/src/features/admin/images/ImageGallery.tsx b/frontend/src/features/admin/images/ImageGallery.tsx index fe932d7..cbebcdc 100644 --- a/frontend/src/features/admin/images/ImageGallery.tsx +++ b/frontend/src/features/admin/images/ImageGallery.tsx @@ -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 } /** * Внутренность галереи: вкладки по категориям, загрузка и удаление. Если передан onSelect — @@ -35,13 +35,12 @@ export function GalleryBrowser({ const [active, setActive] = useState(category) const fileInput = useRef(null) - const onError = (error: unknown) => - toast.error(error instanceof HttpError ? error.detail : t('common.error')) + const onError = useApiError() const [order, setOrder] = useState('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 diff --git a/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx b/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx index 4ad1fa1..64efbc4 100644 --- a/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx +++ b/frontend/src/features/admin/interstitials/ClipGroupPanel.tsx @@ -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({ diff --git a/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx b/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx index 169d4a4..5310f13 100644 --- a/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx +++ b/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx @@ -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), diff --git a/frontend/src/features/admin/maintenance/MaintenancePanel.tsx b/frontend/src/features/admin/maintenance/MaintenancePanel.tsx index 6d3786a..66cca33 100644 --- a/frontend/src/features/admin/maintenance/MaintenancePanel.tsx +++ b/frontend/src/features/admin/maintenance/MaintenancePanel.tsx @@ -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 }) } diff --git a/frontend/src/features/admin/media/ManualInboxDialog.tsx b/frontend/src/features/admin/media/ManualInboxDialog.tsx index 1ccc014..7066ad7 100644 --- a/frontend/src/features/admin/media/ManualInboxDialog.tsx +++ b/frontend/src/features/admin/media/ManualInboxDialog.tsx @@ -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([]) 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) => diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 8bd179d..511ba72 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -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 }) diff --git a/frontend/src/features/admin/media/UploadToShowDialog.tsx b/frontend/src/features/admin/media/UploadToShowDialog.tsx index a740ba4..d8a2c9c 100644 --- a/frontend/src/features/admin/media/UploadToShowDialog.tsx +++ b/frontend/src/features/admin/media/UploadToShowDialog.tsx @@ -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>({}) - 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 diff --git a/frontend/src/features/admin/media/api.ts b/frontend/src/features/admin/media/api.ts index afa8ae6..58a0188 100644 --- a/frontend/src/features/admin/media/api.ts +++ b/frontend/src/features/admin/media/api.ts @@ -10,7 +10,7 @@ import type { PagedList, } from '@/shared/api/types' -export type ListMediaParams = { +type ListMediaParams = { page: number pageSize: number statuses?: MediaAssetStatus[] diff --git a/frontend/src/features/admin/media/episode-parse.ts b/frontend/src/features/admin/media/episode-parse.ts index 6deb6b2..fa980c9 100644 --- a/frontend/src/features/admin/media/episode-parse.ts +++ b/frontend/src/features/admin/media/episode-parse.ts @@ -1,4 +1,4 @@ -export type ParseOptions = { +type ParseOptions = { /** Ручной сезон — перебивает распознанный/дефолтный. */ seasonOverride?: number | null /** Пользовательский regex для номера серии: 1 группа = серия, 2 группы = (сезон, серия). */ diff --git a/frontend/src/features/admin/media/match-show.ts b/frontend/src/features/admin/media/match-show.ts index c3cbf21..1011065 100644 --- a/frontend/src/features/admin/media/match-show.ts +++ b/frontend/src/features/admin/media/match-show.ts @@ -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 { diff --git a/frontend/src/features/admin/media/upload-store.ts b/frontend/src/features/admin/media/upload-store.ts index 2321399..e1ef08a 100644 --- a/frontend/src/features/admin/media/upload-store.ts +++ b/frontend/src/features/admin/media/upload-store.ts @@ -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 = { * showId — общий для всех файлов; resolveShowId — привязка на каждый файл (напр. * автоопределение шоу по имени релиза). Приоритет у resolveShowId, затем общий showId. */ -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}: не удалось завести ролик`) } diff --git a/frontend/src/features/admin/roles/RolesPanel.tsx b/frontend/src/features/admin/roles/RolesPanel.tsx index 016687c..ec9e57e 100644 --- a/frontend/src/features/admin/roles/RolesPanel.tsx +++ b/frontend/src/features/admin/roles/RolesPanel.tsx @@ -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) } } diff --git a/frontend/src/features/admin/settings/SettingsPanel.tsx b/frontend/src/features/admin/settings/SettingsPanel.tsx index 10d6d90..3faacd1 100644 --- a/frontend/src/features/admin/settings/SettingsPanel.tsx +++ b/frontend/src/features/admin/settings/SettingsPanel.tsx @@ -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 ( diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 28054d0..f7685c9 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -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), diff --git a/frontend/src/features/admin/shows/ShowGenresField.tsx b/frontend/src/features/admin/shows/ShowGenresField.tsx index 8fb7169..33ef482 100644 --- a/frontend/src/features/admin/shows/ShowGenresField.tsx +++ b/frontend/src/features/admin/shows/ShowGenresField.tsx @@ -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(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 = () => { diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index 729b82d..ca5c61b 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -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({ diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index f34ccbf..9cf1cec 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -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: () => diff --git a/frontend/src/features/admin/users/UsersPanel.tsx b/frontend/src/features/admin/users/UsersPanel.tsx index 44ba549..99aa18b 100644 --- a/frontend/src/features/admin/users/UsersPanel.tsx +++ b/frontend/src/features/admin/users/UsersPanel.tsx @@ -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(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 }) diff --git a/frontend/src/features/admin/users/api.ts b/frontend/src/features/admin/users/api.ts index 82d2aff..fe4383f 100644 --- a/frontend/src/features/admin/users/api.ts +++ b/frontend/src/features/admin/users/api.ts @@ -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 diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index 04975a1..87a0df2 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -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!, diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 982aa1c..b482cf0 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -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, }) diff --git a/frontend/src/routes/register.tsx b/frontend/src/routes/register.tsx index 1633f75..b0650f7 100644 --- a/frontend/src/routes/register.tsx +++ b/frontend/src/routes/register.tsx @@ -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, }) diff --git a/frontend/src/shared/api/query-keys.ts b/frontend/src/shared/api/query-keys.ts new file mode 100644 index 0000000..b48c3a2 --- /dev/null +++ b/frontend/src/shared/api/query-keys.ts @@ -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, + }, +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index e29e6f5..6cba80a 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -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' diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 68f7927..230c057 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -1,1419 +1,12 @@ -import i18n from 'i18next' -import { initReactI18next } from 'react-i18next' - -const resources = { - ru: { - translation: { - 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: 'выключен', - grid: 'Сетка', - 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: 'Канал в эфире', - regenerate: 'Пересобрать', - regenerated: 'Расписание пересобрано', - settings: 'Настройки', - adPolicy: 'Реклама', - betweenBlocks: 'Между блоками', - betweenEpisodes: 'Между сериями', - adsPerBreak: 'Роликов подряд', - 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: 'Без заглушки', - shows: 'Шоу канала', - show: 'Шоу', - weight: 'Вес', - block: 'Блок', - on: 'Вкл', - noShows: 'Шоу не добавлены', - pickShow: 'Выберите шоу', - blockCount: 'По сериям', - blockDuration: 'По времени', - episodes: 'серий', - minutes: 'минут', - preferredHours: 'Часы', - preferredMultiplier: 'Множитель веса', - preferredHoursHint: - 'В выбранные часы вес шоу умножается — оно чаще попадает в эфир. Время в UTC.', - preferredNone: 'Окна не заданы — предпочтений по времени нет.', - preferredAddWindow: 'Добавить окно', - preferredBadRange: 'начало ≥ конца', - 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: 'Не хватает', - }, - }, - }, - }, - en: { - translation: { - 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', - }, - 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: A–Z', - nameDesc: 'Name: Z–A', - }, - 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', - grid: 'Grid', - 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', - regenerate: 'Rebuild', - regenerated: 'Schedule rebuilt', - settings: 'Settings', - adPolicy: 'Ads', - betweenBlocks: 'Between blocks', - betweenEpisodes: 'Between episodes', - adsPerBreak: 'Ads per break', - 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', - shows: 'Channel shows', - show: 'Show', - weight: 'Weight', - block: 'Block', - on: 'On', - noShows: 'No shows added', - pickShow: 'Pick a show', - blockCount: 'By episodes', - blockDuration: 'By time', - episodes: 'episodes', - minutes: 'minutes', - preferredHours: 'Hours', - preferredMultiplier: 'Weight multiplier', - preferredHoursHint: - 'During the selected hours the show’s weight is multiplied, so it airs more often. Times are UTC.', - preferredNone: 'No windows set — no time preference.', - preferredAddWindow: 'Add window', - preferredBadRange: 'start ≥ end', - 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', - }, - }, - }, - }, -} +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import { en } from './locales/en' +import { ru } from './locales/ru' + +const resources = { + ru: { translation: ru }, + en: { translation: en }, +} const STORAGE_KEY = 'tw-lang' const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts new file mode 100644 index 0000000..efef537 --- /dev/null +++ b/frontend/src/shared/lib/locales/en.ts @@ -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: A–Z', + nameDesc: 'Name: Z–A', + }, + 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', + }, + }, +} diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts new file mode 100644 index 0000000..e87d70b --- /dev/null +++ b/frontend/src/shared/lib/locales/ru.ts @@ -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: 'Не хватает', + }, + }, +} diff --git a/frontend/src/shared/lib/use-api-error.ts b/frontend/src/shared/lib/use-api-error.ts new file mode 100644 index 0000000..8b895a8 --- /dev/null +++ b/frontend/src/shared/lib/use-api-error.ts @@ -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], + ) +} diff --git a/frontend/src/shared/ui/button.tsx b/frontend/src/shared/ui/button.tsx index 697c4f7..18537d5 100644 --- a/frontend/src/shared/ui/button.tsx +++ b/frontend/src/shared/ui/button.tsx @@ -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 & +type ButtonProps = ButtonHTMLAttributes & VariantProps & { asChild?: boolean } export const Button = forwardRef( diff --git a/frontend/src/shared/ui/dialog.tsx b/frontend/src/shared/ui/dialog.tsx index 0720b6a..b660bc8 100644 --- a/frontend/src/shared/ui/dialog.tsx +++ b/frontend/src/shared/ui/dialog.tsx @@ -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, ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( diff --git a/frontend/src/shared/ui/sortable.tsx b/frontend/src/shared/ui/sortable.tsx index 70bbab5..6a00aff 100644 --- a/frontend/src/shared/ui/sortable.tsx +++ b/frontend/src/shared/ui/sortable.tsx @@ -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 } /** * Состояние сортировки таблицы. Клик по новому столбцу — по возрастанию; повторный клик по тому же — diff --git a/frontend/src/shared/ui/toast-store.tsx b/frontend/src/shared/ui/toast-store.tsx index f3ba749..4c00273 100644 --- a/frontend/src/shared/ui/toast-store.tsx +++ b/frontend/src/shared/ui/toast-store.tsx @@ -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 diff --git a/frontend/src/theme/ThemeProvider.tsx b/frontend/src/theme/ThemeProvider.tsx index 6fef1ff..563d4b3 100644 --- a/frontend/src/theme/ThemeProvider.tsx +++ b/frontend/src/theme/ThemeProvider.tsx @@ -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