diff --git a/backend/src/TeleWave.Api/Endpoints/IptvEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/IptvEndpoints.cs
index 0dca789..14f46c0 100644
--- a/backend/src/TeleWave.Api/Endpoints/IptvEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/IptvEndpoints.cs
@@ -151,46 +151,14 @@ public static class IptvEndpoints
writer.WriteStartElement("tv");
writer.WriteAttributeString("generator-info-name", "TeleWave");
+ // Сначала все каналы, потом все передачи: XMLTV требует именно такого порядка секций.
+ foreach (var channel in channels)
+ WriteChannel(writer, channel, origin);
+
foreach (var channel in channels)
{
- writer.WriteStartElement("channel");
- writer.WriteAttributeString("id", channel.Slug);
- writer.WriteElementString("display-name", channel.Name);
- if (channel.Number is { } number)
- writer.WriteElementString(
- "display-name",
- number.ToString(CultureInfo.InvariantCulture)
- );
- if (channel.LogoImageId is { } logoId)
- {
- writer.WriteStartElement("icon");
- writer.WriteAttributeString("src", $"{origin}/api/images/{logoId}");
- writer.WriteEndElement();
- }
- writer.WriteEndElement();
- }
-
- foreach (var channel in channels)
- foreach (var programme in channel.Programmes)
- {
- writer.WriteStartElement("programme");
- writer.WriteAttributeString("start", XmltvTime(programme.StartsAtUtc));
- writer.WriteAttributeString("stop", XmltvTime(programme.EndsAtUtc));
- writer.WriteAttributeString("channel", channel.Slug);
-
- writer.WriteElementString("title", programme.Title);
- if (!string.IsNullOrWhiteSpace(programme.SubTitle))
- writer.WriteElementString("sub-title", programme.SubTitle);
- if (!string.IsNullOrWhiteSpace(programme.Description))
- writer.WriteElementString("desc", programme.Description);
- if (programme.ImageId is { } imageId)
- {
- writer.WriteStartElement("icon");
- writer.WriteAttributeString("src", $"{origin}/api/images/{imageId}");
- writer.WriteEndElement();
- }
-
- writer.WriteEndElement();
+ foreach (var programme in channel.Programmes)
+ WriteProgramme(writer, channel.Slug, programme, origin);
}
writer.WriteEndElement();
@@ -200,6 +168,53 @@ public static class IptvEndpoints
return output.ToString();
}
+ private static void WriteChannel(XmlWriter writer, IptvChannelDto channel, string origin)
+ {
+ writer.WriteStartElement("channel");
+ writer.WriteAttributeString("id", channel.Slug);
+ writer.WriteElementString("display-name", channel.Name);
+ // Номер вторым display-name: так плееры с нумерацией подхватывают его без своих настроек.
+ if (channel.Number is { } number)
+ writer.WriteElementString(
+ "display-name",
+ number.ToString(CultureInfo.InvariantCulture)
+ );
+ WriteIcon(writer, channel.LogoImageId, origin);
+ writer.WriteEndElement();
+ }
+
+ private static void WriteProgramme(
+ XmlWriter writer,
+ string slug,
+ IptvProgrammeDto programme,
+ string origin
+ )
+ {
+ writer.WriteStartElement("programme");
+ writer.WriteAttributeString("start", XmltvTime(programme.StartsAtUtc));
+ writer.WriteAttributeString("stop", XmltvTime(programme.EndsAtUtc));
+ writer.WriteAttributeString("channel", slug);
+
+ writer.WriteElementString("title", programme.Title);
+ if (!string.IsNullOrWhiteSpace(programme.SubTitle))
+ writer.WriteElementString("sub-title", programme.SubTitle);
+ if (!string.IsNullOrWhiteSpace(programme.Description))
+ writer.WriteElementString("desc", programme.Description);
+ WriteIcon(writer, programme.ImageId, origin);
+
+ writer.WriteEndElement();
+ }
+
+ private static void WriteIcon(XmlWriter writer, Guid? imageId, string origin)
+ {
+ if (imageId is not { } id)
+ return;
+
+ writer.WriteStartElement("icon");
+ writer.WriteAttributeString("src", $"{origin}/api/images/{id}");
+ writer.WriteEndElement();
+ }
+
/// Время XMLTV: YYYYMMDDHHMMSS +0000. Отдаём в UTC — плеер сдвинет сам.
private static string XmltvTime(DateTimeOffset moment) =>
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
diff --git a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs
index ad83521..8b3bda9 100644
--- a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs
@@ -97,13 +97,12 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
join collection in dbContext.Collections.AsNoTracking()
on item.CollectionId equals collection.Id
where collectionIds.Contains(item.CollectionId)
- select new
- {
+ select new CollectionPart(
item.CollectionId,
item.ShowId,
item.Position,
- collection.Name,
- }
+ collection.Name
+ )
).ToListAsync(cancellationToken);
var partsByCollection = collectionParts
.GroupBy(p => p.CollectionId)
@@ -120,25 +119,7 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
bumperText = BumperText(bumper.RenderedLinesJson);
}
- // Номер части ищем по позиции шоу в коллекции, а не по индексу записи: франшизу могли
- // переупорядочить, и «часть 2» обязана означать вторую в текущем составе.
- int? part = null;
- int? parts = null;
- string? collectionName = null;
- if (
- e.CollectionId is { } collectionId
- && e.ShowId is { } showId
- && partsByCollection.TryGetValue(collectionId, out var ordered)
- )
- {
- var at = ordered.FindIndex(p => p.ShowId == showId);
- if (at >= 0)
- {
- part = at + 1;
- parts = ordered.Count;
- collectionName = ordered[0].Name;
- }
- }
+ var franchise = FranchisePart(partsByCollection, e.CollectionId, e.ShowId);
dtos.Add(
new ScheduleEntryDto(
@@ -155,9 +136,9 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
: null,
bumperName,
bumperText,
- part,
- parts,
- collectionName
+ franchise?.Part,
+ franchise?.Total,
+ franchise?.Name
)
);
}
@@ -165,6 +146,33 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
return Result.Success>(dtos);
}
+ /// Место записи во франшизе: «часть N из M» и её название.
+ private sealed record FranchisePosition(int Part, int Total, string Name);
+
+ ///
+ /// Какой частью коллекции шла запись. Номер ищется по позиции шоу в текущем составе, а не по
+ /// индексу записи: франшизу могли переупорядочить, и «часть 2» обязана означать вторую сейчас.
+ ///
+ private static FranchisePosition? FranchisePart(
+ IReadOnlyDictionary> partsByCollection,
+ Guid? collectionId,
+ Guid? showId
+ )
+ {
+ if (
+ collectionId is not { } id
+ || showId is not { } show
+ || !partsByCollection.TryGetValue(id, out var ordered)
+ )
+ return null;
+
+ var at = ordered.FindIndex(p => p.ShowId == show);
+ return at < 0 ? null : new FranchisePosition(at + 1, ordered.Count, ordered[0].Name);
+ }
+
+ /// Позиция шоу в коллекции вместе с её названием — то, чем считается номер части.
+ private sealed record CollectionPart(Guid CollectionId, Guid ShowId, int Position, string Name);
+
/// Строки сыгравшей заставки одной меткой для расписания; null — показывать нечего.
private static string? BumperText(string? renderedLinesJson)
{
diff --git a/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQueryHandler.cs
index 8bf7761..b95a525 100644
--- a/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQueryHandler.cs
@@ -102,11 +102,10 @@ public sealed class GetIptvGuideQueryHandler(IAppDbContext dbContext)
// Серии одного шоу встык — это один блок программы. Склеиваем по id, а не по
// названию: у разных шоу оно может совпасть. Разрыв во времени блок рвёт — между
// выходами шла реклама, и объявлять это одной передачей нельзя.
- if (
- lastShowId == entry.ShowId
- && programmes.Count > 0
- && programmes[^1].EndsAtUtc == entry.StartsAtUtc
- )
+ //
+ // Непустоту списка не проверяем: lastShowId выставляется только вместе с добавленной
+ // передачей, и совпасть с ним на пустом списке нечему.
+ if (lastShowId == entry.ShowId && programmes[^1].EndsAtUtc == entry.StartsAtUtc)
{
programmes[^1] = programmes[^1] with { EndsAtUtc = entry.EndsAtUtc };
continue;