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')} +

+
+