From 525fd2ee01055c060ade18c6243628dcfdcd5e93 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 01:51:55 +0300 Subject: [PATCH] Implement preferred audio languages feature: update site settings to include preferred audio languages, modify related data structures and API endpoints, and enhance media processing to select audio tracks based on user preferences. Update frontend components for managing and displaying these settings. --- .env.example | 4 ++ .../Endpoints/SettingsEndpoints.cs | 4 +- .../Common/Interfaces/ISiteSettings.cs | 6 ++ .../Media/AudioTrackSelector.cs | 30 +++++++++ .../Settings/AppSettingsReader.cs | 16 +++++ .../GetSiteSettingsQueryHandler.cs | 3 +- .../Settings/SettingKeys.cs | 4 ++ .../Settings/SiteSettingsDto.cs | 2 +- .../UpdateSiteSettingsCommand.cs | 3 +- .../UpdateSiteSettingsCommandHandler.cs | 8 +++ .../Media/FfmpegMediaProcessor.cs | 66 +++++++++++++++++-- .../Media/MediaOptions.cs | 5 ++ .../Settings/SiteSettings.cs | 16 ++++- .../Media/AudioTrackSelectorTests.cs | 51 ++++++++++++++ .../features/admin/settings/SettingsPanel.tsx | 27 +++++++- frontend/src/shared/api/types.ts | 2 +- frontend/src/shared/lib/i18n.ts | 6 ++ 17 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 backend/src/TeleWave.Application/Media/AudioTrackSelector.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Media/AudioTrackSelectorTests.cs diff --git a/.env.example b/.env.example index 945be93..0c42933 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,10 @@ Media__LoudnessTargetLufs=-16 # держать слот параллелизма/тик планировщика. Поднять TranscodeTimeoutSeconds для очень длинных файлов. # Media__ProbeTimeoutSeconds=120 # Media__TranscodeTimeoutSeconds=1800 +# Предпочитаемые языки аудиодорожек ПО УМОЛЧАНИЮ (через запятую, в порядке приоритета) — если файл +# многоозвучный и есть дорожка с таким языком, при обработке берётся она. Обычно задаётся в админке +# («Настройки»); этот конфиг — дефолт, если в настройках пусто. Пусто — выбор ffmpeg по умолчанию. +# Media__PreferredAudioLanguages=rus,eng # ── ТВ-заставки «Сейчас/Далее» (bumpers) ─────────────────────────────────── # Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность, diff --git a/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs index a6c73ea..6848986 100644 --- a/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs @@ -37,11 +37,11 @@ public static class SettingsEndpoints ) { var result = await sender.Send( - new UpdateSiteSettingsCommand(body.RegistrationEnabled), + new UpdateSiteSettingsCommand(body.RegistrationEnabled, body.PreferredAudioLanguages ?? ""), cancellationToken ); return result.ToHttpResult(); } } -public sealed record UpdateSiteSettingsBody(bool RegistrationEnabled); +public sealed record UpdateSiteSettingsBody(bool RegistrationEnabled, string? PreferredAudioLanguages); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/ISiteSettings.cs b/backend/src/TeleWave.Application/Common/Interfaces/ISiteSettings.cs index e5e1c81..a37e9f6 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/ISiteSettings.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/ISiteSettings.cs @@ -7,4 +7,10 @@ public interface ISiteSettings /// Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды). Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken); + + /// Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета + /// (напр. «rus,eng»); пусто — без предпочтения. + Task GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken); + + Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken); } diff --git a/backend/src/TeleWave.Application/Media/AudioTrackSelector.cs b/backend/src/TeleWave.Application/Media/AudioTrackSelector.cs new file mode 100644 index 0000000..15f04fa --- /dev/null +++ b/backend/src/TeleWave.Application/Media/AudioTrackSelector.cs @@ -0,0 +1,30 @@ +namespace TeleWave.Application.Media; + +/// +/// Выбор аудиодорожки при обработке по предпочитаемым языкам. Чистая логика (без ffmpeg/БД) — общая +/// точка для медиа-процессора и тестов. +/// +public static class AudioTrackSelector +{ + /// + /// Возвращает порядковый номер аудиодорожки (0:a:N) по предпочитаемым языкам: побеждает первый + /// язык из списка , для которого есть дорожка (среди таких — первая по + /// порядку). Нет совпадений / нет предпочтений / меньше двух дорожек — null (дефолтная эвристика + /// ffmpeg: пусть выбирает сам). + /// + public static int? Select( + IReadOnlyList audioLanguages, + IReadOnlyList preferred + ) + { + if (audioLanguages.Count < 2 || preferred.Count == 0) + return null; + + foreach (var lang in preferred) + for (var i = 0; i < audioLanguages.Count; i++) + if (string.Equals(audioLanguages[i], lang, StringComparison.OrdinalIgnoreCase)) + return i; + + return null; + } +} diff --git a/backend/src/TeleWave.Application/Settings/AppSettingsReader.cs b/backend/src/TeleWave.Application/Settings/AppSettingsReader.cs index 49c7af9..7d9ede9 100644 --- a/backend/src/TeleWave.Application/Settings/AppSettingsReader.cs +++ b/backend/src/TeleWave.Application/Settings/AppSettingsReader.cs @@ -21,4 +21,20 @@ public static class AppSettingsReader return value is not null && bool.TryParse(value, out var parsed) ? parsed : defaultValue; } + + public static async Task GetStringSettingAsync( + this IAppDbContext db, + string key, + string defaultValue, + CancellationToken cancellationToken + ) + { + var value = await db + .AppSettings.AsNoTracking() + .Where(s => s.Key == key) + .Select(s => s.Value) + .FirstOrDefaultAsync(cancellationToken); + + return value ?? defaultValue; + } } diff --git a/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQueryHandler.cs b/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQueryHandler.cs index 3f6affb..ae81123 100644 --- a/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Settings/GetSiteSettings/GetSiteSettingsQueryHandler.cs @@ -12,6 +12,7 @@ public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings) ) { var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken); - return new SiteSettingsDto(registrationEnabled); + var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken); + return new SiteSettingsDto(registrationEnabled, preferredAudio); } } diff --git a/backend/src/TeleWave.Application/Settings/SettingKeys.cs b/backend/src/TeleWave.Application/Settings/SettingKeys.cs index 03441f2..312cb29 100644 --- a/backend/src/TeleWave.Application/Settings/SettingKeys.cs +++ b/backend/src/TeleWave.Application/Settings/SettingKeys.cs @@ -5,4 +5,8 @@ public static class SettingKeys { /// Разрешена ли открытая регистрация пользователей (по умолчанию — нет). public const string RegistrationEnabled = "registration.enabled"; + + /// Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета + /// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg. + public const string PreferredAudioLanguages = "media.preferredAudioLanguages"; } diff --git a/backend/src/TeleWave.Application/Settings/SiteSettingsDto.cs b/backend/src/TeleWave.Application/Settings/SiteSettingsDto.cs index d68a41d..9bfa1c4 100644 --- a/backend/src/TeleWave.Application/Settings/SiteSettingsDto.cs +++ b/backend/src/TeleWave.Application/Settings/SiteSettingsDto.cs @@ -1,4 +1,4 @@ namespace TeleWave.Application.Settings; /// Глобальные настройки сайта, управляемые администратором. -public sealed record SiteSettingsDto(bool RegistrationEnabled); +public sealed record SiteSettingsDto(bool RegistrationEnabled, string PreferredAudioLanguages); diff --git a/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommand.cs b/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommand.cs index 6de1be8..628e89e 100644 --- a/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommand.cs +++ b/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommand.cs @@ -3,4 +3,5 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Settings.UpdateSiteSettings; -public sealed record UpdateSiteSettingsCommand(bool RegistrationEnabled) : ICommand; +public sealed record UpdateSiteSettingsCommand(bool RegistrationEnabled, string PreferredAudioLanguages) + : ICommand; diff --git a/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommandHandler.cs index b3bba7c..c8959a8 100644 --- a/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommandHandler.cs +++ b/backend/src/TeleWave.Application/Settings/UpdateSiteSettings/UpdateSiteSettingsCommandHandler.cs @@ -17,6 +17,14 @@ public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings) command.RegistrationEnabled, cancellationToken ); + // Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем. + var languages = string.Join( + ',', + (command.PreferredAudioLanguages ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => x.ToLowerInvariant()) + ); + await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken); return Result.Success(); } } diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs index 29101b0..2a94b13 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs @@ -1,7 +1,9 @@ using System.Globalization; using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Media; namespace TeleWave.Infrastructure.Media; @@ -12,6 +14,7 @@ namespace TeleWave.Infrastructure.Media; /// public sealed class FfmpegMediaProcessor( MediaPathResolver paths, + IServiceScopeFactory scopeFactory, IOptions storageOptions, IOptions mediaOptions ) : IMediaProcessor @@ -42,8 +45,12 @@ public sealed class FfmpegMediaProcessor( Directory.Delete(assetDir, recursive: true); Directory.CreateDirectory(assetDir); + // Выбор аудиодорожки по предпочитаемым языкам (настройка сайта, иначе дефолт из конфига). + var preferred = await GetPreferredLanguagesAsync(cancellationToken); + var audioTrack = AudioTrackSelector.Select(probe.AudioLanguages, preferred); + var (outWidth, outHeight) = ScaleDown(probe.Width, probe.Height); - var args = BuildFfmpegArgs(originalPath, assetDir, segmentSeconds, target, pad); + var args = BuildFfmpegArgs(originalPath, assetDir, segmentSeconds, target, pad, audioTrack); var result = await ProcessRunner.RunAsync( _media.FfmpegPath, @@ -82,7 +89,8 @@ public sealed class FfmpegMediaProcessor( string assetDir, int segmentSeconds, double target, - double pad + double pad, + int? audioTrack ) { var padding = pad > 0.001; @@ -97,6 +105,21 @@ public sealed class FfmpegMediaProcessor( "-y", "-i", input, + }; + + // Явный выбор дорожки по предпочитаемому языку: маппим видео + конкретную аудиодорожку. + // Без выбора (audioTrack == null) — не маппим, оставляя дефолтную эвристику ffmpeg (как раньше). + if (audioTrack is { } track) + { + args.AddRange([ + "-map", + "0:v:0", + "-map", + $"0:a:{track.ToString(CultureInfo.InvariantCulture)}", + ]); + } + + args.AddRange([ "-threads", _media.TranscodeThreads.ToString(CultureInfo.InvariantCulture), "-c:v", @@ -123,7 +146,7 @@ public sealed class FfmpegMediaProcessor( "2", "-ar", "48000", - }; + ]); // Аудиофильтры: нормализация громкости (EBU R128, чтобы все ролики звучали одинаково) и, // при добивке хвоста, тишина apad. Порядок: сначала loudnorm по реальному звуку, потом apad. @@ -197,18 +220,30 @@ public sealed class FfmpegMediaProcessor( int width = 0, height = 0; var hasVideo = false; + // Языки аудиодорожек в порядке их следования — индекс в списке = порядковый номер 0:a:N. + var audioLanguages = new List(); if (root.TryGetProperty("streams", out var streams)) { foreach (var stream in streams.EnumerateArray()) { if (!stream.TryGetProperty("codec_type", out var typeEl)) continue; - if (typeEl.GetString() == "video" && !hasVideo) + var codecType = typeEl.GetString(); + if (codecType == "video" && !hasVideo) { hasVideo = true; width = stream.TryGetProperty("width", out var w) ? w.GetInt32() : 0; height = stream.TryGetProperty("height", out var h) ? h.GetInt32() : 0; } + else if (codecType == "audio") + { + var lang = + stream.TryGetProperty("tags", out var tags) + && tags.TryGetProperty("language", out var langEl) + ? langEl.GetString() + : null; + audioLanguages.Add(lang); + } } } @@ -217,9 +252,23 @@ public sealed class FfmpegMediaProcessor( if (!hasVideo || width <= 0 || height <= 0) throw new InvalidOperationException("В файле не найдена видеодорожка."); - return new ProbeInfo(duration, width, height); + return new ProbeInfo(duration, width, height, audioLanguages); } + private async Task> GetPreferredLanguagesAsync( + CancellationToken cancellationToken + ) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var settings = scope.ServiceProvider.GetRequiredService(); + var configured = await settings.GetPreferredAudioLanguagesAsync(cancellationToken); + var raw = string.IsNullOrWhiteSpace(configured) ? _media.PreferredAudioLanguages : configured; + return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => x.ToLowerInvariant()) + .ToList(); + } + + private static (int Width, int Height) ScaleDown(int width, int height) { if (width <= MaxWidth) @@ -240,5 +289,10 @@ public sealed class FfmpegMediaProcessor( return text.Length <= max ? text : text[^max..]; } - private sealed record ProbeInfo(double Duration, int Width, int Height); + private sealed record ProbeInfo( + double Duration, + int Width, + int Height, + IReadOnlyList AudioLanguages + ); } diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs b/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs index 073fbdd..9ddc38e 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs @@ -36,4 +36,9 @@ public sealed class MediaOptions /// Таймаут одного вызова ffmpeg (транскод/рендер заставки), секунды (0 — без таймаута). /// Не даёт зависшему процессу вечно держать слот параллелизма. Поднять для очень длинных файлов. public int TranscodeTimeoutSeconds { get; init; } = 1800; + + /// Предпочитаемые языки аудиодорожек по умолчанию, через запятую в порядке приоритета + /// (напр. «rus,eng»). Используется, если админ не задал список в настройках сайта. Пусто — без + /// предпочтения (дефолтный выбор ffmpeg). + public string PreferredAudioLanguages { get; init; } = string.Empty; } diff --git a/backend/src/TeleWave.Infrastructure/Settings/SiteSettings.cs b/backend/src/TeleWave.Infrastructure/Settings/SiteSettings.cs index 34dec09..a968805 100644 --- a/backend/src/TeleWave.Infrastructure/Settings/SiteSettings.cs +++ b/backend/src/TeleWave.Infrastructure/Settings/SiteSettings.cs @@ -14,13 +14,23 @@ public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken) { - var value = enabled ? "true" : "false"; + await UpsertAsync(SettingKeys.RegistrationEnabled, enabled ? "true" : "false", cancellationToken); + } + + public Task GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) => + dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken); + + public Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken) => + UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken); + + private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken) + { var existing = await dbContext.AppSettings.FirstOrDefaultAsync( - s => s.Key == SettingKeys.RegistrationEnabled, + s => s.Key == key, cancellationToken ); if (existing is null) - dbContext.AppSettings.Add(AppSetting.Create(SettingKeys.RegistrationEnabled, value)); + dbContext.AppSettings.Add(AppSetting.Create(key, value)); else existing.SetValue(value); } diff --git a/backend/tests/TeleWave.Application.Tests/Media/AudioTrackSelectorTests.cs b/backend/tests/TeleWave.Application.Tests/Media/AudioTrackSelectorTests.cs new file mode 100644 index 0000000..add9fe4 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/AudioTrackSelectorTests.cs @@ -0,0 +1,51 @@ +using TeleWave.Application.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Media; + +public class AudioTrackSelectorTests +{ + [Fact] + public void PicksFirstPreferredLanguage_ByPriorityOrder() + { + // Дорожки: eng(0), rus(1), jpn(2). Предпочтение rus,eng → выбираем rus (индекс 1). + var track = AudioTrackSelector.Select(["eng", "rus", "jpn"], ["rus", "eng"]); + Assert.Equal(1, track); + } + + [Fact] + public void FallsBackToNextPreferred_WhenTopMissing() + { + // Нет rus — берём следующий по приоритету eng (индекс 0). + var track = AudioTrackSelector.Select(["eng", "jpn"], ["rus", "eng"]); + Assert.Equal(0, track); + } + + [Fact] + public void CaseInsensitiveMatch() + { + var track = AudioTrackSelector.Select(["ENG", "RUS"], ["rus"]); + Assert.Equal(1, track); + } + + [Fact] + public void NoMatch_ReturnsNull_KeepingFfmpegDefault() + { + var track = AudioTrackSelector.Select(["eng", "jpn"], ["rus"]); + Assert.Null(track); + } + + [Fact] + public void SingleTrack_ReturnsNull() + { + var track = AudioTrackSelector.Select(["eng"], ["eng"]); + Assert.Null(track); + } + + [Fact] + public void NoPreference_ReturnsNull() + { + var track = AudioTrackSelector.Select(["eng", "rus"], []); + Assert.Null(track); + } +} diff --git a/frontend/src/features/admin/settings/SettingsPanel.tsx b/frontend/src/features/admin/settings/SettingsPanel.tsx index 1d0b4ab..ef2b27e 100644 --- a/frontend/src/features/admin/settings/SettingsPanel.tsx +++ b/frontend/src/features/admin/settings/SettingsPanel.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { HttpError } from '@/shared/api/client' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' import { toast } from '@/shared/ui/toast-store' import { getSiteSettings, updateSiteSettings } from './api' @@ -11,6 +12,7 @@ export function SettingsPanel() { const { t } = useTranslation() const queryClient = useQueryClient() const [registrationEnabled, setRegistrationEnabled] = useState(false) + const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('') const { data, isLoading } = useQuery({ queryKey: ['admin', 'settings'], @@ -18,11 +20,14 @@ export function SettingsPanel() { }) useEffect(() => { - if (data) setRegistrationEnabled(data.registrationEnabled) + if (data) { + setRegistrationEnabled(data.registrationEnabled) + setPreferredAudioLanguages(data.preferredAudioLanguages) + } }, [data]) const save = useMutation({ - mutationFn: () => updateSiteSettings({ registrationEnabled }), + mutationFn: () => updateSiteSettings({ registrationEnabled, preferredAudioLanguages }), onSuccess: () => { toast.success(t('settings.saved')) void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }) @@ -50,6 +55,24 @@ export function SettingsPanel() { /> {t('admin.settings.registrationLabel')} + +
+ + setPreferredAudioLanguages(e.target.value)} + /> +

+ {t('admin.settings.preferredAudioHint')} +

+
+