Refactor IPTV endpoint logic for improved readability and maintainability
ci / build-backend (push) Successful in 1m34s
ci / build-frontend (push) Successful in 55s
ci / tests (push) Successful in 1m43s
ci / sonar (push) Successful in 4m38s

Consolidated channel and programme XML writing into dedicated methods, WriteChannel and WriteProgramme, enhancing code clarity and reducing duplication. Introduced a WriteIcon method to streamline icon handling for both channels and programmes. Updated GetChannelScheduleQueryHandler to utilize a new FranchisePosition record for better representation of franchise details in schedule entries, improving the overall structure and readability of the code.
This commit is contained in:
Leonid Pershin
2026-07-30 01:37:20 +03:00
parent 8119305c36
commit bd89abf24b
3 changed files with 91 additions and 69 deletions
@@ -151,46 +151,14 @@ public static class IptvEndpoints
writer.WriteStartElement("tv"); writer.WriteStartElement("tv");
writer.WriteAttributeString("generator-info-name", "TeleWave"); writer.WriteAttributeString("generator-info-name", "TeleWave");
// Сначала все каналы, потом все передачи: XMLTV требует именно такого порядка секций.
foreach (var channel in channels) foreach (var channel in channels)
{ WriteChannel(writer, channel, origin);
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 channel in channels)
{
foreach (var programme in channel.Programmes) foreach (var programme in channel.Programmes)
{ WriteProgramme(writer, channel.Slug, programme, origin);
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();
} }
writer.WriteEndElement(); writer.WriteEndElement();
@@ -200,6 +168,53 @@ public static class IptvEndpoints
return output.ToString(); 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();
}
/// <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)
@@ -97,13 +97,12 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
join collection in dbContext.Collections.AsNoTracking() join collection in dbContext.Collections.AsNoTracking()
on item.CollectionId equals collection.Id on item.CollectionId equals collection.Id
where collectionIds.Contains(item.CollectionId) where collectionIds.Contains(item.CollectionId)
select new select new CollectionPart(
{
item.CollectionId, item.CollectionId,
item.ShowId, item.ShowId,
item.Position, item.Position,
collection.Name, collection.Name
} )
).ToListAsync(cancellationToken); ).ToListAsync(cancellationToken);
var partsByCollection = collectionParts var partsByCollection = collectionParts
.GroupBy(p => p.CollectionId) .GroupBy(p => p.CollectionId)
@@ -120,25 +119,7 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
bumperText = BumperText(bumper.RenderedLinesJson); bumperText = BumperText(bumper.RenderedLinesJson);
} }
// Номер части ищем по позиции шоу в коллекции, а не по индексу записи: франшизу могли var franchise = FranchisePart(partsByCollection, e.CollectionId, e.ShowId);
// переупорядочить, и «часть 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;
}
}
dtos.Add( dtos.Add(
new ScheduleEntryDto( new ScheduleEntryDto(
@@ -155,9 +136,9 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
: null, : null,
bumperName, bumperName,
bumperText, bumperText,
part, franchise?.Part,
parts, franchise?.Total,
collectionName franchise?.Name
) )
); );
} }
@@ -165,6 +146,33 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos); return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
} }
/// <summary>Место записи во франшизе: «часть N из M» и её название.</summary>
private sealed record FranchisePosition(int Part, int Total, string Name);
/// <summary>
/// Какой частью коллекции шла запись. Номер ищется по позиции шоу в текущем составе, а не по
/// индексу записи: франшизу могли переупорядочить, и «часть 2» обязана означать вторую сейчас.
/// </summary>
private static FranchisePosition? FranchisePart(
IReadOnlyDictionary<Guid, List<CollectionPart>> 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);
}
/// <summary>Позиция шоу в коллекции вместе с её названием — то, чем считается номер части.</summary>
private sealed record CollectionPart(Guid CollectionId, Guid ShowId, int Position, string Name);
/// <summary>Строки сыгравшей заставки одной меткой для расписания; null — показывать нечего.</summary> /// <summary>Строки сыгравшей заставки одной меткой для расписания; null — показывать нечего.</summary>
private static string? BumperText(string? renderedLinesJson) private static string? BumperText(string? renderedLinesJson)
{ {
@@ -102,11 +102,10 @@ public sealed class GetIptvGuideQueryHandler(IAppDbContext dbContext)
// Серии одного шоу встык — это один блок программы. Склеиваем по id, а не по // Серии одного шоу встык — это один блок программы. Склеиваем по id, а не по
// названию: у разных шоу оно может совпасть. Разрыв во времени блок рвёт — между // названию: у разных шоу оно может совпасть. Разрыв во времени блок рвёт — между
// выходами шла реклама, и объявлять это одной передачей нельзя. // выходами шла реклама, и объявлять это одной передачей нельзя.
if ( //
lastShowId == entry.ShowId // Непустоту списка не проверяем: lastShowId выставляется только вместе с добавленной
&& programmes.Count > 0 // передачей, и совпасть с ним на пустом списке нечему.
&& programmes[^1].EndsAtUtc == entry.StartsAtUtc if (lastShowId == entry.ShowId && programmes[^1].EndsAtUtc == entry.StartsAtUtc)
)
{ {
programmes[^1] = programmes[^1] with { EndsAtUtc = entry.EndsAtUtc }; programmes[^1] = programmes[^1] with { EndsAtUtc = entry.EndsAtUtc };
continue; continue;