Files
ersatztv/ErsatzTV.Application/Channels/Mapper.cs
T
timothyandtimothy 8b9a7ed541
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 6m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m9s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 12m1s
feat(414): stamp immutable Channel.Origin (auto-tuned vs user-created) and surface it (#575)
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-23 18:12:25 +00:00

238 lines
9.5 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 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)
{
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() ?? []);
}
internal static ChannelResponseModel ProjectToResponseModel(
Channel channel,
int playoutCount) =>
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);
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
}