Update configuration and enhance media processing: add stream token TTL and timeout settings in .env.example, improve error handling in media endpoints, and refactor command handlers for asynchronous operations. Update documentation to reflect current application state and features.
This commit is contained in:
@@ -17,15 +17,23 @@ public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemp
|
||||
CancellationToken cancellationToken
|
||||
) => SaveAsync(templateId, Audio, extension, content, cancellationToken);
|
||||
|
||||
public void DeleteAudio(Guid templateId) => DeleteKind(templateId, Audio);
|
||||
|
||||
public void DeleteTemplate(Guid templateId)
|
||||
public Task DeleteAudioAsync(Guid templateId, CancellationToken cancellationToken)
|
||||
{
|
||||
var dir = paths.BumperTemplateDir(templateId);
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
DeleteKind(templateId, Audio);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteTemplateAsync(Guid templateId, CancellationToken cancellationToken) =>
|
||||
Task.Run(
|
||||
() =>
|
||||
{
|
||||
var dir = paths.BumperTemplateDir(templateId);
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
public string? AudioPath(Guid templateId, string? extension) =>
|
||||
ResolvePath(templateId, Audio, extension);
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ public sealed class FfmpegBumperRenderer(
|
||||
_media.FfmpegPath,
|
||||
args,
|
||||
lowPriority: true,
|
||||
TimeSpan.FromSeconds(_media.TranscodeTimeoutSeconds),
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
|
||||
@@ -49,6 +49,7 @@ public sealed class FfmpegMediaProcessor(
|
||||
_media.FfmpegPath,
|
||||
args,
|
||||
lowPriority: true,
|
||||
TimeSpan.FromSeconds(_media.TranscodeTimeoutSeconds),
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
@@ -169,6 +170,7 @@ public sealed class FfmpegMediaProcessor(
|
||||
_media.FfprobePath,
|
||||
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path],
|
||||
lowPriority: false,
|
||||
TimeSpan.FromSeconds(_media.ProbeTimeoutSeconds),
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class FfprobeAudioProbe(IOptions<MediaOptions> mediaOptions) : IAu
|
||||
_media.FfprobePath,
|
||||
["-v", "quiet", "-print_format", "json", "-show_format", absolutePath],
|
||||
lowPriority: false,
|
||||
TimeSpan.FromSeconds(_media.ProbeTimeoutSeconds),
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
|
||||
@@ -43,11 +43,12 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
||||
return token;
|
||||
}
|
||||
|
||||
public void DeleteUpload(string uploadToken)
|
||||
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = paths.UploadPath(uploadToken);
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PromoteToOriginalAsync(
|
||||
@@ -72,14 +73,24 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void DeleteAssetArtifacts(Guid assetId, string extension)
|
||||
{
|
||||
var original = paths.OriginalPath(assetId, extension);
|
||||
if (File.Exists(original))
|
||||
File.Delete(original);
|
||||
public Task DeleteAssetArtifactsAsync(
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
|
||||
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
|
||||
Task.Run(
|
||||
() =>
|
||||
{
|
||||
var original = paths.OriginalPath(assetId, extension);
|
||||
if (File.Exists(original))
|
||||
File.Delete(original);
|
||||
|
||||
var assetDir = paths.AssetDir(assetId);
|
||||
if (Directory.Exists(assetDir))
|
||||
Directory.Delete(assetDir, recursive: true);
|
||||
}
|
||||
var assetDir = paths.AssetDir(assetId);
|
||||
if (Directory.Exists(assetDir))
|
||||
Directory.Delete(assetDir, recursive: true);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@ public sealed class ImageStore(MediaPathResolver paths) : IImageStore
|
||||
await File.WriteAllBytesAsync(path, content, cancellationToken);
|
||||
}
|
||||
|
||||
public void Delete(Guid imageId, string extension)
|
||||
public Task DeleteAsync(Guid imageId, string extension, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = paths.ImagePath(imageId, Normalize(extension));
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public string? ResolvePath(Guid imageId, string extension)
|
||||
|
||||
@@ -28,4 +28,12 @@ public sealed class MediaOptions
|
||||
|
||||
/// <summary>Целевая интегральная громкость, LUFS (−16 — типично для стриминга; тише — уменьшить).</summary>
|
||||
public int LoudnessTargetLufs { get; init; } = -16;
|
||||
|
||||
/// <summary>Таймаут одного вызова ffprobe, секунды (0 — без таймаута). Пробирование метаданных —
|
||||
/// быстрая операция; страхует от подвисшего ffprobe на битом/сетевом источнике.</summary>
|
||||
public int ProbeTimeoutSeconds { get; init; } = 120;
|
||||
|
||||
/// <summary>Таймаут одного вызова ffmpeg (транскод/рендер заставки), секунды (0 — без таймаута).
|
||||
/// Не даёт зависшему процессу вечно держать слот параллелизма. Поднять для очень длинных файлов.</summary>
|
||||
public int TranscodeTimeoutSeconds { get; init; } = 1800;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ internal static class ProcessRunner
|
||||
string fileName,
|
||||
IEnumerable<string> arguments,
|
||||
bool lowPriority,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
@@ -58,13 +59,30 @@ internal static class ProcessRunner
|
||||
}
|
||||
}
|
||||
|
||||
// Таймаут (если задан) — отдельный источник отмены, слитый с внешним: зависший ffmpeg/ffprobe
|
||||
// (битый источник, -stream_loop и т.п.) не должен держать слот параллелизма/тик планировщика вечно.
|
||||
using var timeoutCts =
|
||||
timeout > TimeSpan.Zero ? new CancellationTokenSource(timeout) : null;
|
||||
using var linked =
|
||||
timeoutCts is null
|
||||
? null
|
||||
: CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
timeoutCts.Token
|
||||
);
|
||||
var waitToken = linked?.Token ?? cancellationToken;
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(waitToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryKill(process);
|
||||
if (timeoutCts is { IsCancellationRequested: true } && !cancellationToken.IsCancellationRequested)
|
||||
throw new TimeoutException(
|
||||
$"Процесс {fileName} превысил таймаут {timeout.TotalSeconds:0}с и был прерван."
|
||||
);
|
||||
throw;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Auth;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
@@ -30,6 +31,21 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||
public DbSet<Image> Images => Set<Image>();
|
||||
|
||||
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
|
||||
Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken)
|
||||
{
|
||||
// pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции.
|
||||
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
|
||||
// корректности не нарушают.
|
||||
var key = BitConverter.ToInt64(channelId.ToByteArray());
|
||||
return Database.ExecuteSqlAsync(
|
||||
$"SELECT pg_advisory_xact_lock({key})",
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Infrastructure.Streaming;
|
||||
@@ -8,29 +9,37 @@ namespace TeleWave.Infrastructure.Streaming;
|
||||
/// <summary>
|
||||
/// Короткоживущий подписанный токен для cookie <c>tw_stream</c>: выдаётся авторизованному зрителю,
|
||||
/// проверяется на запросах плейлиста и сегментов (работает и с нативным HLS, где заголовок не
|
||||
/// поставить). Подпись — HMAC-SHA256 на том же ключе, что и JWT.
|
||||
/// поставить). Подпись — HMAC-SHA256 на том же ключе, что и JWT. TTL короткий (см.
|
||||
/// <see cref="StreamingOptions.StreamTokenMinutes"/>), а <see cref="Validate"/> возвращает id зрителя —
|
||||
/// эндпоинт плейлиста дополнительно сверяет, что пользователь не заблокирован.
|
||||
/// </summary>
|
||||
public sealed class StreamTokenService(IOptions<JwtOptions> jwtOptions)
|
||||
public sealed class StreamTokenService(
|
||||
IOptions<JwtOptions> jwtOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
)
|
||||
{
|
||||
private static readonly TimeSpan Ttl = TimeSpan.FromHours(6);
|
||||
private readonly TimeSpan _ttl = TimeSpan.FromMinutes(
|
||||
Math.Max(1, streamingOptions.Value.StreamTokenMinutes)
|
||||
);
|
||||
private readonly byte[] _key = Encoding.UTF8.GetBytes(jwtOptions.Value.SigningKey);
|
||||
|
||||
public (string Token, DateTimeOffset ExpiresAt) Issue(Guid userId)
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(Ttl);
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(_ttl);
|
||||
var payload = $"{userId:N}.{expiresAt.ToUnixTimeSeconds()}";
|
||||
var token = $"{Base64Url(Encoding.UTF8.GetBytes(payload))}.{Base64Url(Sign(payload))}";
|
||||
return (token, expiresAt);
|
||||
}
|
||||
|
||||
public bool Validate(string? token)
|
||||
/// <summary>Проверяет подпись и срок; при успехе возвращает id зрителя из токена, иначе null.</summary>
|
||||
public Guid? Validate(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
return null;
|
||||
|
||||
byte[] payloadBytes;
|
||||
byte[] signature;
|
||||
@@ -41,19 +50,22 @@ public sealed class StreamTokenService(IOptions<JwtOptions> jwtOptions)
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = Encoding.UTF8.GetString(payloadBytes);
|
||||
var expected = Sign(payload);
|
||||
if (!CryptographicOperations.FixedTimeEquals(signature, expected))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var dot = payload.LastIndexOf('.');
|
||||
if (dot < 0 || !long.TryParse(payload.AsSpan(dot + 1), out var expiresUnix))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
return DateTimeOffset.FromUnixTimeSeconds(expiresUnix) > DateTimeOffset.UtcNow;
|
||||
if (DateTimeOffset.FromUnixTimeSeconds(expiresUnix) <= DateTimeOffset.UtcNow)
|
||||
return null;
|
||||
|
||||
return Guid.TryParseExact(payload[..dot], "N", out var userId) ? userId : null;
|
||||
}
|
||||
|
||||
private byte[] Sign(string payload) =>
|
||||
|
||||
Reference in New Issue
Block a user