Refactor various components to improve code clarity and maintainability. Update ListUsersQueryHandler to utilize UserListFilter for parameter handling. Refactor BumperSpecFactory and related classes to encapsulate input parameters into dedicated records, enhancing readability. Adjust MediaAsset and Slot classes to streamline content updates with new content models. Improve BumperRenderBackgroundService and MediaProcessingBackgroundService to use MediaReadyInfo for asset readiness, ensuring consistent parameter management across the application.
This commit is contained in:
@@ -32,8 +32,8 @@ builder.Services.AddSerilog(
|
|||||||
|
|
||||||
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
||||||
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
||||||
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback;
|
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
|
||||||
// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
|
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
{
|
{
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
@@ -143,6 +143,6 @@ app.UseDefaultFiles();
|
|||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.MapFallbackToFile("index.html");
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
// Раньше здесь объявлялся `public partial class Program;` — чтобы WebApplicationTestFactory видела
|
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
|
||||||
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
|
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService)
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) =>
|
) =>
|
||||||
identityService.ListUsersAsync(
|
identityService.ListUsersAsync(
|
||||||
query.Page,
|
new UserListFilter(
|
||||||
query.PageSize,
|
query.Page,
|
||||||
query.Search,
|
query.PageSize,
|
||||||
query.RoleId,
|
query.Search,
|
||||||
query.IsBlocked,
|
query.RoleId,
|
||||||
query.Sort,
|
query.IsBlocked,
|
||||||
query.Desc,
|
query.Sort,
|
||||||
|
query.Desc
|
||||||
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ public static class BumperSpecFactory
|
|||||||
BumperTemplate template,
|
BumperTemplate template,
|
||||||
BumperTextVariant variant,
|
BumperTextVariant variant,
|
||||||
int alignedDurationSeconds,
|
int alignedDurationSeconds,
|
||||||
string fromName,
|
BumperSpecInputs inputs
|
||||||
string toName,
|
|
||||||
string? audioPath,
|
|
||||||
string? posterAbsolutePath,
|
|
||||||
string? backgroundAbsolutePath
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var free = variant.Kind == BumperTextKind.Free;
|
var free = variant.Kind == BumperTextKind.Free;
|
||||||
@@ -33,12 +29,12 @@ public static class BumperSpecFactory
|
|||||||
template.TextColor,
|
template.TextColor,
|
||||||
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
|
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
|
||||||
free ? "" : variant.NowLabel,
|
free ? "" : variant.NowLabel,
|
||||||
free ? "" : fromName,
|
free ? "" : inputs.FromName,
|
||||||
free ? "" : variant.NextLabel,
|
free ? "" : variant.NextLabel,
|
||||||
free ? "" : toName,
|
free ? "" : inputs.ToName,
|
||||||
backgroundAbsolutePath,
|
inputs.BackgroundAbsolutePath,
|
||||||
audioPath,
|
inputs.AudioPath,
|
||||||
posterAbsolutePath,
|
inputs.PosterAbsolutePath,
|
||||||
free,
|
free,
|
||||||
variant.Line1,
|
variant.Line1,
|
||||||
variant.Line2
|
variant.Line2
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их
|
||||||
|
/// вызывающий (генератор эфира — по реальной паре соседей, превью — по образцам канала), а
|
||||||
|
/// <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BumperSpecInputs(
|
||||||
|
string FromName,
|
||||||
|
string ToName,
|
||||||
|
string? AudioPath = null,
|
||||||
|
/// <summary>Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно.</summary>
|
||||||
|
string? PosterAbsolutePath = null,
|
||||||
|
string? BackgroundAbsolutePath = null
|
||||||
|
);
|
||||||
@@ -72,11 +72,13 @@ public sealed class BumperSpecLoader(
|
|||||||
template,
|
template,
|
||||||
variant,
|
variant,
|
||||||
aligned,
|
aligned,
|
||||||
names.GetValueOrDefault(cache.FromShowId, "…"),
|
new BumperSpecInputs(
|
||||||
names.GetValueOrDefault(cache.ToShowId, "…"),
|
names.GetValueOrDefault(cache.FromShowId, "…"),
|
||||||
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
names.GetValueOrDefault(cache.ToShowId, "…"),
|
||||||
posterPath,
|
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
||||||
bgPath
|
posterPath,
|
||||||
|
bgPath
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-49
@@ -43,9 +43,6 @@ public sealed class RenderBumperPreviewCommandHandler(
|
|||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||||
|
|
||||||
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
||||||
var fontFile =
|
|
||||||
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
|
||||||
|
|
||||||
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
|
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
|
||||||
var seconds = template.AudioDurationSeconds is { } d and > 0
|
var seconds = template.AudioDurationSeconds is { } d and > 0
|
||||||
? d
|
? d
|
||||||
@@ -55,18 +52,25 @@ public sealed class RenderBumperPreviewCommandHandler(
|
|||||||
);
|
);
|
||||||
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
||||||
|
|
||||||
|
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
|
||||||
|
var inputs = new BumperSpecInputs(
|
||||||
|
fromName,
|
||||||
|
toName,
|
||||||
|
audioPath,
|
||||||
|
PosterAbsolutePath: null,
|
||||||
|
backgroundPath
|
||||||
|
);
|
||||||
|
|
||||||
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
||||||
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
||||||
{
|
{
|
||||||
var spec = BuildSpec(
|
var spec = BumperSpecFactory.Build(
|
||||||
variant,
|
_bumper,
|
||||||
|
channel.BumperFont,
|
||||||
template,
|
template,
|
||||||
|
variant,
|
||||||
aligned,
|
aligned,
|
||||||
fontFile,
|
inputs
|
||||||
backgroundPath,
|
|
||||||
audioPath,
|
|
||||||
fromName,
|
|
||||||
toName
|
|
||||||
);
|
);
|
||||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -92,45 +96,6 @@ public sealed class RenderBumperPreviewCommandHandler(
|
|||||||
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
|
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Спецификация рендера одного подблока. В режиме <see cref="BumperTextKind.Free"/> подписи и
|
|
||||||
/// названия шоу гасятся: там на экране произвольные строки, а не «Сейчас/Далее».
|
|
||||||
/// </summary>
|
|
||||||
private BumperRenderSpec BuildSpec(
|
|
||||||
BumperTextVariant variant,
|
|
||||||
BumperTemplate template,
|
|
||||||
int alignedSeconds,
|
|
||||||
string fontFile,
|
|
||||||
string? backgroundPath,
|
|
||||||
string? audioPath,
|
|
||||||
string fromName,
|
|
||||||
string toName
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var free = variant.Kind == BumperTextKind.Free;
|
|
||||||
return new BumperRenderSpec(
|
|
||||||
alignedSeconds,
|
|
||||||
_bumper.Width,
|
|
||||||
_bumper.Height,
|
|
||||||
template.BackgroundColor,
|
|
||||||
template.BackgroundColor2,
|
|
||||||
template.AccentColor,
|
|
||||||
template.TextColor,
|
|
||||||
fontFile,
|
|
||||||
free ? "" : variant.NowLabel,
|
|
||||||
free ? "" : fromName,
|
|
||||||
free ? "" : variant.NextLabel,
|
|
||||||
free ? "" : toName,
|
|
||||||
backgroundPath,
|
|
||||||
audioPath,
|
|
||||||
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
|
|
||||||
null,
|
|
||||||
free,
|
|
||||||
variant.Line1,
|
|
||||||
variant.Line2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
||||||
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
||||||
|
|||||||
+8
-5
@@ -2,6 +2,7 @@ using LiteCqrs;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
@@ -31,11 +32,13 @@ public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContex
|
|||||||
|
|
||||||
variant.Update(
|
variant.Update(
|
||||||
command.Name.Trim(),
|
command.Name.Trim(),
|
||||||
command.Kind,
|
new BumperTextContent(
|
||||||
command.NowLabel,
|
command.Kind,
|
||||||
command.NextLabel,
|
command.NowLabel,
|
||||||
command.Line1,
|
command.NextLabel,
|
||||||
command.Line2,
|
command.Line1,
|
||||||
|
command.Line2
|
||||||
|
),
|
||||||
command.Trigger,
|
command.Trigger,
|
||||||
command.Weight
|
command.Weight
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,13 +57,7 @@ public interface IIdentityService
|
|||||||
Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
|
Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<PagedList<UserSummaryDto>> ListUsersAsync(
|
Task<PagedList<UserSummaryDto>> ListUsersAsync(
|
||||||
int page,
|
UserListFilter filter,
|
||||||
int pageSize,
|
|
||||||
string? search,
|
|
||||||
Guid? roleId,
|
|
||||||
bool? isBlocked,
|
|
||||||
string? sort,
|
|
||||||
bool desc,
|
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Параметры выборки пользователей: страница, фильтры и сортировка. Отдельный тип, а не запрос
|
||||||
|
/// CQRS: список пользователей живёт в Identity (вне <c>IAppDbContext</c>), и порт не должен зависеть
|
||||||
|
/// от конкретной фичи.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UserListFilter(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
string? Search,
|
||||||
|
Guid? RoleId,
|
||||||
|
bool? IsBlocked,
|
||||||
|
string? Sort = null,
|
||||||
|
bool Desc = false
|
||||||
|
);
|
||||||
+7
-4
@@ -39,16 +39,19 @@ public sealed class ListInterstitialBlocksQueryHandler(IAppDbContext dbContext)
|
|||||||
.Select(a => new { a.Id, a.Duration })
|
.Select(a => new { a.Id, a.Duration })
|
||||||
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value.TotalSeconds, cancellationToken);
|
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value.TotalSeconds, cancellationToken);
|
||||||
|
|
||||||
|
// Длительность шоу-ролика считаем один раз: одно и то же шоу встречается в нескольких блоках.
|
||||||
|
var clipSeconds = clips.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value.Sum((Guid assetId) => durations.GetValueOrDefault(assetId, 0d))
|
||||||
|
);
|
||||||
|
|
||||||
return collections
|
return collections
|
||||||
.Where(c => c.Items.Count > 0 && c.Items.All(i => clips.ContainsKey(i.ShowId)))
|
.Where(c => c.Items.Count > 0 && c.Items.All(i => clips.ContainsKey(i.ShowId)))
|
||||||
.Select(c => new InterstitialBlockDto(
|
.Select(c => new InterstitialBlockDto(
|
||||||
c.Id,
|
c.Id,
|
||||||
c.Name,
|
c.Name,
|
||||||
c.Items.Count,
|
c.Items.Count,
|
||||||
c.Items.Sum(i =>
|
c.Items.Sum(i => clipSeconds[i.ShowId])
|
||||||
clips[i.ShowId]
|
|
||||||
.Sum(assetId => durations.TryGetValue(assetId, out var d) ? d : 0)
|
|
||||||
)
|
|
||||||
))
|
))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
|
|||||||
// ToLower().Contains переводится в LIKE lower(...) — регистронезависимо и без привязки к
|
// ToLower().Contains переводится в LIKE lower(...) — регистронезависимо и без привязки к
|
||||||
// Npgsql-специфичному ILike (Application не ссылается на провайдер).
|
// Npgsql-специфичному ILike (Application не ссылается на провайдер).
|
||||||
var term = query.Search.Trim().ToLower();
|
var term = query.Search.Trim().ToLower();
|
||||||
|
// CA1862 (Contains со StringComparison) здесь неприменим: это дерево выражений EF, а
|
||||||
|
// перегрузку с StringComparison провайдер в SQL не переводит — будет исключение в рантайме.
|
||||||
|
#pragma warning disable CA1862
|
||||||
q = q.Where(x => x.OriginalFileName.ToLower().Contains(term));
|
q = q.Where(x => x.OriginalFileName.ToLower().Contains(term));
|
||||||
|
#pragma warning restore CA1862
|
||||||
}
|
}
|
||||||
|
|
||||||
var total = await q.CountAsync(cancellationToken);
|
var total = await q.CountAsync(cancellationToken);
|
||||||
|
|||||||
@@ -142,13 +142,15 @@ public sealed class GridScheduleGenerator(
|
|||||||
ToEntryKind(item.Kind),
|
ToEntryKind(item.Kind),
|
||||||
item.StartsAtUtc,
|
item.StartsAtUtc,
|
||||||
item.EndsAtUtc,
|
item.EndsAtUtc,
|
||||||
item.ShowId,
|
new ScheduleEntryOrigin(
|
||||||
item.UnitIndex,
|
item.ShowId,
|
||||||
item.SlotId,
|
item.UnitIndex,
|
||||||
item.Trace is null
|
item.SlotId,
|
||||||
? null
|
item.Trace is null
|
||||||
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
|
? null
|
||||||
item.CollectionId
|
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
|
||||||
|
item.CollectionId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
added++;
|
added++;
|
||||||
|
|||||||
+12
-10
@@ -208,16 +208,18 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
slot.SnapToMinutes
|
slot.SnapToMinutes
|
||||||
);
|
);
|
||||||
copySlot.UpdateContent(
|
copySlot.UpdateContent(
|
||||||
slot.Title,
|
new SlotContent(
|
||||||
slot.SlotKind,
|
slot.Title,
|
||||||
slot.GroupId,
|
slot.SlotKind,
|
||||||
slot.StrategyJson,
|
slot.GroupId,
|
||||||
slot.RepeatSourceJson,
|
slot.StrategyJson,
|
||||||
slot.BlockMode,
|
slot.RepeatSourceJson,
|
||||||
slot.BlockValue,
|
slot.BlockMode,
|
||||||
slot.OverflowPolicy,
|
slot.BlockValue,
|
||||||
Map(slot.JunctionBetweenId, junctionMap),
|
slot.OverflowPolicy,
|
||||||
Map(slot.JunctionAfterId, junctionMap)
|
Map(slot.JunctionBetweenId, junctionMap),
|
||||||
|
Map(slot.JunctionAfterId, junctionMap)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,16 +66,18 @@ public sealed class SlotWriter(IAppDbContext dbContext)
|
|||||||
input.SnapToMinutes
|
input.SnapToMinutes
|
||||||
);
|
);
|
||||||
target.UpdateContent(
|
target.UpdateContent(
|
||||||
input.Title,
|
new SlotContent(
|
||||||
input.SlotKind,
|
input.Title,
|
||||||
input.GroupId,
|
input.SlotKind,
|
||||||
input.Strategy?.ToJson(),
|
input.GroupId,
|
||||||
input.RepeatSource?.ToJson(),
|
input.Strategy?.ToJson(),
|
||||||
input.BlockMode,
|
input.RepeatSource?.ToJson(),
|
||||||
input.BlockValue,
|
input.BlockMode,
|
||||||
input.OverflowPolicy,
|
input.BlockValue,
|
||||||
input.JunctionBetweenId,
|
input.OverflowPolicy,
|
||||||
input.JunctionAfterId
|
input.JunctionBetweenId,
|
||||||
|
input.JunctionAfterId
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Текстовое наполнение подблока заставки. Наборы полей взаимоисключающие: при
|
||||||
|
/// <see cref="BumperTextKind.NowNext"/> работают подписи, при <see cref="BumperTextKind.Free"/> —
|
||||||
|
/// произвольные строки; неиспользуемые просто хранятся, чтобы переключение режима не теряло ввод.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BumperTextContent(
|
||||||
|
BumperTextKind Kind,
|
||||||
|
string NowLabel,
|
||||||
|
string NextLabel,
|
||||||
|
string Line1,
|
||||||
|
string Line2
|
||||||
|
);
|
||||||
@@ -58,23 +58,14 @@ public class BumperTextVariant
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
public void Update(
|
public void Update(string name, BumperTextContent text, BumperTrigger trigger, int weight)
|
||||||
string name,
|
|
||||||
BumperTextKind kind,
|
|
||||||
string nowLabel,
|
|
||||||
string nextLabel,
|
|
||||||
string line1,
|
|
||||||
string line2,
|
|
||||||
BumperTrigger trigger,
|
|
||||||
int weight
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
Kind = kind;
|
Kind = text.Kind;
|
||||||
NowLabel = nowLabel;
|
NowLabel = text.NowLabel;
|
||||||
NextLabel = nextLabel;
|
NextLabel = text.NextLabel;
|
||||||
Line1 = line1;
|
Line1 = text.Line1;
|
||||||
Line2 = line2;
|
Line2 = text.Line2;
|
||||||
Trigger = trigger;
|
Trigger = trigger;
|
||||||
Weight = Math.Max(0, weight);
|
Weight = Math.Max(0, weight);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,7 @@ public class ScheduleEntry
|
|||||||
ScheduleEntryKind kind,
|
ScheduleEntryKind kind,
|
||||||
DateTimeOffset startsAtUtc,
|
DateTimeOffset startsAtUtc,
|
||||||
DateTimeOffset endsAtUtc,
|
DateTimeOffset endsAtUtc,
|
||||||
Guid? showId,
|
ScheduleEntryOrigin origin
|
||||||
int? episodeIndex,
|
|
||||||
Guid? slotId,
|
|
||||||
string? traceJson,
|
|
||||||
Guid? collectionId = null
|
|
||||||
) =>
|
) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
@@ -61,11 +57,11 @@ public class ScheduleEntry
|
|||||||
Kind = kind,
|
Kind = kind,
|
||||||
StartsAtUtc = startsAtUtc,
|
StartsAtUtc = startsAtUtc,
|
||||||
EndsAtUtc = endsAtUtc,
|
EndsAtUtc = endsAtUtc,
|
||||||
ShowId = showId,
|
ShowId = origin.ShowId,
|
||||||
EpisodeIndex = episodeIndex,
|
EpisodeIndex = origin.EpisodeIndex,
|
||||||
SlotId = slotId,
|
SlotId = origin.SlotId,
|
||||||
TraceJson = traceJson,
|
TraceJson = origin.TraceJson,
|
||||||
CollectionId = collectionId,
|
CollectionId = origin.CollectionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static ScheduleEntry Program(
|
public static ScheduleEntry Program(
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Происхождение записи расписания: чем она порождена и в рамках чего. На эфирную математику не
|
||||||
|
/// влияет — нужно EPG (<see cref="ShowId"/>/<see cref="EpisodeIndex"/>) и отладке сетки
|
||||||
|
/// (<see cref="SlotId"/>/<see cref="TraceJson"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ScheduleEntryOrigin(
|
||||||
|
Guid? ShowId,
|
||||||
|
int? EpisodeIndex,
|
||||||
|
Guid? SlotId,
|
||||||
|
string? TraceJson,
|
||||||
|
/// <summary>Коллекция (франшиза), частью которой шла запись, или null.</summary>
|
||||||
|
Guid? CollectionId = null
|
||||||
|
);
|
||||||
@@ -90,26 +90,17 @@ public class MediaAsset
|
|||||||
Touch();
|
Touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MarkReady(
|
public void MarkReady(MediaReadyInfo info)
|
||||||
TimeSpan duration,
|
|
||||||
int segmentSeconds,
|
|
||||||
int segmentCount,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
string videoCodec,
|
|
||||||
string audioCodec,
|
|
||||||
string relativePath
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Status = MediaAssetStatus.Ready;
|
Status = MediaAssetStatus.Ready;
|
||||||
Duration = duration;
|
Duration = info.Duration;
|
||||||
SegmentSeconds = segmentSeconds;
|
SegmentSeconds = info.SegmentSeconds;
|
||||||
SegmentCount = segmentCount;
|
SegmentCount = info.SegmentCount;
|
||||||
Width = width;
|
Width = info.Width;
|
||||||
Height = height;
|
Height = info.Height;
|
||||||
VideoCodec = videoCodec;
|
VideoCodec = info.VideoCodec;
|
||||||
AudioCodec = audioCodec;
|
AudioCodec = info.AudioCodec;
|
||||||
RelativePath = relativePath;
|
RelativePath = info.RelativePath;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
if (ProcessingStartedAt is { } startedAt)
|
if (ProcessingStartedAt is { } startedAt)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TeleWave.Domain.Media;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Результат обработки ассета: что получилось после нарезки в HLS. Домен эти значения не вычисляет —
|
||||||
|
/// их приносит обработчик медиа (ffmpeg), а <see cref="MediaAsset.MarkReady"/> лишь фиксирует.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MediaReadyInfo(
|
||||||
|
TimeSpan Duration,
|
||||||
|
int SegmentSeconds,
|
||||||
|
int SegmentCount,
|
||||||
|
int Width,
|
||||||
|
int Height,
|
||||||
|
string VideoCodec,
|
||||||
|
string AudioCodec,
|
||||||
|
/// <summary>Путь к каталогу ассета относительно корня хранилища.</summary>
|
||||||
|
string RelativePath
|
||||||
|
);
|
||||||
@@ -108,32 +108,21 @@ public class Slot
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
|
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
|
||||||
public void UpdateContent(
|
public void UpdateContent(SlotContent content)
|
||||||
string title,
|
|
||||||
SlotKind slotKind,
|
|
||||||
Guid? groupId,
|
|
||||||
string? strategyJson,
|
|
||||||
string? repeatSourceJson,
|
|
||||||
SlotBlockMode blockMode,
|
|
||||||
int blockValue,
|
|
||||||
OverflowPolicy overflowPolicy,
|
|
||||||
Guid? junctionBetweenId = null,
|
|
||||||
Guid? junctionAfterId = null
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
JunctionBetweenId = junctionBetweenId;
|
JunctionBetweenId = content.JunctionBetweenId;
|
||||||
JunctionAfterId = junctionAfterId;
|
JunctionAfterId = content.JunctionAfterId;
|
||||||
Title = title.Trim();
|
Title = content.Title.Trim();
|
||||||
SlotKind = slotKind;
|
SlotKind = content.SlotKind;
|
||||||
BlockMode = blockMode;
|
BlockMode = content.BlockMode;
|
||||||
BlockValue = Math.Max(1, blockValue);
|
BlockValue = Math.Max(1, content.BlockValue);
|
||||||
OverflowPolicy = overflowPolicy;
|
OverflowPolicy = content.OverflowPolicy;
|
||||||
|
|
||||||
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
|
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
|
||||||
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
|
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
|
||||||
GroupId = slotKind == SlotKind.Content ? groupId : null;
|
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
|
||||||
StrategyJson = slotKind == SlotKind.Content ? strategyJson : null;
|
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
|
||||||
RepeatSourceJson = slotKind == SlotKind.Repeat ? repeatSourceJson : null;
|
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
|
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace TeleWave.Domain.Programming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Наполнение слота: чем заполнять эфир, в каком объёме и с какими врезками. Отделено от расписания
|
||||||
|
/// слота (<see cref="Slot.UpdateTiming"/>) — это два независимых набора настроек, и правятся они
|
||||||
|
/// в редакторе тоже раздельно.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SlotContent(
|
||||||
|
string Title,
|
||||||
|
SlotKind SlotKind,
|
||||||
|
/// <summary>Группа контента — только для <see cref="SlotKind.Content"/>, иначе гасится.</summary>
|
||||||
|
Guid? GroupId,
|
||||||
|
string? StrategyJson,
|
||||||
|
string? RepeatSourceJson,
|
||||||
|
SlotBlockMode BlockMode,
|
||||||
|
int BlockValue,
|
||||||
|
OverflowPolicy OverflowPolicy,
|
||||||
|
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
|
||||||
|
Guid? JunctionBetweenId = null,
|
||||||
|
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
||||||
|
Guid? JunctionAfterId = null
|
||||||
|
);
|
||||||
@@ -185,16 +185,12 @@ internal sealed class IdentityService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedList<UserSummaryDto>> ListUsersAsync(
|
public async Task<PagedList<UserSummaryDto>> ListUsersAsync(
|
||||||
int page,
|
UserListFilter filter,
|
||||||
int pageSize,
|
|
||||||
string? search,
|
|
||||||
Guid? roleId,
|
|
||||||
bool? isBlocked,
|
|
||||||
string? sort,
|
|
||||||
bool desc,
|
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
var (page, pageSize, search, roleId, isBlocked, sort, desc) = filter;
|
||||||
|
|
||||||
var query =
|
var query =
|
||||||
from user in dbContext.Users
|
from user in dbContext.Users
|
||||||
join userRole in dbContext.UserRoles on user.Id equals userRole.UserId into userRoles
|
join userRole in dbContext.UserRoles on user.Id equals userRole.UserId into userRoles
|
||||||
|
|||||||
@@ -60,14 +60,16 @@ internal sealed class BumperRenderBackgroundService(
|
|||||||
job.AssetId,
|
job.AssetId,
|
||||||
asset =>
|
asset =>
|
||||||
asset.MarkReady(
|
asset.MarkReady(
|
||||||
render.Duration,
|
new MediaReadyInfo(
|
||||||
render.SegmentSeconds,
|
render.Duration,
|
||||||
render.SegmentCount,
|
render.SegmentSeconds,
|
||||||
render.Width,
|
render.SegmentCount,
|
||||||
render.Height,
|
render.Width,
|
||||||
"h264",
|
render.Height,
|
||||||
"aac",
|
"h264",
|
||||||
render.RelativePath
|
"aac",
|
||||||
|
render.RelativePath
|
||||||
|
)
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
private readonly StorageOptions _storage = storageOptions.Value;
|
private readonly StorageOptions _storage = storageOptions.Value;
|
||||||
private readonly MediaOptions _media = mediaOptions.Value;
|
private readonly MediaOptions _media = mediaOptions.Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Файлы с динамическим текстом заставки. Всё пользователь-редактируемое (названия шоу, подписи,
|
||||||
|
/// свободные строки) ffmpeg читает через <c>textfile=</c> с <c>expansion=none</c>: иначе запятая,
|
||||||
|
/// <c>;</c>, <c>[</c> или <c>]</c> в тексте ломают (или инъектируют звенья в) цепочку
|
||||||
|
/// <c>-filter_complex</c>.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record TextFiles(string Now, string Next, string NowLabel, string NextLabel)
|
||||||
|
{
|
||||||
|
public static TextFiles In(string assetDir) =>
|
||||||
|
new(
|
||||||
|
Path.Combine(assetDir, "now.txt"),
|
||||||
|
Path.Combine(assetDir, "next.txt"),
|
||||||
|
Path.Combine(assetDir, "nowlabel.txt"),
|
||||||
|
Path.Combine(assetDir, "nextlabel.txt")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<BumperRenderResult> RenderAsync(
|
public async Task<BumperRenderResult> RenderAsync(
|
||||||
Guid assetId,
|
Guid assetId,
|
||||||
BumperRenderSpec spec,
|
BumperRenderSpec spec,
|
||||||
@@ -36,31 +53,24 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
Directory.Delete(assetDir, recursive: true);
|
Directory.Delete(assetDir, recursive: true);
|
||||||
Directory.CreateDirectory(assetDir);
|
Directory.CreateDirectory(assetDir);
|
||||||
|
|
||||||
// Динамический текст (названия шоу / свободные строки) пишем в файлы и читаем через textfile=
|
// Названия шоу / свободные строки.
|
||||||
// с expansion=none — так произвольные символы/кириллица не ломают синтаксис фильтра.
|
var text = TextFiles.In(assetDir);
|
||||||
var nowFile = Path.Combine(assetDir, "now.txt");
|
|
||||||
var nextFile = Path.Combine(assetDir, "next.txt");
|
|
||||||
var line1 = spec.FreeText ? spec.FreeLine1 : spec.NowTitle;
|
var line1 = spec.FreeText ? spec.FreeLine1 : spec.NowTitle;
|
||||||
var line2 = spec.FreeText ? spec.FreeLine2 : spec.NextTitle;
|
var line2 = spec.FreeText ? spec.FreeLine2 : spec.NextTitle;
|
||||||
await File.WriteAllTextAsync(nowFile, line1, new UTF8Encoding(false), cancellationToken);
|
await File.WriteAllTextAsync(text.Now, line1, new UTF8Encoding(false), cancellationToken);
|
||||||
await File.WriteAllTextAsync(nextFile, line2, new UTF8Encoding(false), cancellationToken);
|
await File.WriteAllTextAsync(text.Next, line2, new UTF8Encoding(false), cancellationToken);
|
||||||
|
|
||||||
// Подписи «Сейчас/Далее» тоже пользователь-редактируемы (валидатор ограничивает только длину),
|
// Подписи «Сейчас/Далее» нужны лишь в одноимённом режиме — в FreeText их не рисуют.
|
||||||
// поэтому их так же читаем через textfile=, а не подставляем в text= инлайн: иначе запятая/`;`/`[`/`]`
|
|
||||||
// в подписи ломают (или инъектируют звенья в) цепочку -filter_complex. Нужны лишь в режиме
|
|
||||||
// «Сейчас/Далее» (не FreeText), где рисуются подписи.
|
|
||||||
var nowLabelFile = Path.Combine(assetDir, "nowlabel.txt");
|
|
||||||
var nextLabelFile = Path.Combine(assetDir, "nextlabel.txt");
|
|
||||||
if (!spec.FreeText)
|
if (!spec.FreeText)
|
||||||
{
|
{
|
||||||
await File.WriteAllTextAsync(
|
await File.WriteAllTextAsync(
|
||||||
nowLabelFile,
|
text.NowLabel,
|
||||||
spec.NowLabel,
|
spec.NowLabel,
|
||||||
new UTF8Encoding(false),
|
new UTF8Encoding(false),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
await File.WriteAllTextAsync(
|
await File.WriteAllTextAsync(
|
||||||
nextLabelFile,
|
text.NextLabel,
|
||||||
spec.NextLabel,
|
spec.NextLabel,
|
||||||
new UTF8Encoding(false),
|
new UTF8Encoding(false),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
@@ -69,16 +79,7 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var args = BuildArgs(
|
var args = BuildArgs(assetDir, seg, target, text, spec);
|
||||||
assetDir,
|
|
||||||
seg,
|
|
||||||
target,
|
|
||||||
nowFile,
|
|
||||||
nextFile,
|
|
||||||
nowLabelFile,
|
|
||||||
nextLabelFile,
|
|
||||||
spec
|
|
||||||
);
|
|
||||||
var result = await ProcessRunner.RunAsync(
|
var result = await ProcessRunner.RunAsync(
|
||||||
_media.FfmpegPath,
|
_media.FfmpegPath,
|
||||||
args,
|
args,
|
||||||
@@ -114,10 +115,10 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
TryDelete(nowFile);
|
TryDelete(text.Now);
|
||||||
TryDelete(nextFile);
|
TryDelete(text.Next);
|
||||||
TryDelete(nowLabelFile);
|
TryDelete(text.NowLabel);
|
||||||
TryDelete(nextLabelFile);
|
TryDelete(text.NextLabel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +126,7 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
string assetDir,
|
string assetDir,
|
||||||
int seg,
|
int seg,
|
||||||
int target,
|
int target,
|
||||||
string nowFile,
|
TextFiles text,
|
||||||
string nextFile,
|
|
||||||
string nowLabelFile,
|
|
||||||
string nextLabelFile,
|
|
||||||
BumperRenderSpec spec
|
BumperRenderSpec spec
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -211,29 +209,31 @@ public sealed class FfmpegBumperRenderer(
|
|||||||
var line2Y = line1Y + (int)(line2Size * 1.2);
|
var line2Y = line1Y + (int)(line2Size * 1.2);
|
||||||
vchain
|
vchain
|
||||||
.Append(',')
|
.Append(',')
|
||||||
.Append(DrawTitle(font, nowFile, spec.AccentColor, line1Size, line1Y, 0.2));
|
.Append(DrawTitle(font, text.Now, spec.AccentColor, line1Size, line1Y, 0.2));
|
||||||
vchain
|
vchain
|
||||||
.Append(',')
|
.Append(',')
|
||||||
.Append(DrawTitle(font, nextFile, spec.TextColor, line2Size, line2Y, 0.5));
|
.Append(DrawTitle(font, text.Next, spec.TextColor, line2Size, line2Y, 0.5));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var nowSize = FitSize(spec.NowTitle, titleSize, textWidth);
|
var nowSize = FitSize(spec.NowTitle, titleSize, textWidth);
|
||||||
var nextSize = FitSize(spec.NextTitle, titleSize, textWidth);
|
var nextSize = FitSize(spec.NextTitle, titleSize, textWidth);
|
||||||
vchain
|
|
||||||
.Append(',')
|
|
||||||
.Append(DrawLabel(font, nowLabelFile, spec.AccentColor, labelSize, nowLabelY, 0.2));
|
|
||||||
vchain
|
|
||||||
.Append(',')
|
|
||||||
.Append(DrawTitle(font, nowFile, spec.TextColor, nowSize, nowTitleY, 0.3));
|
|
||||||
vchain
|
vchain
|
||||||
.Append(',')
|
.Append(',')
|
||||||
.Append(
|
.Append(
|
||||||
DrawLabel(font, nextLabelFile, spec.AccentColor, labelSize, nextLabelY, 1.0)
|
DrawLabel(font, text.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2)
|
||||||
);
|
);
|
||||||
vchain
|
vchain
|
||||||
.Append(',')
|
.Append(',')
|
||||||
.Append(DrawTitle(font, nextFile, spec.TextColor, nextSize, nextTitleY, 1.1));
|
.Append(DrawTitle(font, text.Now, spec.TextColor, nowSize, nowTitleY, 0.3));
|
||||||
|
vchain
|
||||||
|
.Append(',')
|
||||||
|
.Append(
|
||||||
|
DrawLabel(font, text.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0)
|
||||||
|
);
|
||||||
|
vchain
|
||||||
|
.Append(',')
|
||||||
|
.Append(DrawTitle(font, text.Next, spec.TextColor, nextSize, nextTitleY, 1.1));
|
||||||
}
|
}
|
||||||
vchain.Append("[v]");
|
vchain.Append("[v]");
|
||||||
|
|
||||||
|
|||||||
@@ -53,14 +53,16 @@ internal sealed class MediaProcessingBackgroundService(
|
|||||||
job.AssetId,
|
job.AssetId,
|
||||||
asset =>
|
asset =>
|
||||||
asset.MarkReady(
|
asset.MarkReady(
|
||||||
result.Duration,
|
new MediaReadyInfo(
|
||||||
result.SegmentSeconds,
|
result.Duration,
|
||||||
result.SegmentCount,
|
result.SegmentSeconds,
|
||||||
result.Width,
|
result.SegmentCount,
|
||||||
result.Height,
|
result.Width,
|
||||||
result.VideoCodec,
|
result.Height,
|
||||||
result.AudioCodec,
|
result.VideoCodec,
|
||||||
result.RelativePath
|
result.AudioCodec,
|
||||||
|
result.RelativePath
|
||||||
|
)
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ public class MediaStatsTests
|
|||||||
{
|
{
|
||||||
var a = Pending(name);
|
var a = Pending(name);
|
||||||
a.MarkProcessing();
|
a.MarkProcessing();
|
||||||
a.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x");
|
a.MarkReady(
|
||||||
|
new MediaReadyInfo(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x")
|
||||||
|
);
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ public class BumperTextVariantTests
|
|||||||
{
|
{
|
||||||
var v = NewVariant();
|
var v = NewVariant();
|
||||||
|
|
||||||
v.Update("Name", BumperTextKind.Free, "NOW", "NEXT", "l1", "l2", BumperTrigger.Both, -5);
|
v.Update(
|
||||||
|
"Name",
|
||||||
|
new BumperTextContent(BumperTextKind.Free, "NOW", "NEXT", "l1", "l2"),
|
||||||
|
BumperTrigger.Both,
|
||||||
|
-5
|
||||||
|
);
|
||||||
|
|
||||||
Assert.Equal("Name", v.Name);
|
Assert.Equal("Name", v.Name);
|
||||||
Assert.Equal(BumperTextKind.Free, v.Kind);
|
Assert.Equal(BumperTextKind.Free, v.Kind);
|
||||||
@@ -48,7 +53,7 @@ public class BumperTextVariantTests
|
|||||||
public void Matches_FollowsTrigger(BumperTrigger trigger, bool isShowChange, bool expected)
|
public void Matches_FollowsTrigger(BumperTrigger trigger, bool isShowChange, bool expected)
|
||||||
{
|
{
|
||||||
var v = NewVariant();
|
var v = NewVariant();
|
||||||
v.Update("n", BumperTextKind.NowNext, "a", "b", "", "", trigger, 1);
|
v.Update("n", new BumperTextContent(BumperTextKind.NowNext, "a", "b", "", ""), trigger, 1);
|
||||||
|
|
||||||
Assert.Equal(expected, v.Matches(isShowChange));
|
Assert.Equal(expected, v.Matches(isShowChange));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,16 @@ public class MediaAssetTests
|
|||||||
var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload);
|
var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload);
|
||||||
|
|
||||||
asset.MarkReady(
|
asset.MarkReady(
|
||||||
TimeSpan.FromSeconds(120),
|
new MediaReadyInfo(
|
||||||
segmentSeconds: 2,
|
TimeSpan.FromSeconds(120),
|
||||||
segmentCount: 60,
|
SegmentSeconds: 2,
|
||||||
width: 1920,
|
SegmentCount: 60,
|
||||||
height: 1080,
|
Width: 1920,
|
||||||
videoCodec: "h264",
|
Height: 1080,
|
||||||
audioCodec: "aac",
|
VideoCodec: "h264",
|
||||||
relativePath: "assets/abc"
|
AudioCodec: "aac",
|
||||||
|
RelativePath: "assets/abc"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.Equal(MediaAssetStatus.Ready, asset.Status);
|
Assert.Equal(MediaAssetStatus.Ready, asset.Status);
|
||||||
@@ -64,7 +66,9 @@ public class MediaAssetTests
|
|||||||
Assert.NotNull(asset.ProcessingStartedAt);
|
Assert.NotNull(asset.ProcessingStartedAt);
|
||||||
Assert.Null(asset.ProcessingDuration);
|
Assert.Null(asset.ProcessingDuration);
|
||||||
|
|
||||||
asset.MarkReady(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc");
|
asset.MarkReady(
|
||||||
|
new MediaReadyInfo(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc")
|
||||||
|
);
|
||||||
|
|
||||||
Assert.NotNull(asset.ProcessingDuration);
|
Assert.NotNull(asset.ProcessingDuration);
|
||||||
Assert.True(asset.ProcessingDuration >= TimeSpan.Zero);
|
Assert.True(asset.ProcessingDuration >= TimeSpan.Zero);
|
||||||
@@ -75,7 +79,9 @@ public class MediaAssetTests
|
|||||||
{
|
{
|
||||||
var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload);
|
var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload);
|
||||||
|
|
||||||
asset.MarkReady(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc");
|
asset.MarkReady(
|
||||||
|
new MediaReadyInfo(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc")
|
||||||
|
);
|
||||||
|
|
||||||
Assert.Null(asset.ProcessingDuration);
|
Assert.Null(asset.ProcessingDuration);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,25 +113,29 @@ public class GridLayerOverlapTests
|
|||||||
var layer = NewLayer();
|
var layer = NewLayer();
|
||||||
var slot = layer.AddSlot("Кино", new TimeOnly(20, 0), 90);
|
var slot = layer.AddSlot("Кино", new TimeOnly(20, 0), 90);
|
||||||
slot.UpdateContent(
|
slot.UpdateContent(
|
||||||
"Кино",
|
new SlotContent(
|
||||||
SlotKind.Content,
|
"Кино",
|
||||||
Guid.NewGuid(),
|
SlotKind.Content,
|
||||||
"{\"type\":\"sequential\"}",
|
Guid.NewGuid(),
|
||||||
null,
|
"{\"type\":\"sequential\"}",
|
||||||
SlotBlockMode.Count,
|
null,
|
||||||
1,
|
SlotBlockMode.Count,
|
||||||
OverflowPolicy.ContinueNext
|
1,
|
||||||
|
OverflowPolicy.ContinueNext
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
slot.UpdateContent(
|
slot.UpdateContent(
|
||||||
"Конец вещания",
|
new SlotContent(
|
||||||
SlotKind.SignOff,
|
"Конец вещания",
|
||||||
Guid.NewGuid(),
|
SlotKind.SignOff,
|
||||||
"{\"type\":\"sequential\"}",
|
Guid.NewGuid(),
|
||||||
null,
|
"{\"type\":\"sequential\"}",
|
||||||
SlotBlockMode.FillSlot,
|
null,
|
||||||
1,
|
SlotBlockMode.FillSlot,
|
||||||
OverflowPolicy.ContinueNext
|
1,
|
||||||
|
OverflowPolicy.ContinueNext
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Иначе генератор прочитал бы настройки, оставшиеся от прежнего типа слота.
|
// Иначе генератор прочитал бы настройки, оставшиеся от прежнего типа слота.
|
||||||
|
|||||||
@@ -249,14 +249,16 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
|
|||||||
var layer = template.AddLayer("Базовый", 10);
|
var layer = template.AddLayer("Базовый", 10);
|
||||||
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
|
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
|
||||||
slot.UpdateContent(
|
slot.UpdateContent(
|
||||||
slot.Title,
|
new SlotContent(
|
||||||
SlotKind.Content,
|
slot.Title,
|
||||||
group.Id,
|
SlotKind.Content,
|
||||||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
group.Id,
|
||||||
null,
|
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||||
SlotBlockMode.FillSlot,
|
null,
|
||||||
1,
|
SlotBlockMode.FillSlot,
|
||||||
OverflowPolicy.ContinueNext
|
1,
|
||||||
|
OverflowPolicy.ContinueNext
|
||||||
|
)
|
||||||
);
|
);
|
||||||
channel.SetTemplate(template.Id);
|
channel.SetTemplate(template.Id);
|
||||||
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
|
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
|
||||||
@@ -275,14 +277,16 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
|
|||||||
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
|
||||||
asset.MarkProcessing();
|
asset.MarkProcessing();
|
||||||
asset.MarkReady(
|
asset.MarkReady(
|
||||||
duration,
|
new MediaReadyInfo(
|
||||||
segmentSeconds: 2,
|
duration,
|
||||||
segmentCount: (int)(duration.TotalSeconds / 2),
|
SegmentSeconds: 2,
|
||||||
width: 1920,
|
SegmentCount: (int)(duration.TotalSeconds / 2),
|
||||||
height: 1080,
|
Width: 1920,
|
||||||
videoCodec: "h264",
|
Height: 1080,
|
||||||
audioCodec: "aac",
|
VideoCodec: "h264",
|
||||||
relativePath: $"segments/{asset.Id}"
|
AudioCodec: "aac",
|
||||||
|
RelativePath: $"segments/{asset.Id}"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
db.MediaAssets.Add(asset);
|
db.MediaAssets.Add(asset);
|
||||||
return asset;
|
return asset;
|
||||||
|
|||||||
@@ -150,15 +150,17 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
|
|||||||
var layer = sourceTemplate.AddLayer("Прайм", 10);
|
var layer = sourceTemplate.AddLayer("Прайм", 10);
|
||||||
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
||||||
slot.UpdateContent(
|
slot.UpdateContent(
|
||||||
slot.Title,
|
new SlotContent(
|
||||||
SlotKind.Content,
|
slot.Title,
|
||||||
group.Id,
|
SlotKind.Content,
|
||||||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
group.Id,
|
||||||
null,
|
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||||
SlotBlockMode.FillSlot,
|
null,
|
||||||
1,
|
SlotBlockMode.FillSlot,
|
||||||
OverflowPolicy.ContinueNext,
|
1,
|
||||||
junctionAfterId: junction.Id
|
OverflowPolicy.ContinueNext,
|
||||||
|
JunctionAfterId: junction.Id
|
||||||
|
)
|
||||||
);
|
);
|
||||||
sourceTemplate.SetDefaultJunction(junction.Id);
|
sourceTemplate.SetDefaultJunction(junction.Id);
|
||||||
source.SetTemplate(sourceTemplate.Id);
|
source.SetTemplate(sourceTemplate.Id);
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
|||||||
var channel = Channel.Create("c", $"c-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
var channel = Channel.Create("c", $"c-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
||||||
var show = Show.Create("Show", ShowKind.Series);
|
var show = Show.Create("Show", ShowKind.Series);
|
||||||
var asset = MediaAsset.Register("ep.mkv", ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register("ep.mkv", ".mkv", MediaSource.Upload);
|
||||||
asset.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x");
|
asset.MarkReady(
|
||||||
|
new MediaReadyInfo(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x")
|
||||||
|
);
|
||||||
show.AddEpisode(asset.Id);
|
show.AddEpisode(asset.Id);
|
||||||
var entry = ScheduleEntry.Program(
|
var entry = ScheduleEntry.Program(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
|
|||||||
@@ -66,8 +66,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
setApplyOpen(false)
|
setApplyOpen(false)
|
||||||
toast.success(t('admin.channels.applied', { count: result.added }))
|
toast.success(t('admin.channels.applied', { count: result.added }))
|
||||||
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
|
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
|
||||||
for (const warning of result.warnings)
|
for (const warning of result.warnings) {
|
||||||
toast.error(`${t(`admin.channels.warnings.${warning.kind}`)}: ${warning.details}`)
|
const kind = t(`admin.channels.warnings.${warning.kind}`)
|
||||||
|
toast.error(`${kind}: ${warning.details}`)
|
||||||
|
}
|
||||||
invalidate()
|
invalidate()
|
||||||
},
|
},
|
||||||
onError,
|
onError,
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ const KIND_COLORS: Record<JunctionElementKind, string> = {
|
|||||||
Filler: 'bg-muted-foreground/40',
|
Filler: 'bg-muted-foreground/40',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Translate = ReturnType<typeof useTranslation>['t']
|
||||||
|
|
||||||
|
/** Что уточняет подпись врезки: имя блока заставки либо объём («×3» / «×5 мин»). */
|
||||||
|
function elementSuffix(element: JunctionElementDto, t: Translate) {
|
||||||
|
if (element.kind === 'Bumper')
|
||||||
|
return element.bumperTemplateName ? ` · ${element.bumperTemplateName}` : ''
|
||||||
|
|
||||||
|
const units = element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''
|
||||||
|
return ` ×${element.amountValue}${units}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подсказка сегмента линейки: вид врезки и её оценочная длительность. */
|
||||||
|
function elementTitle(element: JunctionElementDto, seconds: number, t: Translate) {
|
||||||
|
const kind = t(`admin.channels.junctionKinds.${element.kind}`)
|
||||||
|
return `${kind} · ${formatClock(seconds)}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
|
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
|
||||||
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
|
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
|
||||||
@@ -280,11 +297,7 @@ function JunctionChain({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(`admin.channels.junctionKinds.${element.kind}`)}
|
{t(`admin.channels.junctionKinds.${element.kind}`)}
|
||||||
{element.kind === 'Bumper'
|
{elementSuffix(element, t)}
|
||||||
? element.bumperTemplateName
|
|
||||||
? ` · ${element.bumperTemplateName}`
|
|
||||||
: ''
|
|
||||||
: ` ×${element.amountValue}${element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''}`}
|
|
||||||
{element.isRequired && ' *'}
|
{element.isRequired && ' *'}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -303,7 +316,7 @@ function JunctionChain({
|
|||||||
key={element.id}
|
key={element.id}
|
||||||
className={KIND_COLORS[element.kind]}
|
className={KIND_COLORS[element.kind]}
|
||||||
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
||||||
title={`${t(`admin.channels.junctionKinds.${element.kind}`)} · ${formatClock(estimates[index].seconds)}`}
|
title={elementTitle(element, estimates[index].seconds, t)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,53 @@ import type { ScheduleEntryDto } from '@/shared/api/types'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { formatTime } from '../lib/format'
|
import { formatTime } from '../lib/format'
|
||||||
|
|
||||||
|
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
|
||||||
|
function EntryLabel({ entry }: { entry: ScheduleEntryDto }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
|
||||||
|
|
||||||
|
if (entry.kind === 'Bumper')
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Badge variant="muted" className="shrink-0">
|
||||||
|
{t('air.bumper')}
|
||||||
|
</Badge>
|
||||||
|
{(entry.bumperName || entry.bumperText) && (
|
||||||
|
<span className="min-w-0 truncate text-muted-foreground">
|
||||||
|
{entry.bumperName}
|
||||||
|
{entry.bumperName && entry.bumperText ? ' · ' : ''}
|
||||||
|
{entry.bumperText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{entry.showName ?? '—'}
|
||||||
|
<EpisodeSuffix entry={entry} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
|
||||||
|
function EpisodeSuffix({ entry }: { entry: ScheduleEntryDto }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (entry.seasonEpisode)
|
||||||
|
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
|
||||||
|
|
||||||
|
if (entry.episodeIndex == null) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{' '}
|
||||||
|
· {t('air.episode')} {entry.episodeIndex + 1}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SchedulePreview({
|
export function SchedulePreview({
|
||||||
entries,
|
entries,
|
||||||
onShowTrace,
|
onShowTrace,
|
||||||
@@ -22,36 +69,7 @@ export function SchedulePreview({
|
|||||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||||
{formatTime(e.startsAtUtc)}
|
{formatTime(e.startsAtUtc)}
|
||||||
</span>
|
</span>
|
||||||
{e.kind === 'Ad' ? (
|
<EntryLabel entry={e} />
|
||||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
|
||||||
) : e.kind === 'Bumper' ? (
|
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
|
||||||
<Badge variant="muted" className="shrink-0">
|
|
||||||
{t('air.bumper')}
|
|
||||||
</Badge>
|
|
||||||
{(e.bumperName || e.bumperText) && (
|
|
||||||
<span className="min-w-0 truncate text-muted-foreground">
|
|
||||||
{e.bumperName}
|
|
||||||
{e.bumperName && e.bumperText ? ' · ' : ''}
|
|
||||||
{e.bumperText}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
{e.showName ?? '—'}
|
|
||||||
{e.seasonEpisode ? (
|
|
||||||
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
|
|
||||||
) : (
|
|
||||||
e.episodeIndex != null && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{' '}
|
|
||||||
· {t('air.episode')} {e.episodeIndex + 1}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={t('admin.channels.whyHere')}
|
title={t('admin.channels.whyHere')}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const KIND_COLORS: Record<PlannedItemKind, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
||||||
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
|
const PROGRAMME_KINDS = new Set<PlannedItemKind>(['Program', 'Fallback', 'SignOff'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||||
@@ -99,7 +99,7 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
|
|||||||
|
|
||||||
function Programme({ preview }: { preview: SchedulePreviewDto }) {
|
function Programme({ preview }: { preview: SchedulePreviewDto }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
|
const items = preview.items.filter((i) => PROGRAMME_KINDS.has(i.kind))
|
||||||
|
|
||||||
if (items.length === 0)
|
if (items.length === 0)
|
||||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
@@ -202,11 +202,14 @@ function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePre
|
|||||||
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
|
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
|
||||||
function Problems({ preview }: { preview: SchedulePreviewDto }) {
|
function Problems({ preview }: { preview: SchedulePreviewDto }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
// Ключ строки — «вид + порядковый номер внутри вида»: у предупреждения нет своего id, а текст
|
||||||
|
// повторяется (одна и та же причина на разных слотах), и позиция здесь — единственное, что его
|
||||||
|
// отличает. Считаем ключ при группировке, чтобы список не пересобирался на каждый рендер.
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map = new Map<string, string[]>()
|
const map = new Map<string, { key: string; text: string }[]>()
|
||||||
for (const warning of preview.warnings) {
|
for (const warning of preview.warnings) {
|
||||||
const list = map.get(warning.kind) ?? []
|
const list = map.get(warning.kind) ?? []
|
||||||
list.push(warning.details)
|
list.push({ key: `${warning.kind}#${list.length}`, text: warning.details })
|
||||||
map.set(warning.kind, list)
|
map.set(warning.kind, list)
|
||||||
}
|
}
|
||||||
return [...map.entries()]
|
return [...map.entries()]
|
||||||
@@ -223,9 +226,9 @@ function Problems({ preview }: { preview: SchedulePreviewDto }) {
|
|||||||
<span className="font-medium text-amber-500">
|
<span className="font-medium text-amber-500">
|
||||||
{t(`admin.channels.warnings.${kind}`)} · {details.length}
|
{t(`admin.channels.warnings.${kind}`)} · {details.length}
|
||||||
</span>
|
</span>
|
||||||
{details.slice(0, 20).map((detail, index) => (
|
{details.slice(0, 20).map((detail) => (
|
||||||
<span key={index} className="text-muted-foreground">
|
<span key={detail.key} className="text-muted-foreground">
|
||||||
{detail}
|
{detail.text}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{details.length > 20 && (
|
{details.length > 20 && (
|
||||||
|
|||||||
@@ -142,9 +142,15 @@ export function GalleryBrowser({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 max-h-[55vh] overflow-y-auto">
|
<div className="mt-3 max-h-[55vh] overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading && (
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||||
) : images && images.length > 0 ? (
|
)}
|
||||||
|
{!isLoading && sorted.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t('admin.gallery.empty')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isLoading && sorted.length > 0 && (
|
||||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
||||||
{sorted.map((img) => (
|
{sorted.map((img) => (
|
||||||
<div key={img.id} className="group relative">
|
<div key={img.id} className="group relative">
|
||||||
@@ -175,10 +181,6 @@ export function GalleryBrowser({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
{t('admin.gallery.empty')}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ export function formatClock(seconds: number | null | undefined): string {
|
|||||||
const m = Math.floor(total / 60) % 60
|
const m = Math.floor(total / 60) % 60
|
||||||
const h = Math.floor(total / 3600)
|
const h = Math.floor(total / 3600)
|
||||||
const mm = h > 0 ? String(m).padStart(2, '0') : String(m)
|
const mm = h > 0 ? String(m).padStart(2, '0') : String(m)
|
||||||
return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}`
|
const hh = h > 0 ? `${h}:` : ''
|
||||||
|
return `${hh}${mm}:${String(s).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ export function UploadSnackbar() {
|
|||||||
|
|
||||||
const done = items.filter((i) => i.status === 'done').length
|
const done = items.filter((i) => i.status === 'done').length
|
||||||
const hasItems = items.length > 0
|
const hasItems = items.length > 0
|
||||||
const header = hasItems
|
|
||||||
? active
|
let header: string
|
||||||
? t('admin.media.uploadingCount', { done, total: items.length })
|
if (!hasItems) header = t('admin.media.skippedDuplicates', { count: skipped })
|
||||||
: t('admin.media.uploadedCount', { count: done })
|
else if (active) header = t('admin.media.uploadingCount', { done, total: items.length })
|
||||||
: t('admin.media.skippedDuplicates', { count: skipped })
|
else header = t('admin.media.uploadedCount', { count: done })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="crt-panel fixed bottom-4 right-4 z-50 w-96 max-w-[calc(100vw-2rem)] rounded-md shadow-lg">
|
<div className="crt-panel fixed bottom-4 right-4 z-50 w-96 max-w-[calc(100vw-2rem)] rounded-md shadow-lg">
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
|
|||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
/** Пока идёт пакетное добавление — прогресс вместо подписи; серии добавляются по одной. */
|
||||||
|
function addButtonLabel(
|
||||||
|
progress: { current: number; total: number } | null,
|
||||||
|
count: number,
|
||||||
|
t: ReturnType<typeof useTranslation>['t'],
|
||||||
|
) {
|
||||||
|
if (progress) return `${progress.current}/${progress.total}`
|
||||||
|
return `${t('admin.shows.addSelected')} (${count})`
|
||||||
|
}
|
||||||
|
|
||||||
export function ShowDetail({ showId }: { showId: string }) {
|
export function ShowDetail({ showId }: { showId: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -231,9 +241,7 @@ export function ShowDetail({ showId }: { showId: string }) {
|
|||||||
{t('admin.shows.deselectAll')}
|
{t('admin.shows.deselectAll')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" disabled={selected.length === 0 || adding != null} onClick={() => void bulkAdd()}>
|
<Button size="sm" disabled={selected.length === 0 || adding != null} onClick={() => void bulkAdd()}>
|
||||||
{adding
|
{addButtonLabel(adding, isSingle ? Math.min(1, selected.length) : selected.length, t)}
|
||||||
? `${adding.current}/${adding.total}`
|
|
||||||
: `${t('admin.shows.addSelected')} (${isSingle ? Math.min(1, selected.length) : selected.length})`}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -347,20 +347,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{s.expected == null ? (
|
<SeasonGapNote gap={s} />
|
||||||
<p className="mt-1 text-xs text-amber-500">
|
|
||||||
{t('admin.metadata.missingUnknown')}
|
|
||||||
</p>
|
|
||||||
) : s.missing.length === 0 ? (
|
|
||||||
<p className="mt-1 text-xs text-emerald-500">
|
|
||||||
{t('admin.metadata.missingNone')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
{t('admin.metadata.missingList')}:{' '}
|
|
||||||
<span className="text-foreground">{s.missing.join(', ')}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -370,3 +357,21 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Итог по сезону: чего не хватает — или что полный состав сезона неизвестен. */
|
||||||
|
function SeasonGapNote({ gap }: { gap: MissingEpisodesReport['seasons'][number] }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (gap.expected == null)
|
||||||
|
return <p className="mt-1 text-xs text-amber-500">{t('admin.metadata.missingUnknown')}</p>
|
||||||
|
|
||||||
|
if (gap.missing.length === 0)
|
||||||
|
return <p className="mt-1 text-xs text-emerald-500">{t('admin.metadata.missingNone')}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{t('admin.metadata.missingList')}:{' '}
|
||||||
|
<span className="text-foreground">{gap.missing.join(', ')}</span>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,12 +30,10 @@ export function LoginForm({ onSuccess }: { onSuccess: () => void }) {
|
|||||||
applyAuthResponse(auth)
|
applyAuthResponse(auth)
|
||||||
onSuccess()
|
onSuccess()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const status = error instanceof HttpError ? error.status : 0
|
||||||
error instanceof HttpError && error.status === 401
|
let message = t('auth.genericError')
|
||||||
? t('auth.invalidCredentials')
|
if (status === 401) message = t('auth.invalidCredentials')
|
||||||
: error instanceof HttpError && error.status === 403
|
else if (status === 403) message = t('auth.blocked')
|
||||||
? t('auth.blocked')
|
|
||||||
: t('auth.genericError')
|
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,29 @@ function formatTime(iso: string) {
|
|||||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Кадр серии, если он есть; иначе постер шоу (он вертикальный, отсюда другая ширина). */
|
||||||
|
function EntryThumb({ entry }: { entry: PublicEpgEntryDto | undefined }) {
|
||||||
|
if (entry?.episodeStillImageId)
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={imageUrl(entry.episodeStillImageId)}
|
||||||
|
alt=""
|
||||||
|
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (entry?.showPosterImageId)
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={imageUrl(entry.showPosterImageId)}
|
||||||
|
alt=""
|
||||||
|
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function AirPage() {
|
export function AirPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
@@ -194,49 +217,38 @@ export function AirPage() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{selected && watchReady ? (
|
{/* До выдачи cookie tw_stream плеер грузить нечем — держим место заглушкой. */}
|
||||||
playerError ? (
|
{(!selected || !watchReady) && (
|
||||||
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
|
|
||||||
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="font-medium">{t('air.offline')}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="outline" onClick={retry}>
|
|
||||||
<RotateCw className="h-4 w-4" />
|
|
||||||
{t('air.retry')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ChannelPlayer
|
|
||||||
key={`${selected}-${attempt}`}
|
|
||||||
slug={selected}
|
|
||||||
channel={currentChannel}
|
|
||||||
nextUp={nextUp}
|
|
||||||
flash={flash}
|
|
||||||
onUnavailable={handleUnavailable}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
||||||
)}
|
)}
|
||||||
|
{selected && watchReady && playerError && (
|
||||||
|
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
|
||||||
|
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium">{t('air.offline')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
<RotateCw className="h-4 w-4" />
|
||||||
|
{t('air.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected && watchReady && !playerError && (
|
||||||
|
<ChannelPlayer
|
||||||
|
key={`${selected}-${attempt}`}
|
||||||
|
slug={selected}
|
||||||
|
channel={currentChannel}
|
||||||
|
nextUp={nextUp}
|
||||||
|
flash={flash}
|
||||||
|
onUnavailable={handleUnavailable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{current && (
|
{current && (
|
||||||
<div className="crt-panel flex gap-3 rounded-md p-3">
|
<div className="crt-panel flex gap-3 rounded-md p-3">
|
||||||
{currentEntry?.episodeStillImageId ? (
|
<EntryThumb entry={currentEntry} />
|
||||||
<img
|
|
||||||
src={imageUrl(currentEntry.episodeStillImageId)}
|
|
||||||
alt=""
|
|
||||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
|
||||||
/>
|
|
||||||
) : currentEntry?.showPosterImageId ? (
|
|
||||||
<img
|
|
||||||
src={imageUrl(currentEntry.showPosterImageId)}
|
|
||||||
alt=""
|
|
||||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge>{t('air.now')}</Badge>
|
<Badge>{t('air.now')}</Badge>
|
||||||
|
|||||||
@@ -25,5 +25,6 @@ export function getEpg(slug: string, from?: Date, to?: Date) {
|
|||||||
if (from) query.set('from', from.toISOString())
|
if (from) query.set('from', from.toISOString())
|
||||||
if (to) query.set('to', to.toISOString())
|
if (to) query.set('to', to.toISOString())
|
||||||
const qs = query.toString()
|
const qs = query.toString()
|
||||||
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
const suffix = qs ? `?${qs}` : ''
|
||||||
|
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${suffix}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function sortRows<T>(
|
|||||||
if (av == null) return 1
|
if (av == null) return 1
|
||||||
if (bv == null) return -1
|
if (bv == null) return -1
|
||||||
if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir
|
if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir
|
||||||
return (av < bv ? -1 : av > bv ? 1 : 0) * dir
|
if (av < bv) return -dir
|
||||||
|
if (av > bv) return dir
|
||||||
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ export function SortHeader({
|
|||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const active = sort.key === sortKey
|
const active = sort.key === sortKey
|
||||||
const Icon = !active ? ChevronsUpDown : sort.desc ? ArrowDown : ArrowUp
|
const direction = sort.desc ? 'descending' : 'ascending'
|
||||||
|
let Icon = ChevronsUpDown
|
||||||
|
if (active) Icon = sort.desc ? ArrowDown : ArrowUp
|
||||||
return (
|
return (
|
||||||
// aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет,
|
// aria-sort — атрибут заголовка столбца, а не кнопки внутри него: у роли button его нет,
|
||||||
// и скринридер там его просто не прочтёт.
|
// и скринридер там его просто не прочтёт.
|
||||||
<th
|
<th
|
||||||
className={cn('px-4 py-2 font-medium', className)}
|
className={cn('px-4 py-2 font-medium', className)}
|
||||||
aria-sort={active ? (sort.desc ? 'descending' : 'ascending') : 'none'}
|
aria-sort={active ? direction : 'none'}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user