Enhance channel settings to include icon image support for IPTV
Updated the Channel and related DTOs to incorporate an optional IconImageId property, allowing for the specification of a channel icon for external IPTV players. Modified the UpdateChannelSettings command and its handler to accommodate this new property. Adjusted frontend components to manage the icon image, including selection and display functionality. Updated localization files to support new UI elements related to the IPTV icon. This enhancement improves the user experience by providing a visual representation of channels in IPTV applications.
This commit is contained in:
@@ -156,7 +156,13 @@ public static class ChannelEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateChannelSettingsCommand(id, body.Name, body.IsEnabled, body.FillerAssetId),
|
new UpdateChannelSettingsCommand(
|
||||||
|
id,
|
||||||
|
body.Name,
|
||||||
|
body.IsEnabled,
|
||||||
|
body.FillerAssetId,
|
||||||
|
body.IconImageId
|
||||||
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -187,7 +193,12 @@ public sealed record UpdateChannelTimeBody(
|
|||||||
TimeOnly DayStartTime
|
TimeOnly DayStartTime
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record UpdateChannelSettingsBody(string Name, bool IsEnabled, Guid? FillerAssetId);
|
public sealed record UpdateChannelSettingsBody(
|
||||||
|
string Name,
|
||||||
|
bool IsEnabled,
|
||||||
|
Guid? FillerAssetId,
|
||||||
|
Guid? IconImageId
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
|
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
|
||||||
public sealed record UpdateViewerSettingsBody(
|
public sealed record UpdateViewerSettingsBody(
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public static class IptvEndpoints
|
|||||||
/// <summary>Сколько суток программы класть в XMLTV: горизонт ленты и так неделя.</summary>
|
/// <summary>Сколько суток программы класть в XMLTV: горизонт ленты и так неделя.</summary>
|
||||||
private static readonly TimeSpan GuideWindow = TimeSpan.FromDays(7);
|
private static readonly TimeSpan GuideWindow = TimeSpan.FromDays(7);
|
||||||
|
|
||||||
|
/// <summary>Заглушка иконки канала, когда своя не задана: фавикон сайта из wwwroot.</summary>
|
||||||
|
private const string FaviconPath = "/favicon.svg";
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapIptvEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapIptvEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var iptv = app.MapGroup("/api/iptv").WithTags("IPTV");
|
var iptv = app.MapGroup("/api/iptv").WithTags("IPTV");
|
||||||
@@ -109,9 +112,8 @@ public static class IptvEndpoints
|
|||||||
|
|
||||||
foreach (var channel in channels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
var logo = channel.LogoImageId is { } imageId
|
// tvg-logo ставим всегда: своя иконка либо фавикон-заглушка — иначе в плеере пустой квадрат.
|
||||||
? $" tvg-logo=\"{origin}/api/images/{imageId}\""
|
var logo = $" tvg-logo=\"{ChannelIcon(origin, channel.IconImageId)}\"";
|
||||||
: string.Empty;
|
|
||||||
var number = channel.Number is { } value
|
var number = channel.Number is { } value
|
||||||
? $" tvg-chno=\"{value.ToString(CultureInfo.InvariantCulture)}\""
|
? $" tvg-chno=\"{value.ToString(CultureInfo.InvariantCulture)}\""
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
@@ -189,7 +191,10 @@ public static class IptvEndpoints
|
|||||||
"display-name",
|
"display-name",
|
||||||
number.ToString(CultureInfo.InvariantCulture)
|
number.ToString(CultureInfo.InvariantCulture)
|
||||||
);
|
);
|
||||||
WriteIcon(writer, channel.LogoImageId, origin);
|
// Иконку канала пишем всегда: своя либо фавикон-заглушка (в отличие от иконок передач ниже).
|
||||||
|
writer.WriteStartElement("icon");
|
||||||
|
writer.WriteAttributeString("src", ChannelIcon(origin, channel.IconImageId));
|
||||||
|
writer.WriteEndElement();
|
||||||
writer.WriteEndElement();
|
writer.WriteEndElement();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +230,10 @@ public static class IptvEndpoints
|
|||||||
writer.WriteEndElement();
|
writer.WriteEndElement();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>URL иконки канала: своя из реестра либо фавикон-заглушка сайта (абсолютный адрес).</summary>
|
||||||
|
private static string ChannelIcon(string origin, Guid? iconImageId) =>
|
||||||
|
iconImageId is { } id ? $"{origin}/api/images/{id}" : $"{origin}{FaviconPath}";
|
||||||
|
|
||||||
/// <summary>Время XMLTV: <c>YYYYMMDDHHMMSS +0000</c>. Отдаём в UTC — плеер сдвинет сам.</summary>
|
/// <summary>Время XMLTV: <c>YYYYMMDDHHMMSS +0000</c>. Отдаём в UTC — плеер сдвинет сам.</summary>
|
||||||
private static string XmltvTime(DateTimeOffset moment) =>
|
private static string XmltvTime(DateTimeOffset moment) =>
|
||||||
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
|
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public sealed record ChannelDto(
|
|||||||
TimeOnly DayStartTime,
|
TimeOnly DayStartTime,
|
||||||
Guid? TemplateId,
|
Guid? TemplateId,
|
||||||
Guid? FillerAssetId,
|
Guid? FillerAssetId,
|
||||||
|
/// <summary>Иконка канала для внешних IPTV-плееров (M3U/XMLTV); null — отдаётся заглушка.</summary>
|
||||||
|
Guid? IconImageId,
|
||||||
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
||||||
ViewerSettingsDto Viewer
|
ViewerSettingsDto Viewer
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
|||||||
channel.DayStartTime,
|
channel.DayStartTime,
|
||||||
channel.TemplateId,
|
channel.TemplateId,
|
||||||
channel.FillerAssetId,
|
channel.FillerAssetId,
|
||||||
|
channel.IconImageId,
|
||||||
new ViewerSettingsDto(
|
new ViewerSettingsDto(
|
||||||
channel.LogoImageId,
|
channel.LogoImageId,
|
||||||
channel.LogoCorner,
|
channel.LogoCorner,
|
||||||
|
|||||||
+2
-1
@@ -7,5 +7,6 @@ public sealed record UpdateChannelSettingsCommand(
|
|||||||
Guid ChannelId,
|
Guid ChannelId,
|
||||||
string Name,
|
string Name,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
Guid? FillerAssetId
|
Guid? FillerAssetId,
|
||||||
|
Guid? IconImageId
|
||||||
) : ICommand<Result>;
|
) : ICommand<Result>;
|
||||||
|
|||||||
+6
-1
@@ -30,7 +30,12 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
|||||||
return Result.Failure(ChannelErrors.AssetNotFound);
|
return Result.Failure(ChannelErrors.AssetNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.UpdateSettings(command.Name, command.IsEnabled, command.FillerAssetId);
|
channel.UpdateSettings(
|
||||||
|
command.Name,
|
||||||
|
command.IsEnabled,
|
||||||
|
command.FillerAssetId,
|
||||||
|
command.IconImageId
|
||||||
|
);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public sealed class GetIptvGuideQueryHandler(IAppDbContext dbContext)
|
|||||||
c.Slug,
|
c.Slug,
|
||||||
c.Name,
|
c.Name,
|
||||||
c.Number,
|
c.Number,
|
||||||
c.LogoImageId,
|
c.IconImageId,
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (channels.Count == 0)
|
if (channels.Count == 0)
|
||||||
@@ -129,7 +129,7 @@ public sealed class GetIptvGuideQueryHandler(IAppDbContext dbContext)
|
|||||||
channel.Slug,
|
channel.Slug,
|
||||||
channel.Name,
|
channel.Name,
|
||||||
channel.Number,
|
channel.Number,
|
||||||
channel.LogoImageId,
|
channel.IconImageId,
|
||||||
programmes
|
programmes
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ public sealed record IptvChannelDto(
|
|||||||
string Slug,
|
string Slug,
|
||||||
string Name,
|
string Name,
|
||||||
int? Number,
|
int? Number,
|
||||||
Guid? LogoImageId,
|
Guid? IconImageId,
|
||||||
IReadOnlyList<IptvProgrammeDto> Programmes
|
IReadOnlyList<IptvProgrammeDto> Programmes
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ public class Channel
|
|||||||
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
||||||
public Guid? FillerAssetId { get; private set; }
|
public Guid? FillerAssetId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Иконка канала для внешних IPTV-плееров (tvg-logo в M3U и <c><icon></c> в XMLTV): ссылка
|
||||||
|
/// на реестр изображений или null. Отдельная от экранного логотипа-оверлея
|
||||||
|
/// (<see cref="LogoImageId"/>): тот — полупрозрачный водяной знак в углу видео, а здесь нужна
|
||||||
|
/// нормальная квадратная иконка канала. Null — плеер получит заглушку (фавикон сайта).
|
||||||
|
/// </summary>
|
||||||
|
public Guid? IconImageId { get; private set; }
|
||||||
|
|
||||||
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
|
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
|
||||||
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
|
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
|
||||||
|
|
||||||
@@ -81,11 +89,12 @@ public class Channel
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
public void UpdateSettings(string name, bool isEnabled, Guid? fillerAssetId)
|
public void UpdateSettings(string name, bool isEnabled, Guid? fillerAssetId, Guid? iconImageId)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
IsEnabled = isEnabled;
|
IsEnabled = isEnabled;
|
||||||
FillerAssetId = fillerAssetId;
|
FillerAssetId = fillerAssetId;
|
||||||
|
IconImageId = iconImageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
|
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
|
||||||
|
|||||||
Generated
+1605
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChannelIconImage : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "IconImageId",
|
||||||
|
table: "Channels",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(name: "IconImageId", table: "Channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -317,6 +317,9 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid?>("FillerAssetId")
|
b.Property<Guid?>("FillerAssetId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("IconImageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<bool>("IsEnabled")
|
b.Property<bool>("IsEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class ChannelDetailsTests
|
|||||||
var channel = Channel.Create("Первый", "one", T0);
|
var channel = Channel.Create("Первый", "one", T0);
|
||||||
var fillerId = Guid.NewGuid();
|
var fillerId = Guid.NewGuid();
|
||||||
var logoId = Guid.NewGuid();
|
var logoId = Guid.NewGuid();
|
||||||
channel.UpdateSettings("Первый", isEnabled: true, fillerId);
|
channel.UpdateSettings("Первый", isEnabled: true, fillerId, null);
|
||||||
channel.UpdateTimeSettings(3, 120, new TimeOnly(5, 0));
|
channel.UpdateTimeSettings(3, 120, new TimeOnly(5, 0));
|
||||||
channel.UpdateViewerSettings(logoId, LogoCorner.BottomLeft, 0.4, showClock: true, 0.25);
|
channel.UpdateViewerSettings(logoId, LogoCorner.BottomLeft, 0.4, showClock: true, 0.25);
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public class ChannelHandlersTests
|
|||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
|
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
|
||||||
new UpdateChannelSettingsCommand(channel.Id, "c2", true, null),
|
new UpdateChannelSettingsCommand(channel.Id, "c2", true, null, null),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ public class DeleteGuardsTests
|
|||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var asset = MediaAsset.Register("filler.mkv", ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register("filler.mkv", ".mkv", MediaSource.Upload);
|
||||||
var channel = Channel.Create("c", "c", T0);
|
var channel = Channel.Create("c", "c", T0);
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, asset.Id);
|
channel.UpdateSettings(channel.Name, isEnabled: true, asset.Id, null);
|
||||||
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public class TelegramBotTests
|
|||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
var code = TelegramLinkCode.Issue("abc123", userId, Now);
|
var code = TelegramLinkCode.Issue("abc123", userId, Now);
|
||||||
var channel = Channel.Create("Мультреалити", "mult", Now);
|
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||||
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
@@ -145,7 +145,7 @@ public class TelegramBotTests
|
|||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Мультреалити", "mult", Now);
|
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||||
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
||||||
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
@@ -194,7 +194,7 @@ public class TelegramBotTests
|
|||||||
// один блок: зритель спрашивает «что дальше», а не «в каком порядке лежат файлы».
|
// один блок: зритель спрашивает «что дальше», а не «в каком порядке лежат файлы».
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Мультреалити", "mult", Now);
|
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||||
var first = Show.Create("Симпсоны", ShowKind.Series);
|
var first = Show.Create("Симпсоны", ShowKind.Series);
|
||||||
var second = Show.Create("Футурама", ShowKind.Series);
|
var second = Show.Create("Футурама", ShowKind.Series);
|
||||||
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
||||||
@@ -250,7 +250,7 @@ public class TelegramBotTests
|
|||||||
// Два меню в чате — это две панели с разными галочками, и одна из них врёт. Старое снимаем.
|
// Два меню в чате — это две панели с разными галочками, и одна из них врёт. Старое снимаем.
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Мультреалити", "mult", Now);
|
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||||
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
||||||
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ public class IptvGuideTests
|
|||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var visible = Enabled("Первый", "one");
|
var visible = Enabled("Первый", "one");
|
||||||
var hidden = Channel.Create("Выключенный", "off", T0);
|
var hidden = Channel.Create("Выключенный", "off", T0);
|
||||||
hidden.UpdateSettings("Выключенный", isEnabled: false, null);
|
hidden.UpdateSettings("Выключенный", isEnabled: false, null, null);
|
||||||
|
|
||||||
var show = Show.Create("Ворон", ShowKind.Single);
|
var show = Show.Create("Ворон", ShowKind.Single);
|
||||||
var asset = MediaAsset.Register("crow.mkv", ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register("crow.mkv", ".mkv", MediaSource.Upload);
|
||||||
@@ -187,7 +187,7 @@ public class IptvGuideTests
|
|||||||
private static Channel Enabled(string name, string slug)
|
private static Channel Enabled(string name, string slug)
|
||||||
{
|
{
|
||||||
var channel = Channel.Create(name, slug, T0);
|
var channel = Channel.Create(name, slug, T0);
|
||||||
channel.UpdateSettings(name, isEnabled: true, null);
|
channel.UpdateSettings(name, isEnabled: true, null, null);
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ public class ListPublicChannelsTests
|
|||||||
var disabled = Channel.Create("Выключенный", "off", T0);
|
var disabled = Channel.Create("Выключенный", "off", T0);
|
||||||
|
|
||||||
foreach (var channel in new[] { numbered, first, unnumbered })
|
foreach (var channel in new[] { numbered, first, unnumbered })
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||||
disabled.UpdateSettings("Выключенный", isEnabled: false, null);
|
disabled.UpdateSettings("Выключенный", isEnabled: false, null, null);
|
||||||
|
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
@@ -57,7 +57,7 @@ public class ListPublicChannelsTests
|
|||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("Первый", "one", T0);
|
var channel = Channel.Create("Первый", "one", T0);
|
||||||
channel.UpdateSettings("Первый", isEnabled: true, null);
|
channel.UpdateSettings("Первый", isEnabled: true, null, null);
|
||||||
var logoId = Guid.NewGuid();
|
var logoId = Guid.NewGuid();
|
||||||
channel.UpdateViewerSettings(logoId, LogoCorner.TopRight, 0.5, showClock: true, 0.3);
|
channel.UpdateViewerSettings(logoId, LogoCorner.TopRight, 0.5, showClock: true, 0.3);
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public class ValidatorTests
|
|||||||
{
|
{
|
||||||
var v = new UpdateChannelSettingsCommandValidator();
|
var v = new UpdateChannelSettingsCommandValidator();
|
||||||
|
|
||||||
var good = new UpdateChannelSettingsCommand(Guid.NewGuid(), "Name", true, null);
|
var good = new UpdateChannelSettingsCommand(Guid.NewGuid(), "Name", true, null, null);
|
||||||
Assert.True(v.Validate(good).IsValid);
|
Assert.True(v.Validate(good).IsValid);
|
||||||
|
|
||||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class ChannelTests
|
|||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
var filler = Guid.NewGuid();
|
var filler = Guid.NewGuid();
|
||||||
|
|
||||||
channel.UpdateSettings("N2", false, filler);
|
channel.UpdateSettings("N2", false, filler, null);
|
||||||
|
|
||||||
Assert.Equal("N2", channel.Name);
|
Assert.Equal("N2", channel.Name);
|
||||||
Assert.False(channel.IsEnabled);
|
Assert.False(channel.IsEnabled);
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
|
|||||||
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
|
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id);
|
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id, null);
|
||||||
|
|
||||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||||
var layer = template.AddLayer("Базовый", 10);
|
var layer = template.AddLayer("Базовый", 10);
|
||||||
@@ -436,7 +436,7 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
|
|||||||
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(30));
|
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(30));
|
||||||
|
|
||||||
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id);
|
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id, null);
|
||||||
|
|
||||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||||
var layer = template.AddLayer("Базовый", 10);
|
var layer = template.AddLayer("Базовый", 10);
|
||||||
|
|||||||
@@ -1131,6 +1131,14 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
|
|||||||
включённых каналов и ссылка на XMLTV с программой передач (`/api/iptv/epg.xml`, горизонт 7 суток,
|
включённых каналов и ссылка на XMLTV с программой передач (`/api/iptv/epg.xml`, горизонт 7 суток,
|
||||||
подряд идущие серии одного шоу склеены в одну передачу, врезки и заставки в гид не попадают).
|
подряд идущие серии одного шоу склеены в одну передачу, врезки и заставки в гид не попадают).
|
||||||
|
|
||||||
|
Иконка канала для плеера (`tvg-logo` в M3U и `<icon>` канала в XMLTV) — это **отдельное поле
|
||||||
|
`Channel.IconImageId`**, а не экранный логотип-оверлей (тот — полупрозрачный водяной знак в углу
|
||||||
|
видео и как иконка канала смотрелся бы плохо). Задаётся в общих настройках канала картинкой из
|
||||||
|
реестра изображений. Если не задана — плеер получает заглушку: фавикон сайта (`/favicon.svg`),
|
||||||
|
чтобы в списке каналов не было пустого квадрата. Абсолютные адреса собираются из схемы/хоста
|
||||||
|
запроса (внешний TLS → `X-Forwarded-*`), поэтому за корректно настроенным прокси ссылки идут по
|
||||||
|
https.
|
||||||
|
|
||||||
Плеер не умеет ни Bearer, ни httpOnly-cookie — только открыть URL. Поэтому пропуск зашит прямо
|
Плеер не умеет ни Bearer, ни httpOnly-cookie — только открыть URL. Поэтому пропуск зашит прямо
|
||||||
в ссылки файла долгим stream-токеном (`Storage:IptvTokenDays`, по умолчанию 30 суток), и его же
|
в ссылки файла долгим stream-токеном (`Storage:IptvTokenDays`, по умолчанию 30 суток), и его же
|
||||||
принимают плейлист канала и сегменты — рядом с cookie, тем же форматом и той же проверкой подписи.
|
принимают плейлист канала и сегменты — рядом с cookie, тем же форматом и той же проверкой подписи.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type ChannelSettingsBody = {
|
|||||||
name: string
|
name: string
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
fillerAssetId: string | null
|
fillerAssetId: string | null
|
||||||
|
iconImageId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { imageUrl } from '@/features/admin/images/api'
|
||||||
|
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||||
import type { ChannelDto } from '@/shared/api/types'
|
import type { ChannelDto } from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
@@ -27,6 +29,8 @@ export function SettingsCard({
|
|||||||
const [name, setName] = useState(channel.name)
|
const [name, setName] = useState(channel.name)
|
||||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||||
|
const [iconImageId, setIconImageId] = useState(channel.iconImageId ?? '')
|
||||||
|
const [iconGalleryOpen, setIconGalleryOpen] = useState(false)
|
||||||
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
||||||
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
||||||
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
||||||
@@ -36,6 +40,7 @@ export function SettingsCard({
|
|||||||
setName(channel.name)
|
setName(channel.name)
|
||||||
setIsEnabled(channel.isEnabled)
|
setIsEnabled(channel.isEnabled)
|
||||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||||
|
setIconImageId(channel.iconImageId ?? '')
|
||||||
setNumber(channel.number?.toString() ?? '')
|
setNumber(channel.number?.toString() ?? '')
|
||||||
setOffsetHours(channel.utcOffsetMinutes / 60)
|
setOffsetHours(channel.utcOffsetMinutes / 60)
|
||||||
setDayStart(channel.dayStartTime.slice(0, 5))
|
setDayStart(channel.dayStartTime.slice(0, 5))
|
||||||
@@ -48,6 +53,7 @@ export function SettingsCard({
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
isEnabled,
|
isEnabled,
|
||||||
fillerAssetId: fillerAssetId || null,
|
fillerAssetId: fillerAssetId || null,
|
||||||
|
iconImageId: iconImageId || null,
|
||||||
})
|
})
|
||||||
await updateChannelTime(channel.id, {
|
await updateChannelTime(channel.id, {
|
||||||
number: number.trim() === '' ? null : Number(number),
|
number: number.trim() === '' ? null : Number(number),
|
||||||
@@ -119,6 +125,35 @@ export function SettingsCard({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.iptvIcon')}</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md border border-border bg-muted/30">
|
||||||
|
{iconImageId ? (
|
||||||
|
<img src={imageUrl(iconImageId)} alt="" className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<span className="text-center text-[10px] leading-tight text-muted-foreground">
|
||||||
|
{t('admin.channels.noIptvIcon')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIconGalleryOpen(true)}>
|
||||||
|
{t('admin.channels.pickIptvIcon')}
|
||||||
|
</Button>
|
||||||
|
{iconImageId && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setIconImageId('')}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.iptvIconHint')}</p>
|
||||||
|
<ImageGallery
|
||||||
|
open={iconGalleryOpen}
|
||||||
|
onOpenChange={setIconGalleryOpen}
|
||||||
|
category="Library"
|
||||||
|
onSelect={(image) => setIconImageId(image.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -651,6 +651,8 @@ export type ChannelDto = {
|
|||||||
dayStartTime: string
|
dayStartTime: string
|
||||||
templateId: string | null
|
templateId: string | null
|
||||||
fillerAssetId: string | null
|
fillerAssetId: string | null
|
||||||
|
/** Иконка канала для внешних IPTV-плееров (M3U/XMLTV); null — отдаётся фавикон-заглушка. */
|
||||||
|
iconImageId: string | null
|
||||||
viewer: ViewerSettings
|
viewer: ViewerSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -785,6 +785,11 @@ export const en = {
|
|||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
filler: 'Filler',
|
filler: 'Filler',
|
||||||
noFiller: 'No filler',
|
noFiller: 'No filler',
|
||||||
|
iptvIcon: 'IPTV icon',
|
||||||
|
noIptvIcon: 'none',
|
||||||
|
pickIptvIcon: 'Pick an icon',
|
||||||
|
iptvIconHint:
|
||||||
|
'Channel logo in M3U/EPG for external players. If unset, the site favicon is served.',
|
||||||
noSchedule: 'Schedule not built yet',
|
noSchedule: 'Schedule not built yet',
|
||||||
airToday: 'Today',
|
airToday: 'Today',
|
||||||
airCount: 'Entries for the day: {{count}}',
|
airCount: 'Entries for the day: {{count}}',
|
||||||
|
|||||||
@@ -780,6 +780,11 @@ export const ru = {
|
|||||||
settings: 'Настройки',
|
settings: 'Настройки',
|
||||||
filler: 'Заглушка',
|
filler: 'Заглушка',
|
||||||
noFiller: 'Без заглушки',
|
noFiller: 'Без заглушки',
|
||||||
|
iptvIcon: 'Иконка для IPTV',
|
||||||
|
noIptvIcon: 'нет',
|
||||||
|
pickIptvIcon: 'Выбрать иконку',
|
||||||
|
iptvIconHint:
|
||||||
|
'Логотип канала в M3U/EPG для внешних плееров. Если не задан — отдаётся фавикон сайта.',
|
||||||
noSchedule: 'Расписание ещё не построено',
|
noSchedule: 'Расписание ещё не построено',
|
||||||
airToday: 'Сегодня',
|
airToday: 'Сегодня',
|
||||||
airCount: 'Записей за сутки: {{count}}',
|
airCount: 'Записей за сутки: {{count}}',
|
||||||
|
|||||||
Reference in New Issue
Block a user