Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 15m30s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 15m48s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m32s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Closes #415. Server-derived health object on the channel list + detail DTOs (built-timeline detection, kind-agnostic across all 5 PlayoutScheduleKind; assessable gate keyed to the owning channel's mode), single "Problems" SPA filter with per-fault badges. Supersedes #72's api.channel-health-signal decision. Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com>
344 lines
13 KiB
C#
344 lines
13 KiB
C#
using ErsatzTV.Application.Artworks;
|
|
using ErsatzTV.Core.Api.Channels;
|
|
using ErsatzTV.Core.Domain;
|
|
|
|
namespace ErsatzTV.Application.Channels;
|
|
|
|
internal static class Mapper
|
|
{
|
|
/// <summary>
|
|
/// A mirror channel has no playouts of its own; it relays the playouts of its mirror source, so both must be
|
|
/// counted for the total to answer "can this channel play anything?". Requires <see cref="Channel.Playouts" />
|
|
/// and, for mirrors, <see cref="Channel.MirrorSourceChannel" />.<see cref="Channel.Playouts" /> to be included
|
|
/// by the query — the repository reads are AsNoTracking, so an un-included navigation silently counts zero.
|
|
/// </summary>
|
|
internal static int GetPlayoutsCount(Channel channel)
|
|
{
|
|
var result = 0;
|
|
|
|
if (channel.Playouts != null)
|
|
{
|
|
result += channel.Playouts.Count;
|
|
}
|
|
|
|
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
|
|
{
|
|
result += channel.MirrorSourceChannel.Playouts.Count;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
internal static ChannelHealthResponseModel GetHealth(
|
|
Channel channel,
|
|
int playoutCount,
|
|
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
|
|
{
|
|
if (playoutCount == 0)
|
|
{
|
|
return new ChannelHealthResponseModel(
|
|
ChannelHealthStatus.Problems,
|
|
[ChannelFault.NoPlayout],
|
|
0,
|
|
0);
|
|
}
|
|
|
|
var faults = new System.Collections.Generic.HashSet<string>();
|
|
var brokenSourceItemCount = 0;
|
|
var sawAssessable = false;
|
|
|
|
foreach ((Playout playout, ChannelPlayoutMode ownerMode) in ContributingPlayoutsWithOwnerMode(channel))
|
|
{
|
|
bool isOnDemand = ownerMode == ChannelPlayoutMode.OnDemand;
|
|
|
|
upcoming.TryGetValue(playout.Id, out PlayoutUpcoming u);
|
|
brokenSourceItemCount += u.BrokenUpcoming;
|
|
|
|
bool built = playout.BuildStatus is not null && playout.BuildStatus.LastBuild != default;
|
|
|
|
// Presence signals — always live.
|
|
if (built && playout.BuildStatus.Success == false)
|
|
{
|
|
faults.Add(ChannelFault.BuildFailed);
|
|
}
|
|
|
|
if (u.BrokenUpcoming > 0)
|
|
{
|
|
faults.Add(ChannelFault.BrokenSource);
|
|
}
|
|
|
|
// Absence signals — suppressed for on-demand (drains between tune-ins).
|
|
if (!isOnDemand)
|
|
{
|
|
if (!built)
|
|
{
|
|
faults.Add(ChannelFault.NeverBuilt);
|
|
}
|
|
else if (u.TotalUpcoming == 0)
|
|
{
|
|
faults.Add(ChannelFault.EmptyUpcoming);
|
|
}
|
|
else
|
|
{
|
|
sawAssessable = true;
|
|
}
|
|
}
|
|
else if (built && u.TotalUpcoming > 0)
|
|
{
|
|
sawAssessable = true;
|
|
}
|
|
}
|
|
|
|
string status = faults.Count > 0
|
|
? ChannelHealthStatus.Problems
|
|
: sawAssessable
|
|
? ChannelHealthStatus.Healthy
|
|
: ChannelHealthStatus.Unknown;
|
|
|
|
return new ChannelHealthResponseModel(
|
|
status,
|
|
faults.ToArray(),
|
|
playoutCount,
|
|
brokenSourceItemCount);
|
|
}
|
|
|
|
internal static IEnumerable<Playout> ContributingPlayouts(Channel channel) =>
|
|
ContributingPlayoutsWithOwnerMode(channel).Select(x => x.Playout);
|
|
|
|
// Mirror channels are forced Continuous (UpdateChannelHandler), but a mirror of an on-demand SOURCE relays
|
|
// playouts that legitimately drain between tune-ins. Absence-signal suppression must key off the mode of the
|
|
// channel that OWNS each playout, not the mirror's own (always-Continuous) mode — so pair each playout with
|
|
// its owner's mode here, once, rather than re-deriving it at each call site.
|
|
private static IEnumerable<(Playout Playout, ChannelPlayoutMode OwnerMode)> ContributingPlayoutsWithOwnerMode(
|
|
Channel channel)
|
|
{
|
|
if (channel.Playouts is not null)
|
|
{
|
|
foreach (Playout p in channel.Playouts)
|
|
{
|
|
yield return (p, channel.PlayoutMode);
|
|
}
|
|
}
|
|
|
|
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null)
|
|
{
|
|
foreach (Playout p in channel.MirrorSourceChannel.Playouts)
|
|
{
|
|
yield return (p, channel.MirrorSourceChannel.PlayoutMode);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
|
|
new(
|
|
channel.Id,
|
|
channel.Number,
|
|
channel.Name,
|
|
channel.Group,
|
|
channel.Categories,
|
|
channel.FFmpegProfileId,
|
|
channel.SlugSeconds,
|
|
GetLogo(channel),
|
|
channel.StreamSelectorMode,
|
|
channel.StreamSelector,
|
|
channel.PreferredAudioLanguageCode,
|
|
channel.PreferredAudioTitle,
|
|
channel.PlayoutSource,
|
|
channel.PlayoutMode,
|
|
channel.MirrorSourceChannelId,
|
|
channel.PlayoutOffset,
|
|
channel.StreamingMode,
|
|
channel.WatermarkId,
|
|
channel.FallbackFillerId,
|
|
playoutCount,
|
|
channel.PreferredSubtitleLanguageCode,
|
|
channel.SubtitleMode,
|
|
channel.MusicVideoCreditsMode,
|
|
channel.MusicVideoCreditsTemplate,
|
|
channel.SongVideoMode,
|
|
channel.TranscodeMode,
|
|
channel.IdleBehavior,
|
|
channel.IsEnabled,
|
|
channel.ShowInEpg);
|
|
|
|
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(
|
|
Channel channel,
|
|
int playoutCount,
|
|
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
|
|
{
|
|
ArtworkContentTypeModel logo = GetLogo(channel);
|
|
return new ChannelDetailResponseModel(
|
|
channel.Id,
|
|
channel.Number,
|
|
channel.Name,
|
|
channel.Group,
|
|
channel.Categories,
|
|
channel.FFmpegProfileId,
|
|
channel.SlugSeconds,
|
|
new ChannelLogoResponseModel(logo.Path, logo.ContentType),
|
|
channel.StreamSelectorMode,
|
|
channel.StreamSelector,
|
|
channel.PreferredAudioLanguageCode,
|
|
channel.PreferredAudioTitle,
|
|
channel.PlayoutSource,
|
|
channel.PlayoutMode,
|
|
channel.MirrorSourceChannelId,
|
|
channel.PlayoutOffset,
|
|
channel.StreamingMode,
|
|
channel.WatermarkId,
|
|
channel.FallbackFillerId,
|
|
playoutCount,
|
|
channel.PreferredSubtitleLanguageCode,
|
|
channel.SubtitleMode,
|
|
channel.MusicVideoCreditsMode,
|
|
channel.MusicVideoCreditsTemplate,
|
|
channel.SongVideoMode,
|
|
channel.TranscodeMode,
|
|
channel.IdleBehavior,
|
|
channel.IsEnabled,
|
|
channel.ShowInEpg,
|
|
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? [],
|
|
GetHealth(channel, playoutCount, upcoming));
|
|
}
|
|
|
|
internal static ChannelResponseModel ProjectToResponseModel(
|
|
Channel channel,
|
|
int playoutCount,
|
|
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming) =>
|
|
new(
|
|
channel.Id,
|
|
channel.Number,
|
|
channel.SortNumber,
|
|
channel.Name,
|
|
channel.Group,
|
|
channel.Categories,
|
|
channel.FFmpegProfile.Name,
|
|
channel.PreferredAudioLanguageCode,
|
|
GetStreamingMode(channel),
|
|
channel.IsEnabled,
|
|
channel.ShowInEpg,
|
|
playoutCount,
|
|
GetLogoUrl(channel),
|
|
GetPreview(channel.StreamingMode, channel.Number, channel.IsEnabled, playoutCount),
|
|
channel.Origin,
|
|
GetHealth(channel, playoutCount, upcoming));
|
|
|
|
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
|
new(resolution.Height, resolution.Width);
|
|
|
|
internal static ChannelStreamingSpecsViewModel ProjectToSpecsViewModel(Channel channel) =>
|
|
new(
|
|
channel.FFmpegProfile.Resolution.Height,
|
|
channel.FFmpegProfile.Resolution.Width,
|
|
(int)((channel.FFmpegProfile.VideoBitrate * 1000 + channel.FFmpegProfile.AudioBitrate * 1000) * 1.2),
|
|
channel.FFmpegProfile.VideoFormat,
|
|
channel.FFmpegProfile.VideoProfile,
|
|
channel.FFmpegProfile.AudioFormat);
|
|
|
|
// Rooted, directly-usable channel-logo URL for the SPA's <img src> on browse surfaces (guide grid +
|
|
// channels list), following the #181 artwork convention (docs/api-conventions.md §4): the SPA does no
|
|
// client-side path building. External logo URLs pass through as-is; an uploaded logo ("iptv/logos/{file}")
|
|
// is rooted with a leading slash so it resolves against the site root regardless of the current SPA route.
|
|
// Returns null when the channel has no logo, so the SPA falls back to the generated initials "bug".
|
|
#nullable enable
|
|
internal static string? GetLogoUrl(Channel channel)
|
|
{
|
|
// Browse surfaces must not crash the whole list over a missing Artwork include; GetLogo assumes
|
|
// the caller included Channel.Artwork (GetAll + the guide query do), but stay defensive here.
|
|
if (channel.Artwork is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
ArtworkContentTypeModel logo = GetLogo(channel);
|
|
if (string.IsNullOrWhiteSpace(logo.Path))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return logo.IsExternalUrl || logo.Path.StartsWith('/') ? logo.Path : $"/{logo.Path}";
|
|
}
|
|
#nullable restore
|
|
|
|
private static ArtworkContentTypeModel GetLogo(Channel channel)
|
|
{
|
|
Option<Artwork> maybeArtwork = channel.Artwork
|
|
.Where(a => a.ArtworkKind == ArtworkKind.Logo)
|
|
.HeadOrNone();
|
|
|
|
foreach (Artwork artwork in maybeArtwork)
|
|
{
|
|
return artwork.IsExternalUrl()
|
|
? new ArtworkContentTypeModel(artwork.Path, string.Empty)
|
|
: new ArtworkContentTypeModel($"iptv/logos/{artwork.Path}", artwork.OriginalContentType);
|
|
}
|
|
|
|
return ArtworkContentTypeModel.None;
|
|
}
|
|
|
|
private static string GetStreamingMode(Channel channel) =>
|
|
channel.StreamingMode switch
|
|
{
|
|
StreamingMode.TransportStream => "MPEG-TS (Legacy)",
|
|
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
|
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
|
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(channel))
|
|
};
|
|
|
|
#nullable enable
|
|
internal static ChannelPreviewResponseModel GetPreview(
|
|
StreamingMode streamingMode,
|
|
string channelNumber,
|
|
bool isEnabled,
|
|
int playoutCount)
|
|
{
|
|
// Precedence among the two Unavailable causes (checked in this order; the first match wins):
|
|
// 1. channel disabled — an explicit operator choice; IptvController 404s a disabled channel, so
|
|
// preview must not even try.
|
|
// 2. no playout — the channel could theoretically play once scheduled, but a manifest
|
|
// request against it blocks indefinitely today; catch it before that happens.
|
|
//
|
|
// IPTV JWT auth (ConditionalIptvAuthorizeFilter, active only when JWT:IssuerSigningKey is set) is no
|
|
// longer an Unavailable cause: the SPA mints a short-lived token via GET /api/v1/auth/iptv-token and
|
|
// appends it as ?access_token= to the manifest URL below (issue #552). The token is global and the
|
|
// ManifestUrl is identical with or without JWT, so this projection is JWT-agnostic.
|
|
if (!isEnabled)
|
|
{
|
|
return new ChannelPreviewResponseModel(
|
|
ChannelPreviewAvailability.Unavailable,
|
|
null,
|
|
"Channel is disabled");
|
|
}
|
|
|
|
if (playoutCount == 0)
|
|
{
|
|
return new ChannelPreviewResponseModel(
|
|
ChannelPreviewAvailability.Unavailable,
|
|
null,
|
|
"Channel has no playout");
|
|
}
|
|
|
|
return streamingMode switch
|
|
{
|
|
StreamingMode.HttpLiveStreamingSegmenter or StreamingMode.HttpLiveStreamingDirect =>
|
|
new ChannelPreviewResponseModel(
|
|
ChannelPreviewAvailability.Available,
|
|
$"/iptv/channel/{channelNumber}.m3u8",
|
|
null),
|
|
|
|
// A browser cannot play video/mp2t. Forcing ?mode=segmenter yields a playable stream,
|
|
// but one that does not exercise the channel's configured Transport Stream pipeline —
|
|
// the SPA labels this result accordingly.
|
|
StreamingMode.TransportStream or StreamingMode.TransportStreamHybrid =>
|
|
new ChannelPreviewResponseModel(
|
|
ChannelPreviewAvailability.ForcedHlsOnly,
|
|
$"/iptv/channel/{channelNumber}.m3u8?mode=segmenter",
|
|
null),
|
|
|
|
_ => throw new ArgumentOutOfRangeException(nameof(streamingMode))
|
|
};
|
|
}
|
|
#nullable restore
|
|
}
|