Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@
{ "eac3", 6 },
{ "dca", 6 },
{ "mlp", 6 },
{ "truehd", 6 },

Check warning on line 140 in MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of using this literal 'truehd' 4 times.

See more on https://sonarcloud.io/project/issues?id=jellyfin_jellyfin&issues=AaBhC9_hlrVUzj8cBPD4&open=AaBhC9_hlrVUzj8cBPD4&pullRequest=17768
};

private static readonly Dictionary<HardwareAccelerationType, string> _mjpegCodecMap = new()
Expand Down Expand Up @@ -695,7 +695,11 @@
"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"
};
}

Expand Down
10 changes: 10 additions & 0 deletions MediaBrowser.Model/Dlna/StreamBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public class StreamBuilder
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"];
Expand Down Expand Up @@ -718,6 +720,14 @@ private StreamInfo BuildVideoItem(MediaSourceInfo item, MediaOptions options)
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;
Expand Down
26 changes: 25 additions & 1 deletion src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost
{
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;
Expand Down Expand Up @@ -151,11 +152,20 @@ protected virtual MediaSourceInfo CreateMediaSourceInfo(TunerHostInfo info, Chan
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)
Expand Down Expand Up @@ -210,6 +220,20 @@ protected virtual MediaSourceInfo CreateMediaSourceInfo(TunerHostInfo info, Chan
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>());
Expand Down
Original file line number Diff line number Diff line change
@@ -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>());
}
72 changes: 72 additions & 0 deletions tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
104 changes: 104 additions & 0 deletions tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading