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;