Never treat a manifest container as an audio codec or a direct play target

This commit is contained in:
Shadowghost
2026-09-02 09:29:48 +02:00
parent b3766b00d4
commit 8eb4964599
6 changed files with 256 additions and 2 deletions
@@ -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"
};
}
+10
View File
@@ -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;
+25 -1
View File
@@ -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);
}
}