diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..ea00aea
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,10 @@
+# Концы строк в репозитории — всегда LF. Бэкенд и так лежал в LF, фронтенд — в CRLF; Prettier
+# (endOfLine: lf по умолчанию) выровнял его, и этот файл не даёт разъехаться обратно: без него
+# при core.autocrlf=true у другого разработчика CRLF вернулись бы в коммит.
+* text=auto eol=lf
+
+# Бинарники не трогаем.
+*.png binary
+*.jpg binary
+*.ico binary
+*.woff2 binary
diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs
index a244360..b0e143a 100644
--- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs
@@ -1,247 +1,247 @@
-using LiteCqrs;
-using TeleWave.Api.Common;
-using TeleWave.Application.Broadcast;
-using TeleWave.Application.Broadcast.CreateChannel;
-using TeleWave.Application.Broadcast.GetChannel;
-using TeleWave.Application.Broadcast.GetSchedule;
-using TeleWave.Application.Broadcast.ListChannels;
-using TeleWave.Application.Broadcast.UpdateChannelSettings;
-using TeleWave.Application.Broadcast.UpdateChannelTime;
-using TeleWave.Application.Broadcast.UpdateViewerSettings;
-using TeleWave.Application.Programming.Planning.Trace;
-using TeleWave.Domain.Broadcast;
-using TeleWave.Infrastructure.Identity;
-
-namespace TeleWave.Api.Endpoints;
-
-///
-/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
-/// в ChannelEndpoints.Bumpers.cs. Что и когда идёт в эфире, задаёт шаблон сетки
-/// (TemplateEndpoints).
-///
-public static partial class ChannelEndpoints
-{
- public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
- {
- var admin = app.MapGroup("/api/admin/channels")
- .WithTags("Admin.Channels")
- .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
-
- admin.MapPost("", CreateChannel).Produces(StatusCodes.Status201Created);
- admin.MapGet("", ListChannels).Produces>();
- admin.MapGet("/{id:guid}", GetChannel).Produces();
- admin
- .MapPut("/{id:guid}/settings", UpdateSettings)
- .Produces(StatusCodes.Status204NoContent);
- admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
-
- admin
- .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
- .Produces(StatusCodes.Status201Created);
- admin
- .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapPut(
- "/{id:guid}/bumper/templates/{templateId:guid}/background",
- SetTemplateBackground
- )
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete(
- "/{id:guid}/bumper/templates/{templateId:guid}/background",
- ClearTemplateBackground
- )
- .Produces(StatusCodes.Status204NoContent);
-
- admin
- .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
- .Produces(StatusCodes.Status204NoContent);
- admin.MapGet(
- "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
- PreviewPlaylist
- );
- admin.MapGet(
- "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
- PreviewSegment
- );
-
- admin
- .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
- .Produces(StatusCodes.Status201Created);
- admin
- .MapPut(
- "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
- UpdateBumperVariant
- )
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete(
- "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
- RemoveBumperVariant
- )
- .Produces(StatusCodes.Status204NoContent);
-
- admin
- .MapGet("/{id:guid}/schedule", GetSchedule)
- .Produces>();
- admin
- .MapPut("/{id:guid}/viewer", UpdateViewerSettings)
- .Produces(StatusCodes.Status204NoContent);
-
- // «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
- admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces();
-
- return app;
- }
-
- private static async Task UpdateViewerSettings(
- Guid id,
- UpdateViewerSettingsBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new UpdateViewerSettingsCommand(
- id,
- body.LogoImageId,
- body.LogoCorner,
- body.LogoOpacity,
- body.ShowClock,
- body.AnalogFilterStrength
- ),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-
- private static async Task GetEntryTrace(
- Guid entryId,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
- return result.ToHttpResult();
- }
-
- private static async Task CreateChannel(
- CreateChannelCommand command,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(command, cancellationToken);
- return result.IsSuccess
- ? Results.Created(
- $"/api/admin/channels/{result.Value}",
- new CreatedIdResponse(result.Value)
- )
- : result.ToHttpResult();
- }
-
- private static async Task ListChannels(
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
- return Results.Ok(result);
- }
-
- private static async Task GetChannel(
- Guid id,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
- return result.ToHttpResult();
- }
-
- private static async Task UpdateTime(
- Guid id,
- UpdateChannelTimeBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-
- private static async Task UpdateSettings(
- Guid id,
- UpdateChannelSettingsBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new UpdateChannelSettingsCommand(
- id,
- body.Name,
- body.IsEnabled,
- body.BumpersEnabled,
- body.Bumper,
- body.FillerAssetId
- ),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-
- private static async Task GetSchedule(
- Guid id,
- DateTimeOffset? from,
- DateTimeOffset? to,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var fromUtc = from ?? DateTimeOffset.UtcNow;
- var toUtc = to ?? fromUtc.AddDays(1);
- var result = await sender.Send(
- new GetChannelScheduleQuery(id, fromUtc, toUtc),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-}
-
-/// Номер канала и его время: смещение от UTC и начало вещательных суток.
-public sealed record UpdateChannelTimeBody(
- int? Number,
- int UtcOffsetMinutes,
- TimeOnly DayStartTime
-);
-
-public sealed record UpdateChannelSettingsBody(
- string Name,
- bool IsEnabled,
- bool BumpersEnabled,
- BumperSettingsInput Bumper,
- Guid? FillerAssetId
-);
-
-/// Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).
-public sealed record UpdateViewerSettingsBody(
- Guid? LogoImageId,
- LogoCorner LogoCorner,
- double LogoOpacity,
- bool ShowClock,
- double AnalogFilterStrength
-);
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Broadcast;
+using TeleWave.Application.Broadcast.CreateChannel;
+using TeleWave.Application.Broadcast.GetChannel;
+using TeleWave.Application.Broadcast.GetSchedule;
+using TeleWave.Application.Broadcast.ListChannels;
+using TeleWave.Application.Broadcast.UpdateChannelSettings;
+using TeleWave.Application.Broadcast.UpdateChannelTime;
+using TeleWave.Application.Broadcast.UpdateViewerSettings;
+using TeleWave.Application.Programming.Planning.Trace;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+///
+/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
+/// в ChannelEndpoints.Bumpers.cs. Что и когда идёт в эфире, задаёт шаблон сетки
+/// (TemplateEndpoints).
+///
+public static partial class ChannelEndpoints
+{
+ public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/channels")
+ .WithTags("Admin.Channels")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapPost("", CreateChannel).Produces(StatusCodes.Status201Created);
+ admin.MapGet("", ListChannels).Produces>();
+ admin.MapGet("/{id:guid}", GetChannel).Produces();
+ admin
+ .MapPut("/{id:guid}/settings", UpdateSettings)
+ .Produces(StatusCodes.Status204NoContent);
+ admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
+ .Produces(StatusCodes.Status201Created);
+ admin
+ .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPut(
+ "/{id:guid}/bumper/templates/{templateId:guid}/background",
+ SetTemplateBackground
+ )
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete(
+ "/{id:guid}/bumper/templates/{templateId:guid}/background",
+ ClearTemplateBackground
+ )
+ .Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
+ .Produces(StatusCodes.Status204NoContent);
+ admin.MapGet(
+ "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
+ PreviewPlaylist
+ );
+ admin.MapGet(
+ "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
+ PreviewSegment
+ );
+
+ admin
+ .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
+ .Produces(StatusCodes.Status201Created);
+ admin
+ .MapPut(
+ "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
+ UpdateBumperVariant
+ )
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete(
+ "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
+ RemoveBumperVariant
+ )
+ .Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapGet("/{id:guid}/schedule", GetSchedule)
+ .Produces>();
+ admin
+ .MapPut("/{id:guid}/viewer", UpdateViewerSettings)
+ .Produces(StatusCodes.Status204NoContent);
+
+ // «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
+ admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces();
+
+ return app;
+ }
+
+ private static async Task UpdateViewerSettings(
+ Guid id,
+ UpdateViewerSettingsBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateViewerSettingsCommand(
+ id,
+ body.LogoImageId,
+ body.LogoCorner,
+ body.LogoOpacity,
+ body.ShowClock,
+ body.AnalogFilterStrength
+ ),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task GetEntryTrace(
+ Guid entryId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task CreateChannel(
+ CreateChannelCommand command,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(command, cancellationToken);
+ return result.IsSuccess
+ ? Results.Created(
+ $"/api/admin/channels/{result.Value}",
+ new CreatedIdResponse(result.Value)
+ )
+ : result.ToHttpResult();
+ }
+
+ private static async Task ListChannels(
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task GetChannel(
+ Guid id,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task UpdateTime(
+ Guid id,
+ UpdateChannelTimeBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task UpdateSettings(
+ Guid id,
+ UpdateChannelSettingsBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateChannelSettingsCommand(
+ id,
+ body.Name,
+ body.IsEnabled,
+ body.BumpersEnabled,
+ body.Bumper,
+ body.FillerAssetId
+ ),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task GetSchedule(
+ Guid id,
+ DateTimeOffset? from,
+ DateTimeOffset? to,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var fromUtc = from ?? DateTimeOffset.UtcNow;
+ var toUtc = to ?? fromUtc.AddDays(1);
+ var result = await sender.Send(
+ new GetChannelScheduleQuery(id, fromUtc, toUtc),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+}
+
+/// Номер канала и его время: смещение от UTC и начало вещательных суток.
+public sealed record UpdateChannelTimeBody(
+ int? Number,
+ int UtcOffsetMinutes,
+ TimeOnly DayStartTime
+);
+
+public sealed record UpdateChannelSettingsBody(
+ string Name,
+ bool IsEnabled,
+ bool BumpersEnabled,
+ BumperSettingsInput Bumper,
+ Guid? FillerAssetId
+);
+
+/// Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).
+public sealed record UpdateViewerSettingsBody(
+ Guid? LogoImageId,
+ LogoCorner LogoCorner,
+ double LogoOpacity,
+ bool ShowClock,
+ double AnalogFilterStrength
+);
diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs
index 4a40d50..5d71fc4 100644
--- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs
@@ -1,216 +1,216 @@
-using System.Text;
-using LiteCqrs;
-using Microsoft.Extensions.Options;
-using TeleWave.Api.Common;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Common.Models;
-using TeleWave.Application.Media;
-using TeleWave.Application.Media.Delete;
-using TeleWave.Application.Media.ListMedia;
-using TeleWave.Application.Media.ManualInbox;
-using TeleWave.Application.Media.Register;
-using TeleWave.Application.Media.Stats;
-using TeleWave.Domain.Media;
-using TeleWave.Infrastructure.Identity;
-using TeleWave.Infrastructure.Media;
-
-namespace TeleWave.Api.Endpoints;
-
-public static class MediaEndpoints
-{
- public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
- {
- var admin = app.MapGroup("/api/admin/media")
- .WithTags("Admin.Media")
- .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
-
- admin.MapPost("", Upload).Produces(StatusCodes.Status201Created);
- admin.MapGet("", List).Produces>();
- admin.MapGet("/stats", Stats).Produces();
- admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
-
- // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
- admin.MapGet("/manual", ListManual).Produces();
- admin.MapPost("/manual/import", ImportManual).Produces();
-
- // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
- // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
- admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
- admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
-
- return app;
- }
-
- ///
- /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
- /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
- ///
- private static async Task Upload(
- string fileName,
- HttpRequest request,
- IMediaStorage storage,
- IMediaProcessingQueue queue,
- ISender sender,
- UploadLimits limits,
- CancellationToken cancellationToken
- )
- {
- if (string.IsNullOrWhiteSpace(fileName))
- return Results.Problem(
- title: MediaErrors.EmptyFileName.Code,
- detail: MediaErrors.EmptyFileName.Message,
- statusCode: StatusCodes.Status400BadRequest
- );
-
- if (!MediaFormats.IsAllowed(fileName))
- return Results.Problem(
- title: MediaErrors.UnsupportedFormat.Code,
- detail: MediaErrors.UnsupportedFormat.Message,
- statusCode: StatusCodes.Status400BadRequest
- );
-
- var contentLength = request.ContentLength ?? 0;
- if (contentLength > limits.MaxUploadBytes)
- return Results.Problem(
- title: MediaErrors.FileTooLarge.Code,
- detail: MediaErrors.FileTooLarge.Message,
- statusCode: StatusCodes.Status400BadRequest
- );
-
- var free = storage.GetAvailableFreeSpaceBytes();
- if (free - contentLength < limits.MinFreeSpaceBytes)
- return Results.Problem(
- title: MediaErrors.InsufficientStorage.Code,
- detail: MediaErrors.InsufficientStorage.Message,
- statusCode: StatusCodes.Status409Conflict
- );
-
- var extension = Path.GetExtension(fileName);
- var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
-
- var result = await sender.Send(
- new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
- cancellationToken
- );
- if (!result.IsSuccess)
- {
- await storage.DeleteUploadAsync(token, cancellationToken);
- return result.ToHttpResult();
- }
-
- queue.Enqueue(result.Value);
- return Results.Created(
- $"/api/admin/media/{result.Value}",
- new UploadMediaResponse(result.Value)
- );
- }
-
- private static async Task List(
- [AsParameters] ListMediaFilter filter,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new ListMediaAssetsQuery(
- 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
- );
- return Results.Ok(result);
- }
-
- private static async Task Stats(ISender sender, CancellationToken cancellationToken)
- {
- var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
- return Results.Ok(result);
- }
-
- private static async Task Delete(
- Guid id,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
- return result.ToHttpResult();
- }
-
- private static async Task ListManual(
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
- return Results.Ok(result);
- }
-
- private static async Task ImportManual(
- ImportManualInboxBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new ImportManualInboxCommand(body.Items, body.ShowId),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-
- /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.
- private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
- {
- if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
- return Results.NotFound();
-
- var baseUrl = $"/api/admin/media/{id}/preview/";
- var sb = new StringBuilder();
- foreach (var line in File.ReadLines(indexPath))
- {
- var trimmed = line.Trim();
- if (trimmed.Length == 0)
- continue;
- // Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
- sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
- .Append('\n');
- }
-
- return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
- }
-
- private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
- {
- if (!SegmentFiles.IsSegmentName(file))
- return Results.NotFound();
-
- if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
- return Results.NotFound();
-
- return Results.File(path, "video/mp2t", enableRangeProcessing: true);
- }
-}
-
-///
-/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
-/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
-/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
-/// AsParameters обязательность определяется nullable-типом, а не значением по умолчанию.
-///
-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 Items, Guid ShowId);
+using System.Text;
+using LiteCqrs;
+using Microsoft.Extensions.Options;
+using TeleWave.Api.Common;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+using TeleWave.Application.Media;
+using TeleWave.Application.Media.Delete;
+using TeleWave.Application.Media.ListMedia;
+using TeleWave.Application.Media.ManualInbox;
+using TeleWave.Application.Media.Register;
+using TeleWave.Application.Media.Stats;
+using TeleWave.Domain.Media;
+using TeleWave.Infrastructure.Identity;
+using TeleWave.Infrastructure.Media;
+
+namespace TeleWave.Api.Endpoints;
+
+public static class MediaEndpoints
+{
+ public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/media")
+ .WithTags("Admin.Media")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapPost("", Upload).Produces(StatusCodes.Status201Created);
+ admin.MapGet("", List).Produces>();
+ admin.MapGet("/stats", Stats).Produces();
+ admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
+
+ // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
+ admin.MapGet("/manual", ListManual).Produces();
+ admin.MapPost("/manual/import", ImportManual).Produces();
+
+ // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
+ // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
+ admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
+ admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
+
+ return app;
+ }
+
+ ///
+ /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
+ /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
+ ///
+ private static async Task Upload(
+ string fileName,
+ HttpRequest request,
+ IMediaStorage storage,
+ IMediaProcessingQueue queue,
+ ISender sender,
+ UploadLimits limits,
+ CancellationToken cancellationToken
+ )
+ {
+ if (string.IsNullOrWhiteSpace(fileName))
+ return Results.Problem(
+ title: MediaErrors.EmptyFileName.Code,
+ detail: MediaErrors.EmptyFileName.Message,
+ statusCode: StatusCodes.Status400BadRequest
+ );
+
+ if (!MediaFormats.IsAllowed(fileName))
+ return Results.Problem(
+ title: MediaErrors.UnsupportedFormat.Code,
+ detail: MediaErrors.UnsupportedFormat.Message,
+ statusCode: StatusCodes.Status400BadRequest
+ );
+
+ var contentLength = request.ContentLength ?? 0;
+ if (contentLength > limits.MaxUploadBytes)
+ return Results.Problem(
+ title: MediaErrors.FileTooLarge.Code,
+ detail: MediaErrors.FileTooLarge.Message,
+ statusCode: StatusCodes.Status400BadRequest
+ );
+
+ var free = storage.GetAvailableFreeSpaceBytes();
+ if (free - contentLength < limits.MinFreeSpaceBytes)
+ return Results.Problem(
+ title: MediaErrors.InsufficientStorage.Code,
+ detail: MediaErrors.InsufficientStorage.Message,
+ statusCode: StatusCodes.Status409Conflict
+ );
+
+ var extension = Path.GetExtension(fileName);
+ var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
+
+ var result = await sender.Send(
+ new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
+ cancellationToken
+ );
+ if (!result.IsSuccess)
+ {
+ await storage.DeleteUploadAsync(token, cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ queue.Enqueue(result.Value);
+ return Results.Created(
+ $"/api/admin/media/{result.Value}",
+ new UploadMediaResponse(result.Value)
+ );
+ }
+
+ private static async Task List(
+ [AsParameters] ListMediaFilter filter,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new ListMediaAssetsQuery(
+ 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
+ );
+ return Results.Ok(result);
+ }
+
+ private static async Task Stats(ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task Delete(
+ Guid id,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task ListManual(
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task ImportManual(
+ ImportManualInboxBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new ImportManualInboxCommand(body.Items, body.ShowId),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.
+ private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
+ {
+ if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
+ return Results.NotFound();
+
+ var baseUrl = $"/api/admin/media/{id}/preview/";
+ var sb = new StringBuilder();
+ foreach (var line in File.ReadLines(indexPath))
+ {
+ var trimmed = line.Trim();
+ if (trimmed.Length == 0)
+ continue;
+ // Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
+ sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
+ .Append('\n');
+ }
+
+ return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
+ }
+
+ private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
+ {
+ if (!SegmentFiles.IsSegmentName(file))
+ return Results.NotFound();
+
+ if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
+ return Results.NotFound();
+
+ return Results.File(path, "video/mp2t", enableRangeProcessing: true);
+ }
+}
+
+///
+/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
+/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
+/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
+/// AsParameters обязательность определяется nullable-типом, а не значением по умолчанию.
+///
+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 Items, Guid ShowId);
diff --git a/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs
index d26bb62..f669e2f 100644
--- a/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs
@@ -1,55 +1,55 @@
-using LiteCqrs;
-using TeleWave.Api.Common;
-using TeleWave.Application.Settings;
-using TeleWave.Application.Settings.GetSiteSettings;
-using TeleWave.Application.Settings.UpdateSiteSettings;
-using TeleWave.Infrastructure.Identity;
-
-namespace TeleWave.Api.Endpoints;
-
-public static class SettingsEndpoints
-{
- public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
- {
- var admin = app.MapGroup("/api/admin/settings")
- .WithTags("Admin.Settings")
- .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
-
- admin.MapGet("", GetSettings).Produces();
- admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
-
- return app;
- }
-
- private static async Task GetSettings(
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
- return Results.Ok(settings);
- }
-
- private static async Task UpdateSettings(
- UpdateSiteSettingsBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new UpdateSiteSettingsCommand(
- body.RegistrationEnabled,
- body.PreferredAudioLanguages ?? "",
- body.ChannelNumbersEnabled
- ),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-}
-
-public sealed record UpdateSiteSettingsBody(
- bool RegistrationEnabled,
- string? PreferredAudioLanguages,
- bool ChannelNumbersEnabled
-);
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Settings;
+using TeleWave.Application.Settings.GetSiteSettings;
+using TeleWave.Application.Settings.UpdateSiteSettings;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+public static class SettingsEndpoints
+{
+ public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/settings")
+ .WithTags("Admin.Settings")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapGet("", GetSettings).Produces();
+ admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
+
+ return app;
+ }
+
+ private static async Task GetSettings(
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
+ return Results.Ok(settings);
+ }
+
+ private static async Task UpdateSettings(
+ UpdateSiteSettingsBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateSiteSettingsCommand(
+ body.RegistrationEnabled,
+ body.PreferredAudioLanguages ?? "",
+ body.ChannelNumbersEnabled
+ ),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+}
+
+public sealed record UpdateSiteSettingsBody(
+ bool RegistrationEnabled,
+ string? PreferredAudioLanguages,
+ bool ChannelNumbersEnabled
+);
diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs
index b676c59..f44d391 100644
--- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs
@@ -1,186 +1,186 @@
-using LiteCqrs;
-using TeleWave.Api.Common;
-using TeleWave.Application.Library;
-using TeleWave.Application.Library.AddEpisode;
-using TeleWave.Application.Library.CreateShow;
-using TeleWave.Application.Library.DeleteShow;
-using TeleWave.Application.Library.GetShow;
-using TeleWave.Application.Library.ListShows;
-using TeleWave.Application.Library.RemoveEpisode;
-using TeleWave.Application.Library.RenameShow;
-using TeleWave.Application.Library.SetShowAudience;
-using TeleWave.Application.Library.SetShowGenres;
-using TeleWave.Application.Library.SetShowOriginalName;
-using TeleWave.Domain.Library;
-using TeleWave.Infrastructure.Identity;
-
-namespace TeleWave.Api.Endpoints;
-
-public static class ShowEndpoints
-{
- public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
- {
- var admin = app.MapGroup("/api/admin/shows")
- .WithTags("Admin.Shows")
- .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
-
- admin.MapPost("", CreateShow).Produces(StatusCodes.Status201Created);
- admin.MapGet("", ListShows).Produces>();
- admin.MapGet("/{id:guid}", GetShow).Produces();
- admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
- admin
- .MapPut("/{id:guid}/original-name", SetOriginalName)
- .Produces(StatusCodes.Status204NoContent);
- admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
- admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
- admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
- admin
- .MapPost("/{id:guid}/episodes", AddEpisode)
- .Produces(StatusCodes.Status201Created);
- admin
- .MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
- .Produces(StatusCodes.Status204NoContent);
-
- return app;
- }
-
- private static async Task CreateShow(
- CreateShowCommand command,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(command, cancellationToken);
- return result.IsSuccess
- ? Results.Created(
- $"/api/admin/shows/{result.Value}",
- new CreatedIdResponse(result.Value)
- )
- : result.ToHttpResult();
- }
-
- private static async Task ListShows(
- ISender sender,
- CancellationToken cancellationToken,
- Guid? genreId = null,
- bool interstitials = false
- )
- {
- var result = await sender.Send(
- new ListShowsQuery(genreId, interstitials),
- cancellationToken
- );
- return Results.Ok(result);
- }
-
- private static async Task GetShow(
- Guid id,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new GetShowQuery(id), cancellationToken);
- return result.ToHttpResult();
- }
-
- private static async Task Rename(
- Guid id,
- RenameShowBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
- return result.ToHttpResult();
- }
-
- private static async Task SetOriginalName(
- Guid id,
- SetShowOriginalNameBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new SetShowOriginalNameCommand(id, body.OriginalName),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-
- private static async Task SetAudience(
- Guid id,
- SetShowAudienceBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new SetShowAudienceCommand(id, body.Audience),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-
- private static async Task SetGenres(
- Guid id,
- SetShowGenresBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-
- private static async Task DeleteShow(
- Guid id,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
- return result.ToHttpResult();
- }
-
- private static async Task AddEpisode(
- Guid id,
- AddEpisodeBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new AddEpisodeCommand(id, body.MediaAssetId),
- cancellationToken
- );
- return result.IsSuccess
- ? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
- : result.ToHttpResult();
- }
-
- private static async Task RemoveEpisode(
- Guid id,
- Guid episodeId,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
- return result.ToHttpResult();
- }
-}
-
-public sealed record AddEpisodeBody(Guid MediaAssetId);
-
-public sealed record RenameShowBody(string Name);
-
-public sealed record SetShowOriginalNameBody(string? OriginalName);
-
-/// Рейтинг шоу; null — снять проставленный.
-public sealed record SetShowAudienceBody(ShowAudience? Audience);
-
-public sealed record SetShowGenresBody(IReadOnlyList GenreIds, Guid? PrimaryGenreId);
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Library;
+using TeleWave.Application.Library.AddEpisode;
+using TeleWave.Application.Library.CreateShow;
+using TeleWave.Application.Library.DeleteShow;
+using TeleWave.Application.Library.GetShow;
+using TeleWave.Application.Library.ListShows;
+using TeleWave.Application.Library.RemoveEpisode;
+using TeleWave.Application.Library.RenameShow;
+using TeleWave.Application.Library.SetShowAudience;
+using TeleWave.Application.Library.SetShowGenres;
+using TeleWave.Application.Library.SetShowOriginalName;
+using TeleWave.Domain.Library;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+public static class ShowEndpoints
+{
+ public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/shows")
+ .WithTags("Admin.Shows")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapPost("", CreateShow).Produces(StatusCodes.Status201Created);
+ admin.MapGet("", ListShows).Produces>();
+ admin.MapGet("/{id:guid}", GetShow).Produces();
+ admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPut("/{id:guid}/original-name", SetOriginalName)
+ .Produces(StatusCodes.Status204NoContent);
+ admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
+ admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
+ admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPost("/{id:guid}/episodes", AddEpisode)
+ .Produces(StatusCodes.Status201Created);
+ admin
+ .MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
+ .Produces(StatusCodes.Status204NoContent);
+
+ return app;
+ }
+
+ private static async Task CreateShow(
+ CreateShowCommand command,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(command, cancellationToken);
+ return result.IsSuccess
+ ? Results.Created(
+ $"/api/admin/shows/{result.Value}",
+ new CreatedIdResponse(result.Value)
+ )
+ : result.ToHttpResult();
+ }
+
+ private static async Task ListShows(
+ ISender sender,
+ CancellationToken cancellationToken,
+ Guid? genreId = null,
+ bool interstitials = false
+ )
+ {
+ var result = await sender.Send(
+ new ListShowsQuery(genreId, interstitials),
+ cancellationToken
+ );
+ return Results.Ok(result);
+ }
+
+ private static async Task GetShow(
+ Guid id,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new GetShowQuery(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task Rename(
+ Guid id,
+ RenameShowBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task SetOriginalName(
+ Guid id,
+ SetShowOriginalNameBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new SetShowOriginalNameCommand(id, body.OriginalName),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task SetAudience(
+ Guid id,
+ SetShowAudienceBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new SetShowAudienceCommand(id, body.Audience),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task SetGenres(
+ Guid id,
+ SetShowGenresBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task DeleteShow(
+ Guid id,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task AddEpisode(
+ Guid id,
+ AddEpisodeBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new AddEpisodeCommand(id, body.MediaAssetId),
+ cancellationToken
+ );
+ return result.IsSuccess
+ ? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
+ : result.ToHttpResult();
+ }
+
+ private static async Task RemoveEpisode(
+ Guid id,
+ Guid episodeId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
+ return result.ToHttpResult();
+ }
+}
+
+public sealed record AddEpisodeBody(Guid MediaAssetId);
+
+public sealed record RenameShowBody(string Name);
+
+public sealed record SetShowOriginalNameBody(string? OriginalName);
+
+/// Рейтинг шоу; null — снять проставленный.
+public sealed record SetShowAudienceBody(ShowAudience? Audience);
+
+public sealed record SetShowGenresBody(IReadOnlyList GenreIds, Guid? PrimaryGenreId);
diff --git a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs
index 7cb55cd..8a13440 100644
--- a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs
@@ -1,191 +1,191 @@
-using System.Globalization;
-using System.Text;
-using LiteCqrs;
-using Microsoft.Extensions.Hosting;
-using TeleWave.Api.Common;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Streaming;
-using TeleWave.Application.Streaming.GetLivePlaylist;
-using TeleWave.Application.Streaming.GetPublicEpg;
-using TeleWave.Application.Streaming.ListPublicChannels;
-using TeleWave.Infrastructure.Media;
-using TeleWave.Infrastructure.Streaming;
-
-namespace TeleWave.Api.Endpoints;
-
-public static class StreamingEndpoints
-{
- private const string StreamCookieName = "tw_stream";
-
- public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
- {
- // Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
- var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
- channels.MapGet("", ListChannels).Produces>();
- // Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
- channels.MapGet("/features", ViewerFeatures).Produces();
- channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
- channels.MapGet("/{slug}/epg", Epg);
-
- // Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит