Browse Source

improve hls direct compatibility with channels dvr (#245)

* rename HttpLiveStreaming to HttpLiveStreamingDirect

* improve hls direct compatibility with channels dvr

* code cleanup
pull/246/head
Jason Dove 5 years ago committed by GitHub
parent
commit
0ef03d66f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs
  3. 29
      ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs
  4. 12
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs
  5. 2
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs
  6. 4
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs
  7. 46
      ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs
  8. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  9. 14
      ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs
  10. 2
      ErsatzTV.Core/Domain/StreamingMode.cs
  11. 2
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  12. 2
      ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
  13. 2
      ErsatzTV.Core/Hdhr/LineupItem.cs
  14. 2
      ErsatzTV.Core/Iptv/ChannelPlaylist.cs
  15. 2
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  16. 4
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  17. 6
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  18. 42
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  19. 7
      ErsatzTV/Controllers/InternalController.cs
  20. 18
      ErsatzTV/Controllers/IptvController.cs
  21. 6
      ErsatzTV/Pages/ChannelEditor.razor
  22. 2
      ErsatzTV/Pages/Channels.razor
  23. 6
      docs/user-guide/create-channels.md

9
CHANGELOG.md

@ -6,8 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Support `(Part #)` name suffixes for multi-part episode grouping - Support `(Part #)` name suffixes for multi-part episode grouping
- Support multi-episode files in local libraries - Support multi-episode files in local and Plex libraries
- Save Channels table page size - Save Channels table page size
- Add optional query string parameter to M3U channel playlist to allow some customization per client
- `?mode=ts` will force `MPEG-TS` mode for all channels
- `?mode=hls-direct` will force `HLS Direct` mode for all channels
- `?mode=mixed` or no parameter will maintain existing behavior
### Changed
- Rename channel mode `TransportStream` to `MPEG-TS` and `HttpLiveStreaming` to `HLS Direct`
### Fixed ### Fixed
- Fix search result crashes due to missing season metadata - Fix search result crashes due to missing season metadata

2
ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs

@ -3,5 +3,5 @@ using MediatR;
namespace ErsatzTV.Application.Channels.Queries namespace ErsatzTV.Application.Channels.Queries
{ {
public record GetChannelPlaylist(string Scheme, string Host) : IRequest<ChannelPlaylist>; public record GetChannelPlaylist(string Scheme, string Host, string Mode) : IRequest<ChannelPlaylist>;
} }

29
ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs

@ -1,5 +1,7 @@
using System.Threading; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
using LanguageExt; using LanguageExt;
@ -16,6 +18,31 @@ namespace ErsatzTV.Application.Channels.Queries
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) => public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll() _channelRepository.GetAll()
.Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels)); .Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{
var result = new List<Channel>();
foreach (Channel channel in channels)
{
switch (mode.ToLowerInvariant())
{
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
break;
case "ts":
channel.StreamingMode = StreamingMode.TransportStream;
result.Add(channel);
break;
default:
result.Add(channel);
break;
}
}
return result;
}
} }
} }

12
ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs

@ -39,6 +39,18 @@ namespace ErsatzTV.Application.Streaming.Queries
private async Task<Validation<BaseError, Channel>> ChannelMustExist(T request) => private async Task<Validation<BaseError, Channel>> ChannelMustExist(T request) =>
(await _channelRepository.GetByNumber(request.ChannelNumber)) (await _channelRepository.GetByNumber(request.ChannelNumber))
.Map(
channel =>
{
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"ts" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};
return channel;
})
.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist."); .ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist.");
private Task<Validation<BaseError, string>> FFmpegPathMustExist() => private Task<Validation<BaseError, string>> FFmpegPathMustExist() =>

2
ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs

@ -5,5 +5,5 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries namespace ErsatzTV.Application.Streaming.Queries
{ {
public record FFmpegProcessRequest(string ChannelNumber) : IRequest<Either<BaseError, Process>>; public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>;
} }

4
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs

@ -2,7 +2,9 @@
{ {
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
{ {
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(channelNumber) public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts")
{ {
Scheme = scheme; Scheme = scheme;
Host = host; Host = host;

46
ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs

@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Serilog; using Serilog;
namespace ErsatzTV.Application.Streaming.Queries namespace ErsatzTV.Application.Streaming.Queries
@ -14,14 +15,17 @@ namespace ErsatzTV.Application.Streaming.Queries
GetHlsPlaylistByChannelNumberHandler : IRequestHandler<GetHlsPlaylistByChannelNumber, Either<BaseError, string>> GetHlsPlaylistByChannelNumberHandler : IRequestHandler<GetHlsPlaylistByChannelNumber, Either<BaseError, string>>
{ {
private readonly IChannelRepository _channelRepository; private readonly IChannelRepository _channelRepository;
private readonly IMemoryCache _memoryCache;
private readonly IPlayoutRepository _playoutRepository; private readonly IPlayoutRepository _playoutRepository;
public GetHlsPlaylistByChannelNumberHandler( public GetHlsPlaylistByChannelNumberHandler(
IChannelRepository channelRepository, IChannelRepository channelRepository,
IPlayoutRepository playoutRepository) IPlayoutRepository playoutRepository,
IMemoryCache memoryCache)
{ {
_channelRepository = channelRepository; _channelRepository = channelRepository;
_playoutRepository = playoutRepository; _playoutRepository = playoutRepository;
_memoryCache = memoryCache;
} }
public Task<Either<BaseError, string>> Handle( public Task<Either<BaseError, string>> Handle(
@ -40,12 +44,15 @@ namespace ErsatzTV.Application.Streaming.Queries
return maybePlayoutItem.Match<Either<BaseError, string>>( return maybePlayoutItem.Match<Either<BaseError, string>>(
playoutItem => playoutItem =>
{ {
double timeRemaining = Math.Abs((playoutItem.Finish - now).TotalSeconds); long index = GetIndexForChannel(channel, playoutItem);
double timeRemaining = Math.Abs((playoutItem.FinishOffset - now).TotalSeconds);
return $@"#EXTM3U return $@"#EXTM3U
#EXT-X-VERSION:3 #EXT-X-VERSION:3
#EXT-X-TARGETDURATION:18000 #EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:{index}
#EXT-X-DISCONTINUITY
#EXTINF:{timeRemaining:F2}, #EXTINF:{timeRemaining:F2},
{request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber} {request.Scheme}://{request.Host}/ffmpeg/stream/{request.ChannelNumber}?index={index}&mode=hls-direct
"; ";
}, },
() => () =>
@ -59,5 +66,36 @@ namespace ErsatzTV.Application.Streaming.Queries
private async Task<Validation<BaseError, Channel>> ChannelMustExist(GetHlsPlaylistByChannelNumber request) => private async Task<Validation<BaseError, Channel>> ChannelMustExist(GetHlsPlaylistByChannelNumber request) =>
(await _channelRepository.GetByNumber(request.ChannelNumber)) (await _channelRepository.GetByNumber(request.ChannelNumber))
.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist."); .ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist.");
private long GetIndexForChannel(Channel channel, PlayoutItem playoutItem)
{
long ticks = playoutItem.Start.Ticks;
var key = new ChannelIndexKey(channel.Id);
long index;
if (_memoryCache.TryGetValue(key, out ChannelIndexRecord channelRecord))
{
if (channelRecord.StartTicks == ticks)
{
index = channelRecord.Index;
}
else
{
index = channelRecord.Index + 1;
_memoryCache.Set(key, new ChannelIndexRecord(ticks, index), TimeSpan.FromDays(1));
}
}
else
{
index = 1;
_memoryCache.Set(key, new ChannelIndexRecord(ticks, index), TimeSpan.FromDays(1));
}
return index;
}
private record ChannelIndexKey(int ChannelId);
private record ChannelIndexRecord(long StartTicks, long Index);
} }
} }

2
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs

@ -2,7 +2,7 @@
{ {
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{ {
public GetPlayoutItemProcessByChannelNumber(string channelNumber) : base(channelNumber) public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode)
{ {
} }
} }

14
ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs

@ -40,7 +40,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 }; FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(), new MediaStream(),
@ -76,7 +76,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile(); FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(), new MediaStream(),
@ -112,7 +112,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile(); FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(), new MediaStream(),
@ -151,7 +151,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegProfile ffmpegProfile = TestProfile(); FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile, ffmpegProfile,
new MediaVersion(), new MediaVersion(),
new MediaStream(), new MediaStream(),
@ -317,7 +317,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" }; var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
@ -426,7 +426,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" }; { Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" }, new MediaStream { Codec = "mpeg2video" },
@ -718,7 +718,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var version = new MediaVersion(); var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings( FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming, StreamingMode.HttpLiveStreamingDirect,
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),

2
ErsatzTV.Core/Domain/StreamingMode.cs

@ -3,6 +3,6 @@
public enum StreamingMode public enum StreamingMode
{ {
TransportStream = 1, TransportStream = 1,
HttpLiveStreaming = 2 HttpLiveStreamingDirect = 2
} }
} }

2
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -64,7 +64,7 @@ namespace ErsatzTV.Core.FFmpeg
switch (streamingMode) switch (streamingMode)
{ {
case StreamingMode.HttpLiveStreaming: case StreamingMode.HttpLiveStreamingDirect:
result.AudioCodec = "copy"; result.AudioCodec = "copy";
result.VideoCodec = "copy"; result.VideoCodec = "copy";
result.Deinterlace = false; result.Deinterlace = false;

2
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs

@ -28,7 +28,7 @@ namespace ErsatzTV.Core.FFmpeg
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version) public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
{ {
if (channel.StreamingMode == StreamingMode.HttpLiveStreaming && if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode)) string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
{ {
_logger.LogDebug( _logger.LogDebug(

2
ErsatzTV.Core/Hdhr/LineupItem.cs

@ -22,7 +22,7 @@ namespace ErsatzTV.Core.Hdhr
public string URL => _channel.StreamingMode switch public string URL => _channel.StreamingMode switch
{ {
StreamingMode.HttpLiveStreaming => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8", StreamingMode.HttpLiveStreamingDirect => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts" _ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
}; };
} }

2
ErsatzTV.Core/Iptv/ChannelPlaylist.cs

@ -42,7 +42,7 @@ namespace ErsatzTV.Core.Iptv
string format = channel.StreamingMode switch string format = channel.StreamingMode switch
{ {
StreamingMode.HttpLiveStreaming => "m3u8", StreamingMode.HttpLiveStreamingDirect => "m3u8",
_ => "ts" _ => "ts"
}; };

2
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -177,7 +177,7 @@ namespace ErsatzTV.Core.Metadata
private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata) private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata)
{ {
var updated = false; var updated = false;
episode.EpisodeMetadata ??= new List<EpisodeMetadata>(); episode.EpisodeMetadata ??= new List<EpisodeMetadata>();
var toUpdate = episode.EpisodeMetadata var toUpdate = episode.EpisodeMetadata

4
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -22,9 +22,9 @@ namespace ErsatzTV.Core.Metadata
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMetadataRepository _metadataRepository;
private readonly ILogger<TelevisionFolderScanner> _logger; private readonly ILogger<TelevisionFolderScanner> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly ISearchIndex _searchIndex; private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository; private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
@ -276,7 +276,7 @@ namespace ErsatzTV.Core.Metadata
private async Task<Either<BaseError, Season>> EnsureMetadataExists(Season season) private async Task<Either<BaseError, Season>> EnsureMetadataExists(Season season)
{ {
season.SeasonMetadata ??= new List<SeasonMetadata>(); season.SeasonMetadata ??= new List<SeasonMetadata>();
if (!season.SeasonMetadata.Any()) if (!season.SeasonMetadata.Any())
{ {
var metadata = new SeasonMetadata var metadata = new SeasonMetadata

6
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -428,12 +428,12 @@ namespace ErsatzTV.Core.Plex
var toAdd = incoming.EpisodeMetadata var toAdd = incoming.EpisodeMetadata
.Where(em => existing.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber)) .Where(em => existing.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber))
.ToList(); .ToList();
foreach (EpisodeMetadata metadata in toRemove) foreach (EpisodeMetadata metadata in toRemove)
{ {
await _televisionRepository.RemoveMetadata(existing, metadata); await _televisionRepository.RemoveMetadata(existing, metadata);
} }
foreach (EpisodeMetadata metadata in toAdd) foreach (EpisodeMetadata metadata in toAdd)
{ {
metadata.EpisodeId = existing.Id; metadata.EpisodeId = existing.Id;
@ -442,7 +442,7 @@ namespace ErsatzTV.Core.Plex
await _metadataRepository.Add(metadata); await _metadataRepository.Add(metadata);
} }
// TODO: update existing metadata // TODO: update existing metadata
return existing; return existing;

42
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -119,27 +119,6 @@ namespace ErsatzTV.Infrastructure.Plex
} }
} }
private List<PlexEpisode> ProcessMultiEpisodeFiles(IEnumerable<PlexEpisode> episodes)
{
// add all metadata from duplicate paths to first entry with given path
// i.e. s1e1 episode will add s1e2 metadata if s1e1 and s1e2 have same physical path
var result = new Dictionary<string, PlexEpisode>();
foreach (PlexEpisode episode in episodes.OrderBy(e => e.EpisodeMetadata.Head().EpisodeNumber))
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
if (result.TryGetValue(path, out PlexEpisode existing))
{
existing.EpisodeMetadata.Add(episode.EpisodeMetadata.Head());
}
else
{
result.Add(path, episode);
}
}
return result.Values.ToList();
}
public async Task<Either<BaseError, MovieMetadata>> GetMovieMetadata( public async Task<Either<BaseError, MovieMetadata>> GetMovieMetadata(
PlexLibrary library, PlexLibrary library,
string key, string key,
@ -247,6 +226,27 @@ namespace ErsatzTV.Infrastructure.Plex
} }
} }
private List<PlexEpisode> ProcessMultiEpisodeFiles(IEnumerable<PlexEpisode> episodes)
{
// add all metadata from duplicate paths to first entry with given path
// i.e. s1e1 episode will add s1e2 metadata if s1e1 and s1e2 have same physical path
var result = new Dictionary<string, PlexEpisode>();
foreach (PlexEpisode episode in episodes.OrderBy(e => e.EpisodeMetadata.Head().EpisodeNumber))
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
if (result.TryGetValue(path, out PlexEpisode existing))
{
existing.EpisodeMetadata.Add(episode.EpisodeMetadata.Head());
}
else
{
result.Add(path, episode);
}
}
return result.Values.ToList();
}
private static IPlexServerApi XmlServiceFor(string uri) private static IPlexServerApi XmlServiceFor(string uri)
{ {
var overrides = new XmlAttributeOverrides(); var overrides = new XmlAttributeOverrides();

7
ErsatzTV/Controllers/InternalController.cs

@ -27,8 +27,11 @@ namespace ErsatzTV.Controllers
.ToActionResult(); .ToActionResult();
[HttpGet("ffmpeg/stream/{channelNumber}")] [HttpGet("ffmpeg/stream/{channelNumber}")]
public Task<IActionResult> GetStream(string channelNumber) => public Task<IActionResult> GetStream(
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber)).Map( string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode)).Map(
result => result =>
result.Match<IActionResult>( result.Match<IActionResult>(
process => process =>

18
ErsatzTV/Controllers/IptvController.cs

@ -27,8 +27,10 @@ namespace ErsatzTV.Controllers
} }
[HttpGet("iptv/channels.m3u")] [HttpGet("iptv/channels.m3u")]
public Task<IActionResult> GetChannelPlaylist() => public Task<IActionResult> GetChannelPlaylist(
_mediator.Send(new GetChannelPlaylist(Request.Scheme, Request.Host.ToString())) [FromQuery]
string mode = "mixed") =>
_mediator.Send(new GetChannelPlaylist(Request.Scheme, Request.Host.ToString(), mode))
.Map<ChannelPlaylist, IActionResult>(Ok); .Map<ChannelPlaylist, IActionResult>(Ok);
[HttpGet("iptv/xmltv.xml")] [HttpGet("iptv/xmltv.xml")]
@ -54,14 +56,14 @@ namespace ErsatzTV.Controllers
[HttpGet("iptv/channel/{channelNumber}.m3u8")] [HttpGet("iptv/channel/{channelNumber}.m3u8")]
public Task<IActionResult> GetHttpLiveStreamingVideo(string channelNumber) => public Task<IActionResult> GetHttpLiveStreamingVideo(string channelNumber) =>
_mediator.Send(new GetHlsPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber)) _mediator.Send(
new GetHlsPlaylistByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
channelNumber))
.Map( .Map(
result => result.Match<IActionResult>( result => result.Match<IActionResult>(
playlist => playlist => Content(playlist, "application/x-mpegurl"),
{
_logger.LogInformation("Starting hls stream for channel {ChannelNumber}", channelNumber);
return Content(playlist, "application/x-mpegurl");
},
error => BadRequest(error.Value))); error => BadRequest(error.Value)));
[HttpGet("iptv/logos/{fileName}")] [HttpGet("iptv/logos/{fileName}")]

6
ErsatzTV/Pages/ChannelEditor.razor

@ -24,10 +24,8 @@
<MudTextField Label="Number" @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/> <MudTextField Label="Number" @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/> <MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Class="mt-3" Label="Streaming Mode" @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)"> <MudSelect Class="mt-3" Label="Streaming Mode" @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)">
@foreach (StreamingMode streamingMode in Enum.GetValues<StreamingMode>()) <MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS</MudSelectItem>
{ <MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
<MudSelectItem Value="@streamingMode">@streamingMode</MudSelectItem>
}
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)" <MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
Disabled="@(_model.StreamingMode != StreamingMode.TransportStream)"> Disabled="@(_model.StreamingMode != StreamingMode.TransportStream)">

2
ErsatzTV/Pages/Channels.razor

@ -50,7 +50,7 @@
</MudTd> </MudTd>
<MudTd DataLabel="Name">@context.Name</MudTd> <MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Language">@context.PreferredLanguageCode</MudTd> <MudTd DataLabel="Language">@context.PreferredLanguageCode</MudTd>
<MudTd DataLabel="Mode">@(context.StreamingMode == StreamingMode.TransportStream ? "TS" : "HLS")</MudTd> <MudTd DataLabel="Mode">@(context.StreamingMode == StreamingMode.TransportStream ? "MPEG-TS" : "HLS Direct")</MudTd>
<MudTd DataLabel="FFmpeg Profile"> <MudTd DataLabel="FFmpeg Profile">
@if (context.StreamingMode == StreamingMode.TransportStream) @if (context.StreamingMode == StreamingMode.TransportStream)
{ {

6
docs/user-guide/create-channels.md

@ -8,9 +8,9 @@ Channel numbers can be whole numbers or can contain one decimal, like `500` or `
### Streaming Mode ### Streaming Mode
Two streaming modes are currently supported: `Transport Stream` and `HttpLiveStreaming`. Two streaming modes are currently supported: `MPEG-TS` (Transport Stream) and `HLS Direct` (HTTP Live Streaming Direct).
`Transport Stream` is considered stable and is recommended for most purposes. `MPEG-TS` is considered stable and is recommended for most purposes.
`HttpLiveStreaming` is unstable and is not recommended for general use. `HLS Direct` is unstable and is not recommended for general use, but can avoid the need to transcode with some clients.
### FFmpeg Profile ### FFmpeg Profile

Loading…
Cancel
Save