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.
This commit is contained in:
@@ -66,6 +66,10 @@ Media__LoudnessTargetLufs=-16
|
||||
# держать слот параллелизма/тик планировщика. Поднять TranscodeTimeoutSeconds для очень длинных файлов.
|
||||
# Media__ProbeTimeoutSeconds=120
|
||||
# Media__TranscodeTimeoutSeconds=1800
|
||||
# Предпочитаемые языки аудиодорожек ПО УМОЛЧАНИЮ (через запятую, в порядке приоритета) — если файл
|
||||
# многоозвучный и есть дорожка с таким языком, при обработке берётся она. Обычно задаётся в админке
|
||||
# («Настройки»); этот конфиг — дефолт, если в настройках пусто. Пусто — выбор ffmpeg по умолчанию.
|
||||
# Media__PreferredAudioLanguages=rus,eng
|
||||
|
||||
# ── ТВ-заставки «Сейчас/Далее» (bumpers) ───────────────────────────────────
|
||||
# Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,4 +7,10 @@ public interface ISiteSettings
|
||||
|
||||
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
|
||||
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
|
||||
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace TeleWave.Application.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Выбор аудиодорожки при обработке по предпочитаемым языкам. Чистая логика (без ffmpeg/БД) — общая
|
||||
/// точка для медиа-процессора и тестов.
|
||||
/// </summary>
|
||||
public static class AudioTrackSelector
|
||||
{
|
||||
/// <summary>
|
||||
/// Возвращает порядковый номер аудиодорожки (0:a:N) по предпочитаемым языкам: побеждает первый
|
||||
/// язык из списка <paramref name="preferred"/>, для которого есть дорожка (среди таких — первая по
|
||||
/// порядку). Нет совпадений / нет предпочтений / меньше двух дорожек — null (дефолтная эвристика
|
||||
/// ffmpeg: пусть выбирает сам).
|
||||
/// </summary>
|
||||
public static int? Select(
|
||||
IReadOnlyList<string?> audioLanguages,
|
||||
IReadOnlyList<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,8 @@ public static class SettingKeys
|
||||
{
|
||||
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
|
||||
public const string RegistrationEnabled = "registration.enabled";
|
||||
|
||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
|
||||
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace TeleWave.Application.Settings;
|
||||
|
||||
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
|
||||
public sealed record SiteSettingsDto(bool RegistrationEnabled);
|
||||
public sealed record SiteSettingsDto(bool RegistrationEnabled, string PreferredAudioLanguages);
|
||||
|
||||
+2
-1
@@ -3,4 +3,5 @@ using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||
|
||||
public sealed record UpdateSiteSettingsCommand(bool RegistrationEnabled) : ICommand<Result>;
|
||||
public sealed record UpdateSiteSettingsCommand(bool RegistrationEnabled, string PreferredAudioLanguages)
|
||||
: ICommand<Result>;
|
||||
|
||||
+8
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
public sealed class FfmpegMediaProcessor(
|
||||
MediaPathResolver paths,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<StorageOptions> storageOptions,
|
||||
IOptions<MediaOptions> 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<string?>();
|
||||
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<IReadOnlyList<string>> GetPreferredLanguagesAsync(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<ISiteSettings>();
|
||||
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<string?> AudioLanguages
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,4 +36,9 @@ public sealed class MediaOptions
|
||||
/// <summary>Таймаут одного вызова ffmpeg (транскод/рендер заставки), секунды (0 — без таймаута).
|
||||
/// Не даёт зависшему процессу вечно держать слот параллелизма. Поднять для очень длинных файлов.</summary>
|
||||
public int TranscodeTimeoutSeconds { get; init; } = 1800;
|
||||
|
||||
/// <summary>Предпочитаемые языки аудиодорожек по умолчанию, через запятую в порядке приоритета
|
||||
/// (напр. «rus,eng»). Используется, если админ не задал список в настройках сайта. Пусто — без
|
||||
/// предпочтения (дефолтный выбор ffmpeg).</summary>
|
||||
public string PreferredAudioLanguages { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -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<string> 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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')}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="preferred-audio">
|
||||
{t('admin.settings.preferredAudio')}
|
||||
</label>
|
||||
<Input
|
||||
id="preferred-audio"
|
||||
className="max-w-xs"
|
||||
placeholder="rus, eng"
|
||||
value={preferredAudioLanguages}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.settings.preferredAudioHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -18,7 +18,7 @@ export type AuthResponse = {
|
||||
|
||||
export type RegistrationStatus = { enabled: boolean }
|
||||
|
||||
export type SiteSettings = { registrationEnabled: boolean }
|
||||
export type SiteSettings = { registrationEnabled: boolean; preferredAudioLanguages: string }
|
||||
|
||||
export type RoleDto = {
|
||||
id: string
|
||||
|
||||
@@ -351,6 +351,9 @@ const resources = {
|
||||
registrationHint:
|
||||
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
|
||||
registrationLabel: 'Разрешить регистрацию на сайте',
|
||||
preferredAudio: 'Предпочитаемые озвучки',
|
||||
preferredAudioHint:
|
||||
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
|
||||
},
|
||||
metadata: {
|
||||
title: 'Метаданные',
|
||||
@@ -728,6 +731,9 @@ const resources = {
|
||||
registrationHint:
|
||||
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
|
||||
registrationLabel: 'Allow public registration',
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user