Refactor user and media endpoints: remove unused GetUser and GetMedia methods, streamline AdminUserEndpoints and MediaEndpoints, and enhance metadata handling in MetadataEndpoints. Update Program.cs to remove legacy image relocation logic and improve overall code clarity.
build / backend (push) Successful in 1m14s
build / frontend (push) Successful in 35s
tests / backend-tests (push) Successful in 3m16s

This commit is contained in:
Leonid Pershin
2026-07-26 02:58:17 +03:00
parent 44e954920c
commit a0533a8ed8
50 changed files with 230 additions and 849 deletions
@@ -122,7 +122,11 @@ public sealed class BumperRenderBackgroundService(
var spec = await BuildSpecAsync(db, assetId, cancellationToken);
if (spec is null)
{
await FailAsync(assetId, "Не удалось восстановить спецификацию заставки", cancellationToken);
await FailAsync(
assetId,
"Не удалось восстановить спецификацию заставки",
cancellationToken
);
return;
}
@@ -195,7 +199,11 @@ public sealed class BumperRenderBackgroundService(
if (variant.Kind == Domain.Broadcast.BumperTextKind.NowNext)
posterPath = await ResolveShowPosterAsync(db, cache.ToShowId, cancellationToken);
var bgPath = await ResolveTemplateBackgroundAsync(db, template.BackgroundImageId, cancellationToken);
var bgPath = await ResolveTemplateBackgroundAsync(
db,
template.BackgroundImageId,
cancellationToken
);
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
@@ -1,20 +1,6 @@
using System.Threading.Channels;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>Сигнальная очередь-будильник поверх Channel (id ассета — лишь сигнал; работу берём из БД).</summary>
public sealed class BumperRenderQueue : IBumperRenderQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions { SingleReader = true }
);
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
public async ValueTask WaitAsync(CancellationToken cancellationToken)
{
await _channel.Reader.ReadAsync(cancellationToken);
while (_channel.Reader.TryRead(out _)) { }
}
}
/// <summary>Сигнальная очередь-будильник рендерера заставок (см. <see cref="SignalQueue"/>).</summary>
public sealed class BumperRenderQueue : SignalQueue, IBumperRenderQueue { }
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Text;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using static TeleWave.Infrastructure.Media.FfmpegText;
namespace TeleWave.Infrastructure.Media;
@@ -221,9 +222,7 @@ public sealed class FfmpegBumperRenderer(
var nextSize = FitSize(spec.NextTitle, titleSize, textWidth);
vchain
.Append(',')
.Append(
DrawLabel(font, nowLabelFile, spec.AccentColor, labelSize, nowLabelY, 0.2)
);
.Append(DrawLabel(font, nowLabelFile, spec.AccentColor, labelSize, nowLabelY, 0.2));
vchain
.Append(',')
.Append(DrawTitle(font, nowFile, spec.TextColor, nowSize, nowTitleY, 0.3));
@@ -340,9 +339,6 @@ public sealed class FfmpegBumperRenderer(
/// двоеточие экранируется). На Linux (контейнере) — фактически no-op.</summary>
private static string EscapePath(string path) => path.Replace('\\', '/').Replace(":", "\\:");
private static string Fmt(double value) =>
value.ToString("0.###", CultureInfo.InvariantCulture);
private static void TryDelete(string path)
{
try
@@ -355,11 +351,4 @@ public sealed class FfmpegBumperRenderer(
// Файл-подсказка для drawtext; не критично, если не удалился.
}
}
private static string Tail(string text)
{
text = text.Trim();
const int max = 500;
return text.Length <= max ? text : text[^max..];
}
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media;
using static TeleWave.Infrastructure.Media.FfmpegText;
namespace TeleWave.Infrastructure.Media;
@@ -98,14 +99,7 @@ public sealed class FfmpegMediaProcessor(
if (padding)
vfilter += $",tpad=stop_duration={Fmt(pad)}:stop_mode=add:color=black";
var args = new List<string>
{
"-hide_banner",
"-nostdin",
"-y",
"-i",
input,
};
var args = new List<string> { "-hide_banner", "-nostdin", "-y", "-i", input };
// Явный выбор дорожки по предпочитаемому языку: маппим видео + конкретную аудиодорожку.
// Без выбора (audioTrack == null) — не маппим, оставляя дефолтную эвристику ffmpeg (как раньше).
@@ -262,13 +256,17 @@ public sealed class FfmpegMediaProcessor(
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)
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)
@@ -279,16 +277,6 @@ public sealed class FfmpegMediaProcessor(
return (MaxWidth, scaledHeight);
}
private static string Fmt(double value) =>
value.ToString("0.###", CultureInfo.InvariantCulture);
private static string Tail(string text)
{
text = text.Trim();
const int max = 500;
return text.Length <= max ? text : text[^max..];
}
private sealed record ProbeInfo(
double Duration,
int Width,
@@ -0,0 +1,18 @@
using System.Globalization;
namespace TeleWave.Infrastructure.Media;
/// <summary>Общие текстовые хелперы для сборки аргументов ffmpeg и разбора его вывода.</summary>
internal static class FfmpegText
{
/// <summary>Формат double для фильтров/аргументов ffmpeg (инвариантная культура, до 3 знаков).</summary>
public static string Fmt(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
/// <summary>Хвост stderr для сообщения об ошибке (последние 500 символов).</summary>
public static string Tail(string text)
{
text = text.Trim();
const int max = 500;
return text.Length <= max ? text : text[^max..];
}
}
@@ -18,7 +18,6 @@ public sealed class MediaPathResolver
OriginalsDir = Path.Combine(_root, "originals");
AssetsDir = Path.Combine(_root, "assets");
BumpersDir = Path.Combine(_root, "bumpers");
MetadataDir = Path.Combine(_root, "metadata");
ImagesDir = Path.Combine(_root, "images");
}
@@ -30,9 +29,6 @@ public sealed class MediaPathResolver
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
public string BumpersDir { get; }
/// <summary>Картинки метаданных (постеры/кадры), скачанные локально.</summary>
public string MetadataDir { get; }
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
public string ImagesDir { get; }
@@ -43,47 +39,9 @@ public sealed class MediaPathResolver
Directory.CreateDirectory(OriginalsDir);
Directory.CreateDirectory(AssetsDir);
Directory.CreateDirectory(BumpersDir);
Directory.CreateDirectory(MetadataDir);
Directory.CreateDirectory(ImagesDir);
}
public string MetadataShowDir(Guid showId) =>
EnsureWithinRoot(Path.Combine(MetadataDir, "shows", showId.ToString("N")));
/// <summary>Абсолютный путь к файлу постера шоу (extension — с точкой).</summary>
public string MetadataShowPosterPath(Guid showId, string extension) =>
EnsureWithinRoot(
Path.Combine(MetadataDir, "shows", showId.ToString("N"), "poster" + extension)
);
/// <summary>Относительный путь постера от корня (для хранения в БД и отдачи).</summary>
public string MetadataShowPosterRelative(Guid showId, string extension) =>
$"metadata/shows/{showId:N}/poster{extension}";
public string MetadataEpisodeDir(Guid episodeId) =>
EnsureWithinRoot(Path.Combine(MetadataDir, "episodes", episodeId.ToString("N")));
public string MetadataEpisodeStillPath(Guid episodeId, string extension) =>
EnsureWithinRoot(
Path.Combine(MetadataDir, "episodes", episodeId.ToString("N"), "still" + extension)
);
public string MetadataEpisodeStillRelative(Guid episodeId, string extension) =>
$"metadata/episodes/{episodeId:N}/still{extension}";
/// <summary>Абсолютный путь по относительному (с защитой от traversal) или null, если вне корня.</summary>
public string? ResolveRelative(string relativePath)
{
try
{
return EnsureWithinRoot(Path.Combine(_root, relativePath));
}
catch (UnauthorizedAccessException)
{
return null;
}
}
public string BumperTemplateDir(Guid templateId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
@@ -1,21 +1,6 @@
using System.Threading.Channels;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>Сигнальная очередь-будильник поверх Channel (id ассета используется лишь как сигнал).</summary>
public sealed class MediaProcessingQueue : IMediaProcessingQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions { SingleReader = true }
);
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
public async ValueTask WaitAsync(CancellationToken cancellationToken)
{
await _channel.Reader.ReadAsync(cancellationToken);
// Сдренировать накопившиеся сигналы — работу всё равно берём из БД пачкой.
while (_channel.Reader.TryRead(out _)) { }
}
}
/// <summary>Сигнальная очередь-будильник медиа-конвейера (см. <see cref="SignalQueue"/>).</summary>
public sealed class MediaProcessingQueue : SignalQueue, IMediaProcessingQueue { }
@@ -63,13 +63,9 @@ internal static class ProcessRunner
// (битый источник, -stream_loop и т.п.) не должен держать слот параллелизма/тик планировщика вечно.
using var timeoutCts =
timeout > TimeSpan.Zero ? new CancellationTokenSource(timeout) : null;
using var linked =
timeoutCts is null
? null
: CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
timeoutCts.Token
);
using var linked = timeoutCts is null
? null
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var waitToken = linked?.Token ?? cancellationToken;
try
@@ -79,7 +75,10 @@ internal static class ProcessRunner
catch (OperationCanceledException)
{
TryKill(process);
if (timeoutCts is { IsCancellationRequested: true } && !cancellationToken.IsCancellationRequested)
if (
timeoutCts is { IsCancellationRequested: true }
&& !cancellationToken.IsCancellationRequested
)
throw new TimeoutException(
$"Процесс {fileName} превысил таймаут {timeout.TotalSeconds:0}с и был прерван."
);
@@ -0,0 +1,26 @@
using System.Threading.Channels;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Базовая сигнальная очередь-будильник поверх Channel: id ассета используется лишь как сигнал,
/// а работу фоновый обработчик всё равно берёт из БД пачкой (по статусу Pending). Поэтому потеря
/// сигнала при рестарте не теряет задачи — они подхватываются из базы.
/// </summary>
public abstract class SignalQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions { SingleReader = true }
);
/// <summary>Разбудить обработчик: появился ассет в статусе Pending.</summary>
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
/// <summary>Ждать сигнала о новой работе (с дренажом накопленных).</summary>
public async ValueTask WaitAsync(CancellationToken cancellationToken)
{
await _channel.Reader.ReadAsync(cancellationToken);
// Сдренировать накопившиеся сигналы — работу всё равно берём из БД пачкой.
while (_channel.Reader.TryRead(out _)) { }
}
}