Add UploadLimits service and refactor user and media listing endpoints to use filter records for improved parameter handling. Update audio upload logic to streamline parameter passing and enhance clarity. Adjust ImageEndpoints to use HashSet for allowed extensions, and refine StreamingEndpoints to clarify token handling.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Infrastructure.Media;
|
||||
|
||||
namespace TeleWave.Api.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Лимиты приёма файла, собранные в одно место. Они живут в двух разных секциях конфигурации
|
||||
/// (<c>Media</c> и <c>Storage</c>), но проверяются всегда вместе и только на входе загрузки —
|
||||
/// хендлеру незачем знать про обе секции и тащить два <see cref="IOptions{T}"/> в сигнатуре.
|
||||
/// </summary>
|
||||
public sealed class UploadLimits(
|
||||
IOptions<MediaOptions> media,
|
||||
IOptions<StorageOptions> storage
|
||||
)
|
||||
{
|
||||
/// <summary>Потолок размера загружаемого файла.</summary>
|
||||
public long MaxUploadBytes { get; } = media.Value.MaxUploadBytes;
|
||||
|
||||
/// <summary>Сколько места должно остаться свободным после приёма файла.</summary>
|
||||
public long MinFreeSpaceBytes { get; } = storage.Value.MinFreeSpaceBytes;
|
||||
}
|
||||
@@ -32,31 +32,21 @@ public static class AdminUserEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query превращает
|
||||
/// запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело.
|
||||
/// </summary>
|
||||
private static async Task<IResult> ListUsers(
|
||||
[AsParameters] ListUsersFilter filter,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
string? search = null,
|
||||
Guid? roleId = null,
|
||||
bool? isBlocked = null,
|
||||
string? sort = null,
|
||||
bool desc = false
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ListUsersQuery(
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? 20 : pageSize,
|
||||
search,
|
||||
roleId,
|
||||
isBlocked,
|
||||
sort,
|
||||
desc
|
||||
filter.Page is > 0 ? filter.Page.Value : 1,
|
||||
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
|
||||
filter.Search,
|
||||
filter.RoleId,
|
||||
filter.IsBlocked,
|
||||
filter.Sort,
|
||||
filter.Desc ?? false
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
@@ -126,6 +116,22 @@ public static class AdminUserEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Фильтр и страница списка пользователей. Все поля nullable намеренно: обязательный параметр в query
|
||||
/// превращает запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело. Умолчания
|
||||
/// подставляет сам хендлер, а не объявление: при <c>AsParameters</c> обязательность определяется
|
||||
/// nullable-типом, а не наличием значения по умолчанию в конструкторе.
|
||||
/// </summary>
|
||||
public sealed record ListUsersFilter(
|
||||
int? Page,
|
||||
int? PageSize,
|
||||
string? Search,
|
||||
Guid? RoleId,
|
||||
bool? IsBlocked,
|
||||
string? Sort,
|
||||
bool? Desc
|
||||
);
|
||||
|
||||
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
|
||||
|
||||
public sealed record ResetPasswordBody(string NewPassword);
|
||||
|
||||
@@ -66,9 +66,7 @@ public static partial class ChannelEndpoints
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadTemplateAudio(
|
||||
Guid id,
|
||||
Guid templateId,
|
||||
string fileName,
|
||||
[AsParameters] BumperAudioUpload upload,
|
||||
HttpRequest request,
|
||||
IBumperTemplateStorage storage,
|
||||
IAudioProbe probe,
|
||||
@@ -76,23 +74,31 @@ public static partial class ChannelEndpoints
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext)
|
||||
if (
|
||||
ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions)
|
||||
is not { } ext
|
||||
)
|
||||
return ChannelErrors.InvalidBumperFile.ToProblem();
|
||||
|
||||
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
|
||||
await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken);
|
||||
|
||||
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
||||
var path = storage.AudioPath(templateId, ext);
|
||||
var path = storage.AudioPath(upload.TemplateId, ext);
|
||||
var duration = path is null
|
||||
? null
|
||||
: await probe.TryGetDurationAsync(path, cancellationToken);
|
||||
|
||||
var result = await sender.Send(
|
||||
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0),
|
||||
new SetBumperTemplateAudioCommand(
|
||||
upload.Id,
|
||||
upload.TemplateId,
|
||||
ext,
|
||||
duration?.TotalSeconds ?? 0
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
await storage.DeleteAudioAsync(templateId, cancellationToken);
|
||||
await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
@@ -242,13 +248,12 @@ public static partial class ChannelEndpoints
|
||||
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
private static IResult PreviewSegment(
|
||||
Guid id,
|
||||
Guid templateId,
|
||||
Guid variantId,
|
||||
string file,
|
||||
MediaPathResolver paths
|
||||
)
|
||||
/// <summary>
|
||||
/// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью
|
||||
/// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные
|
||||
/// параметры маршрута просто не связываются.
|
||||
/// </summary>
|
||||
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
|
||||
{
|
||||
if (!SegmentFiles.IsSegmentName(file))
|
||||
return Results.NotFound();
|
||||
@@ -300,6 +305,13 @@ public sealed record UpdateBumperVariantBody(
|
||||
int Weight
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему
|
||||
/// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два
|
||||
/// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться.
|
||||
/// </summary>
|
||||
public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName);
|
||||
|
||||
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
|
||||
internal static class BumperFiles
|
||||
{
|
||||
|
||||
@@ -15,7 +15,9 @@ public static class ImageEndpoints
|
||||
{
|
||||
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
|
||||
|
||||
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
|
||||
// Тип поля — HashSet, а не IReadOnlySet: поле приватное, и через интерфейс Contains уходит
|
||||
// в виртуальный вызов вместо прямого.
|
||||
private static readonly HashSet<string> AllowedExtensions = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
{
|
||||
|
||||
@@ -51,8 +51,7 @@ public static class MediaEndpoints
|
||||
IMediaStorage storage,
|
||||
IMediaProcessingQueue queue,
|
||||
ISender sender,
|
||||
IOptions<MediaOptions> mediaOptions,
|
||||
IOptions<StorageOptions> storageOptions,
|
||||
UploadLimits limits,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
@@ -71,7 +70,7 @@ public static class MediaEndpoints
|
||||
);
|
||||
|
||||
var contentLength = request.ContentLength ?? 0;
|
||||
if (contentLength > mediaOptions.Value.MaxUploadBytes)
|
||||
if (contentLength > limits.MaxUploadBytes)
|
||||
return Results.Problem(
|
||||
title: MediaErrors.FileTooLarge.Code,
|
||||
detail: MediaErrors.FileTooLarge.Message,
|
||||
@@ -79,7 +78,7 @@ public static class MediaEndpoints
|
||||
);
|
||||
|
||||
var free = storage.GetAvailableFreeSpaceBytes();
|
||||
if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes)
|
||||
if (free - contentLength < limits.MinFreeSpaceBytes)
|
||||
return Results.Problem(
|
||||
title: MediaErrors.InsufficientStorage.Code,
|
||||
detail: MediaErrors.InsufficientStorage.Message,
|
||||
@@ -106,30 +105,20 @@ public static class MediaEndpoints
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query заставлял
|
||||
/// минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые ассеты»,
|
||||
/// которым сортировка не нужна.
|
||||
/// </summary>
|
||||
private static async Task<IResult> List(
|
||||
[AsParameters] ListMediaFilter filter,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
MediaAssetStatus[]? status = null,
|
||||
string? search = null,
|
||||
string? sort = null,
|
||||
bool desc = false
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ListMediaAssetsQuery(
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? 20 : pageSize,
|
||||
status ?? [],
|
||||
search,
|
||||
sort,
|
||||
desc
|
||||
filter.Page is > 0 ? filter.Page.Value : 1,
|
||||
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
|
||||
filter.Status ?? [],
|
||||
filter.Search,
|
||||
filter.Sort,
|
||||
filter.Desc ?? false
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
@@ -204,6 +193,21 @@ public static class MediaEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
|
||||
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
|
||||
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
|
||||
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
|
||||
/// </summary>
|
||||
public sealed record ListMediaFilter(
|
||||
int? Page,
|
||||
int? PageSize,
|
||||
MediaAssetStatus[]? Status,
|
||||
string? Search,
|
||||
string? Sort,
|
||||
bool? Desc
|
||||
);
|
||||
|
||||
public sealed record UploadMediaResponse(Guid Id);
|
||||
|
||||
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
|
||||
|
||||
@@ -51,8 +51,12 @@ public static class StreamingEndpoints
|
||||
new ViewerFeaturesDto(await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken))
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
|
||||
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
|
||||
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
|
||||
/// </summary>
|
||||
private static IResult Watch(
|
||||
string slug,
|
||||
ICurrentUser currentUser,
|
||||
StreamTokenService tokens,
|
||||
HttpRequest request,
|
||||
|
||||
@@ -61,6 +61,7 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton<UploadLimits>();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user