Enhance channel settings to include icon image support for IPTV
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 1m22s
ci / sonar (push) Successful in 3m41s

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:
Leonid Pershin
2026-08-02 17:07:24 +03:00
parent bed6d4fcfc
commit 7a164dbd90
27 changed files with 1758 additions and 28 deletions
@@ -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,
@@ -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>;
@@ -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>&lt;icon&gt;</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>
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);
+8
View File
@@ -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"
+2
View File
@@ -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
} }
+5
View File
@@ -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}}',
+5
View File
@@ -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}}',