Never treat a manifest container as an audio codec or a direct play target
This commit is contained in:
@@ -695,7 +695,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
"ogg" or "oga" or "ogv" or "webm" or "webma" => "opus",
|
||||
"m4a" or "m4b" or "mp4" or "mov" or "mkv" or "mka" => "aac",
|
||||
"ts" or "avi" or "flv" or "f4v" or "swf" => "mp3",
|
||||
_ => inferredCodec
|
||||
// Containers that share their name with the codec they carry.
|
||||
"aac" or "ac3" or "alac" or "dts" or "eac3" or "flac" or "mp2" or "mp3" or "opus" or "truehd" or "vorbis" => inferredCodec,
|
||||
// Anything else - manifests such as m3u8/mpd in particular - names a container that
|
||||
// is not an audio codec. Never hand that name to ffmpeg as an encoder.
|
||||
_ => "aac"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ namespace MediaBrowser.Model.Dlna
|
||||
internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons;
|
||||
internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported;
|
||||
|
||||
private const string ManifestContainers = "hls,applehttp,dash";
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private readonly ITranscoderSupport _transcoderSupport;
|
||||
private static readonly string[] _supportedHlsVideoCodecs = ["h264", "hevc", "vp9", "av1"];
|
||||
@@ -718,6 +720,14 @@ namespace MediaBrowser.Model.Dlna
|
||||
isEligibleForDirectPlay = false;
|
||||
}
|
||||
|
||||
// A manifest is not a byte stream, so it cannot be handed to the client as one. The variant
|
||||
// and segment URIs inside it are relative to the origin and do not resolve against the
|
||||
// Jellyfin url the client would fetch it from.
|
||||
if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container))
|
||||
{
|
||||
isEligibleForDirectPlay = false;
|
||||
}
|
||||
|
||||
if (bitrateLimitExceeded)
|
||||
{
|
||||
transcodeReasons = TranscodeReason.ContainerBitrateExceedsLimit;
|
||||
|
||||
@@ -32,6 +32,7 @@ namespace Jellyfin.LiveTv.TunerHosts
|
||||
{
|
||||
private static readonly string[] _mimeTypesCanShareHttpStream = ["video/MP2T"];
|
||||
private static readonly string[] _extensionsCanShareHttpStream = [".ts", ".tsv", ".m2t"];
|
||||
private static readonly string[] _manifestExtensions = [".m3u8", ".m3u", ".mpd"];
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
@@ -151,11 +152,20 @@ namespace Jellyfin.LiveTv.TunerHosts
|
||||
var protocol = _mediaSourceManager.GetPathProtocol(path);
|
||||
|
||||
var isRemote = true;
|
||||
if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
|
||||
Uri.TryCreate(path, UriKind.Absolute, out var uri);
|
||||
if (uri is not null)
|
||||
{
|
||||
isRemote = !_networkManager.IsInLocalNetwork(uri.Host);
|
||||
}
|
||||
|
||||
// A manifest is not a byte stream. Serving one directly hands the client a playlist whose
|
||||
// variant and segment URIs are relative to the origin, and those do not resolve against the
|
||||
// Jellyfin url the client fetched it from. Remux or transcode these instead.
|
||||
if (IsManifest(path, uri))
|
||||
{
|
||||
supportsDirectPlay = false;
|
||||
}
|
||||
|
||||
var httpHeaders = new Dictionary<string, string>();
|
||||
|
||||
if (protocol == MediaProtocol.Http)
|
||||
@@ -210,6 +220,20 @@ namespace Jellyfin.LiveTv.TunerHosts
|
||||
return mediaSource;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a channel path points at an HLS or DASH manifest rather than at a byte stream.
|
||||
/// </summary>
|
||||
/// <param name="path">The channel path.</param>
|
||||
/// <param name="uri">The channel path parsed as an absolute uri, or <c>null</c> if it is not one.</param>
|
||||
/// <returns><c>true</c> if the path names a streaming manifest.</returns>
|
||||
private static bool IsManifest(string path, Uri uri)
|
||||
{
|
||||
// Use the uri path when there is one so that a query string does not hide the extension.
|
||||
var extension = Path.GetExtension(uri is null ? path : uri.AbsolutePath);
|
||||
|
||||
return _manifestExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new List<TunerHostInfo>());
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
|
||||
|
||||
namespace Jellyfin.Controller.Tests.MediaEncoding;
|
||||
|
||||
public class EncodingHelperInferAudioCodecTests
|
||||
{
|
||||
[Theory]
|
||||
// Manifests and other containers that carry no inferable audio codec.
|
||||
[InlineData("m3u8", "aac")]
|
||||
[InlineData("mpd", "aac")]
|
||||
[InlineData("wtv", "aac")]
|
||||
[InlineData("", "aac")]
|
||||
// Containers with a well known audio codec.
|
||||
[InlineData("mp4", "aac")]
|
||||
[InlineData("mkv", "aac")]
|
||||
[InlineData("webm", "opus")]
|
||||
[InlineData("ts", "mp3")]
|
||||
// Containers named after the codec they carry.
|
||||
[InlineData("flac", "flac")]
|
||||
[InlineData("opus", "opus")]
|
||||
[InlineData("ac3", "ac3")]
|
||||
public void InferAudioCodec_ReturnsAnAudioCodec(string container, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Create().InferAudioCodec(container));
|
||||
}
|
||||
|
||||
private static EncodingHelper Create()
|
||||
=> new(
|
||||
Mock.Of<IApplicationPaths>(),
|
||||
Mock.Of<IMediaEncoder>(),
|
||||
Mock.Of<ISubtitleEncoder>(),
|
||||
Mock.Of<IConfiguration>(),
|
||||
Mock.Of<IConfigurationManager>(),
|
||||
Mock.Of<IPathManager>());
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.LiveTv.TunerHosts;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.LiveTv;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.LiveTv.Tests
|
||||
{
|
||||
public class M3UTunerHostTests
|
||||
{
|
||||
[Theory]
|
||||
// A manifest is not a byte stream, so it must never be offered for direct play.
|
||||
[InlineData("http://example.com/live/1234.m3u8", false)]
|
||||
[InlineData("http://example.com/live/1234.m3u8?token=abc", false)]
|
||||
[InlineData("http://example.com/live/1234.mpd", false)]
|
||||
// Byte streams are unaffected.
|
||||
[InlineData("http://example.com/live/1234.ts", true)]
|
||||
[InlineData("http://example.com/live/1234", true)]
|
||||
public async Task GetChannelStreamMediaSources_ManifestPath_DisablesDirectPlay(string path, bool expectDirectPlay)
|
||||
{
|
||||
var mediaSourceManager = new Mock<IMediaSourceManager>();
|
||||
mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.Http);
|
||||
|
||||
var host = new TestableM3UTunerHost(
|
||||
Mock.Of<IServerConfigurationManager>(),
|
||||
mediaSourceManager.Object,
|
||||
Mock.Of<ILogger<M3UTunerHost>>(),
|
||||
Mock.Of<IFileSystem>(),
|
||||
Mock.Of<IHttpClientFactory>(),
|
||||
Mock.Of<IServerApplicationHost>(),
|
||||
Mock.Of<INetworkManager>(),
|
||||
Mock.Of<IStreamHelper>());
|
||||
|
||||
var sources = await host.GetMediaSources(
|
||||
new TunerHostInfo { TunerCount = 0, EnableStreamLooping = false },
|
||||
new ChannelInfo { Path = path });
|
||||
|
||||
Assert.Equal(expectDirectPlay, sources[0].SupportsDirectPlay);
|
||||
}
|
||||
|
||||
private sealed class TestableM3UTunerHost : M3UTunerHost
|
||||
{
|
||||
public TestableM3UTunerHost(
|
||||
IServerConfigurationManager config,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
ILogger<M3UTunerHost> logger,
|
||||
IFileSystem fileSystem,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IServerApplicationHost appHost,
|
||||
INetworkManager networkManager,
|
||||
IStreamHelper streamHelper)
|
||||
: base(config, mediaSourceManager, logger, fileSystem, httpClientFactory, appHost, networkManager, streamHelper)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<List<MediaSourceInfo>> GetMediaSources(TunerHostInfo tuner, ChannelInfo channel)
|
||||
=> GetChannelStreamMediaSources(tuner, channel, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Model.Tests.Dlna;
|
||||
|
||||
public class StreamBuilderManifestContainerTests
|
||||
{
|
||||
[Theory]
|
||||
// A manifest describes a stream instead of carrying one, so it can never be direct played,
|
||||
// even when the client claims to support the container.
|
||||
[InlineData("hls")]
|
||||
[InlineData("hls,applehttp")]
|
||||
[InlineData("applehttp")]
|
||||
[InlineData("dash")]
|
||||
public void GetOptimalVideoStream_ManifestContainer_DoesNotDirectPlay(string container)
|
||||
{
|
||||
var streamInfo = BuildFor(container);
|
||||
|
||||
Assert.NotNull(streamInfo);
|
||||
Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOptimalVideoStream_ByteStreamContainer_StillDirectPlays()
|
||||
{
|
||||
var streamInfo = BuildFor("mp4");
|
||||
|
||||
Assert.NotNull(streamInfo);
|
||||
Assert.Equal(PlayMethod.DirectPlay, streamInfo.PlayMethod);
|
||||
}
|
||||
|
||||
private static StreamInfo? BuildFor(string container)
|
||||
{
|
||||
var mediaSource = new MediaSourceInfo
|
||||
{
|
||||
Id = "test-source",
|
||||
Path = "http://example.com/live/channel",
|
||||
Protocol = MediaProtocol.Http,
|
||||
Container = container,
|
||||
SupportsDirectPlay = true,
|
||||
SupportsDirectStream = true,
|
||||
SupportsTranscoding = true,
|
||||
IsInfiniteStream = true,
|
||||
IsRemote = true,
|
||||
MediaStreams =
|
||||
[
|
||||
new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264" },
|
||||
new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac" }
|
||||
]
|
||||
};
|
||||
|
||||
var profile = new DeviceProfile
|
||||
{
|
||||
Name = "Manifest aware client",
|
||||
DirectPlayProfiles =
|
||||
[
|
||||
new DirectPlayProfile
|
||||
{
|
||||
Type = DlnaProfileType.Video,
|
||||
Container = "mp4,hls,applehttp,dash",
|
||||
VideoCodec = "h264",
|
||||
AudioCodec = "aac"
|
||||
}
|
||||
],
|
||||
TranscodingProfiles =
|
||||
[
|
||||
new TranscodingProfile
|
||||
{
|
||||
Type = DlnaProfileType.Video,
|
||||
Context = EncodingContext.Streaming,
|
||||
Protocol = MediaStreamProtocol.hls,
|
||||
Container = "ts",
|
||||
VideoCodec = "h264",
|
||||
AudioCodec = "aac"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var options = new MediaOptions
|
||||
{
|
||||
ItemId = new Guid("11D229B7-2D48-4B95-9F9B-49F6AB75E613"),
|
||||
MediaSourceId = mediaSource.Id,
|
||||
MediaSources = [mediaSource],
|
||||
DeviceId = "test-deviceId",
|
||||
Profile = profile,
|
||||
AllowAudioStreamCopy = true,
|
||||
AllowVideoStreamCopy = true,
|
||||
EnableDirectStream = false // This is disabled in server
|
||||
};
|
||||
|
||||
var transcodeSupport = new Mock<ITranscoderSupport>();
|
||||
|
||||
return new StreamBuilder(transcodeSupport.Object, new NullLogger<StreamBuilderManifestContainerTests>())
|
||||
.GetOptimalVideoStream(options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user