mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-28 10:30:57 +01:00
Merge branch 'master' into fix/livetv-channel-icon-refresh
Resolve GuideManager conflict by keeping LiveTvChannelImageHelper so channel icons re-fetch on every guide refresh, including when the URL is unchanged.
This commit is contained in:
99
tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
Normal file
99
tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Api.Tests.Helpers
|
||||
{
|
||||
public class MediaInfoHelperTests
|
||||
{
|
||||
private static MediaInfoHelper CreateHelper()
|
||||
{
|
||||
return new MediaInfoHelper(
|
||||
Mock.Of<IUserManager>(),
|
||||
Mock.Of<ILibraryManager>(),
|
||||
Mock.Of<IMediaSourceManager>(),
|
||||
Mock.Of<IMediaEncoder>(),
|
||||
Mock.Of<IServerConfigurationManager>(),
|
||||
Mock.Of<ILogger<MediaInfoHelper>>(),
|
||||
Mock.Of<INetworkManager>(),
|
||||
Mock.Of<IDeviceManager>());
|
||||
}
|
||||
|
||||
private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true)
|
||||
{
|
||||
return new MediaSourceInfo
|
||||
{
|
||||
Id = itemId.ToString("N", CultureInfo.InvariantCulture),
|
||||
Protocol = MediaProtocol.File,
|
||||
Bitrate = bitrate,
|
||||
SupportsDirectPlay = supportsDirectPlay,
|
||||
SupportsDirectStream = true,
|
||||
SupportsTranscoding = true
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortMediaSources_PreferredItemExceedsBitrate_StaysDefault()
|
||||
{
|
||||
// The version the user was watching (the queried item) must stay the default
|
||||
// even when a sibling version fits the bitrate limit better, since the resume
|
||||
// position belongs to that exact version.
|
||||
var preferredItemId = Guid.NewGuid();
|
||||
var preferredSource = CreateSource(preferredItemId, bitrate: 80_000_000, supportsDirectPlay: false);
|
||||
var siblingSource = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
|
||||
|
||||
var result = new PlaybackInfoResponse
|
||||
{
|
||||
MediaSources = [siblingSource, preferredSource]
|
||||
};
|
||||
|
||||
CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, preferredItemId);
|
||||
|
||||
Assert.Equal(preferredSource.Id, result.MediaSources[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortMediaSources_NoPreferredItem_OrdersByPlayability()
|
||||
{
|
||||
var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
|
||||
var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
|
||||
transcodeOnly.SupportsDirectStream = false;
|
||||
|
||||
var result = new PlaybackInfoResponse
|
||||
{
|
||||
MediaSources = [transcodeOnly, directPlay]
|
||||
};
|
||||
|
||||
CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000);
|
||||
|
||||
Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortMediaSources_PreferredIdNotInSources_KeepsPlayabilityOrder()
|
||||
{
|
||||
var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
|
||||
var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
|
||||
transcodeOnly.SupportsDirectStream = false;
|
||||
|
||||
var result = new PlaybackInfoResponse
|
||||
{
|
||||
MediaSources = [transcodeOnly, directPlay]
|
||||
};
|
||||
|
||||
CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, Guid.NewGuid());
|
||||
|
||||
Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
|
||||
|
||||
namespace Jellyfin.Controller.Tests.MediaEncoding
|
||||
{
|
||||
public class EncodingHelperAudioBitStreamTests
|
||||
{
|
||||
private const string BothFilters = " -bsf:a noise=drop='lt(pts*tb\\,63.063)',aac_adtstoasc";
|
||||
private const string NoiseOnly = " -bsf:a noise=drop='lt(pts*tb\\,63.063)'";
|
||||
private const string AdtsOnly = " -bsf:a aac_adtstoasc";
|
||||
private const long DefaultSeekTicks = 630_630_000L;
|
||||
private const string DefaultFfmpegVersion = "5.0";
|
||||
|
||||
private static EncodingHelper CreateHelper(string ffmpegVersion)
|
||||
{
|
||||
var mediaEncoder = new Mock<IMediaEncoder>();
|
||||
mediaEncoder
|
||||
.Setup(e => e.GetTimeParameter(It.IsAny<long>()))
|
||||
.Returns((long ticks) => TimeSpan.FromTicks(ticks).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture));
|
||||
mediaEncoder
|
||||
.SetupGet(e => e.EncoderVersion)
|
||||
.Returns(Version.Parse(ffmpegVersion));
|
||||
|
||||
return new EncodingHelper(
|
||||
Mock.Of<IApplicationPaths>(),
|
||||
mediaEncoder.Object,
|
||||
Mock.Of<ISubtitleEncoder>(),
|
||||
Mock.Of<IConfiguration>(),
|
||||
Mock.Of<IConfigurationManager>(),
|
||||
Mock.Of<IPathManager>());
|
||||
}
|
||||
|
||||
private static EncodingJobInfo CreateState(
|
||||
TranscodingJobType jobType,
|
||||
string outputVideoCodec,
|
||||
string outputAudioCodec,
|
||||
string audioStreamCodec,
|
||||
string inputContainer,
|
||||
long startTimeTicks)
|
||||
{
|
||||
return new EncodingJobInfo(jobType)
|
||||
{
|
||||
IsVideoRequest = true,
|
||||
OutputVideoCodec = outputVideoCodec,
|
||||
OutputAudioCodec = outputAudioCodec,
|
||||
InputContainer = inputContainer,
|
||||
RunTimeTicks = TimeSpan.FromMinutes(10).Ticks,
|
||||
AudioStream = new MediaStream
|
||||
{
|
||||
Type = MediaStreamType.Audio,
|
||||
Codec = audioStreamCodec
|
||||
},
|
||||
BaseRequest = new BaseEncodingJobOptions
|
||||
{
|
||||
StartTimeTicks = startTimeTicks
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", BothFilters)]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "aac", BothFilters)]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "hls", BothFilters)]
|
||||
[InlineData(TranscodingJobType.Progressive, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
|
||||
[InlineData(TranscodingJobType.Hls, "copy", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "aac", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "wtv", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", 0L, DefaultFfmpegVersion, "mp4", "ts", AdtsOnly)]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, "4.4.6", "mp4", "ts", AdtsOnly)]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "ts", "ts", NoiseOnly)]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "copy", "aac", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "mkv", NoiseOnly)]
|
||||
[InlineData(TranscodingJobType.Hls, "libx264", "copy", "ac3", "ts", DefaultSeekTicks, DefaultFfmpegVersion, "mp4", "ts", NoiseOnly)]
|
||||
public void AudioBitStreamArguments_AppliesGates(
|
||||
TranscodingJobType jobType,
|
||||
string outputVideoCodec,
|
||||
string outputAudioCodec,
|
||||
string audioStreamCodec,
|
||||
string inputContainer,
|
||||
long startTicks,
|
||||
string ffmpegVersion,
|
||||
string segmentContainer,
|
||||
string mediaSourceContainer,
|
||||
string expected)
|
||||
{
|
||||
var state = CreateState(jobType, outputVideoCodec, outputAudioCodec, audioStreamCodec, inputContainer, startTicks);
|
||||
var result = CreateHelper(ffmpegVersion).GetAudioBitStreamArguments(state, segmentContainer, mediaSourceContainer);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
397
tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs
Normal file
397
tests/Jellyfin.Extensions.Tests/StreamExtensionsTests.cs
Normal file
@@ -0,0 +1,397 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Extensions.Tests;
|
||||
|
||||
public class StreamExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task IsStreamIdenticalAsync_SeekableDifferentLengths_ReturnsFalse()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
await using var a = new MemoryStream(new byte[] { 1, 2, 3 });
|
||||
await using var b = new MemoryStream(new byte[] { 1, 2, 3, 4 });
|
||||
|
||||
var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsStreamIdenticalAsync_NonSeekableIdenticalStreams_ReturnsTrue()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
|
||||
await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
|
||||
|
||||
var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsStreamIdenticalAsync_NonSeekableDifferentStreams_ReturnsFalse()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
await using var a = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
|
||||
await using var b = new NonSeekableReadStream(new byte[] { 1, 2, 9, 4 });
|
||||
|
||||
var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsFileIdenticalAsync_NonSeekableStream_ThrowsArgumentException()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
await File.WriteAllBytesAsync(path, new byte[] { 1, 2, 3, 4 }, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await stream.IsFileIdenticalAsync(path, cancellationToken));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Both publiclyVisible values are exercised so the test runs once under the fast path
|
||||
// (TryGetBuffer succeeds) and once under the slow path (TryGetBuffer returns false).
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task IsFileIdenticalAsync_UsesStartOfStreamAndRestoresPosition_OnMatch(bool publiclyVisible)
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
var bytes = new byte[] { 10, 20, 30, 40, 50 };
|
||||
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = CreateMemoryStream(bytes, publiclyVisible);
|
||||
stream.Position = 3;
|
||||
|
||||
var result = await stream.IsFileIdenticalAsync(path, cancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(3, stream.Position);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task IsFileIdenticalAsync_RestoresPosition_OnMismatch(bool publiclyVisible)
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
await File.WriteAllBytesAsync(path, new byte[] { 10, 20, 30, 40, 99 }, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = CreateMemoryStream(new byte[] { 10, 20, 30, 40, 50 }, publiclyVisible);
|
||||
stream.Position = 2;
|
||||
|
||||
var result = await stream.IsFileIdenticalAsync(path, cancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(2, stream.Position);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task IsStreamIdenticalAsync_BothMemoryStreams_NonZeroPositions_SeeksToStart(bool publiclyVisible)
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible);
|
||||
await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4, 5 }, publiclyVisible);
|
||||
a.Position = 3;
|
||||
b.Position = 1;
|
||||
|
||||
var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task IsStreamIdenticalAsync_MemoryStreamPairedWithSeekableNonMemoryStream_NonZeroPositions_SeeksToStart(bool publiclyVisible)
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
await using var a = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible);
|
||||
await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
|
||||
a.Position = 2;
|
||||
b.Position = 3;
|
||||
|
||||
var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task IsStreamIdenticalAsync_NonMemoryStreamPairedWithMemoryStream_Swaps_ReturnsTrue(bool publiclyVisible)
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
|
||||
await using var b = CreateMemoryStream(new byte[] { 1, 2, 3, 4 }, publiclyVisible);
|
||||
|
||||
var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsStreamIdenticalAsync_BothSeekableNonMemoryStreams_NonZeroPositions_SeeksToStart()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
await using var a = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
|
||||
await using var b = new SeekableNonMemoryStream(new byte[] { 1, 2, 3, 4 });
|
||||
a.Position = 1;
|
||||
b.Position = 2;
|
||||
|
||||
var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsStreamIdenticalAsync_NonSeekableShortReads_Identical_ReturnsTrue()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
|
||||
await using var a = new ShortReadingNonSeekableStream(data, maxReadSize: 3);
|
||||
await using var b = new ShortReadingNonSeekableStream(data, maxReadSize: 5);
|
||||
|
||||
var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsStreamIdenticalAsync_NonSeekableShortReads_DifferentLengths_ReturnsFalse()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
await using var a = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4 }, maxReadSize: 3);
|
||||
await using var b = new ShortReadingNonSeekableStream(new byte[] { 1, 2, 3, 4, 5 }, maxReadSize: 5);
|
||||
|
||||
var result = await a.IsStreamIdenticalAsync(b, cancellationToken);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
private static MemoryStream CreateMemoryStream(byte[] data, bool publiclyVisible)
|
||||
=> publiclyVisible
|
||||
? new MemoryStream(data, 0, data.Length, writable: false, publiclyVisible: true)
|
||||
: new MemoryStream(data);
|
||||
|
||||
private sealed class NonSeekableReadStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
|
||||
public NonSeekableReadStream(byte[] data)
|
||||
{
|
||||
_inner = new MemoryStream(data, writable: false);
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
=> _inner.Read(buffer, offset, count);
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
=> _inner.ReadAsync(buffer, cancellationToken);
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
=> _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await _inner.DisposeAsync();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SeekableNonMemoryStream : Stream
|
||||
{
|
||||
private readonly MemoryStream _inner;
|
||||
|
||||
public SeekableNonMemoryStream(byte[] data)
|
||||
{
|
||||
_inner = new MemoryStream(data, writable: false);
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => true;
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override long Length => _inner.Length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _inner.Position;
|
||||
set => _inner.Position = value;
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
=> _inner.Read(buffer, offset, count);
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
=> _inner.ReadAsync(buffer, cancellationToken);
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
=> _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
=> _inner.Seek(offset, origin);
|
||||
|
||||
public override void SetLength(long value)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await _inner.DisposeAsync();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ShortReadingNonSeekableStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private readonly int _maxReadSize;
|
||||
|
||||
public ShortReadingNonSeekableStream(byte[] data, int maxReadSize)
|
||||
{
|
||||
_inner = new MemoryStream(data, writable: false);
|
||||
_maxReadSize = maxReadSize;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
=> _inner.Read(buffer, offset, Math.Min(count, _maxReadSize));
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
=> _inner.ReadAsync(buffer[..Math.Min(buffer.Length, _maxReadSize)], cancellationToken);
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
=> _inner.ReadAsync(buffer.AsMemory(offset, Math.Min(count, _maxReadSize)), cancellationToken).AsTask();
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await _inner.DisposeAsync();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
using System;
|
||||
using AutoFixture;
|
||||
using AutoFixture.AutoMoq;
|
||||
using MediaBrowser.MediaEncoding.Subtitles;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.MediaEncoding.Subtitles.Tests
|
||||
{
|
||||
public class FilterEventsTests
|
||||
{
|
||||
private readonly SubtitleEncoder _encoder;
|
||||
|
||||
public FilterEventsTests()
|
||||
{
|
||||
var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
|
||||
_encoder = fixture.Create<SubtitleEncoder>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SubtitleSpanningSegmentBoundary_IsRetained()
|
||||
{
|
||||
// Subtitle starts at 5s, ends at 15s.
|
||||
// Segment requested from 10s to 20s.
|
||||
// The subtitle is still on screen at 10s and should NOT be dropped.
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Still on screen")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "Next subtitle")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Equal(2, track.TrackEvents.Count);
|
||||
Assert.Equal("1", track.TrackEvents[0].Id);
|
||||
Assert.Equal("2", track.TrackEvents[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SubtitleFullyBeforeSegment_IsDropped()
|
||||
{
|
||||
// Subtitle starts at 2s, ends at 5s.
|
||||
// Segment requested from 10s.
|
||||
// The subtitle ended before the segment — should be dropped.
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Already gone")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(2).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(5).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "Visible")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Single(track.TrackEvents);
|
||||
Assert.Equal("2", track.TrackEvents[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SubtitleAfterSegment_IsDropped()
|
||||
{
|
||||
// Segment is 10s-20s, subtitle starts at 25s.
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "In range")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "After segment")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(25).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(30).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Single(track.TrackEvents);
|
||||
Assert.Equal("1", track.TrackEvents[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_PreserveTimestampsFalse_AdjustsTimestamps()
|
||||
{
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Subtitle")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(15).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(20).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(30).Ticks,
|
||||
preserveTimestamps: false);
|
||||
|
||||
Assert.Single(track.TrackEvents);
|
||||
// Timestamps should be shifted back by 10s
|
||||
Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].StartPositionTicks);
|
||||
Assert.Equal(TimeSpan.FromSeconds(10).Ticks, track.TrackEvents[0].EndPositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_PreserveTimestampsTrue_KeepsOriginalTimestamps()
|
||||
{
|
||||
var startTicks = TimeSpan.FromSeconds(15).Ticks;
|
||||
var endTicks = TimeSpan.FromSeconds(20).Ticks;
|
||||
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Subtitle")
|
||||
{
|
||||
StartPositionTicks = startTicks,
|
||||
EndPositionTicks = endTicks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(30).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Single(track.TrackEvents);
|
||||
Assert.Equal(startTicks, track.TrackEvents[0].StartPositionTicks);
|
||||
Assert.Equal(endTicks, track.TrackEvents[0].EndPositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SubtitleEndingExactlyAtSegmentStart_IsRetained()
|
||||
{
|
||||
// Subtitle ends exactly when the segment begins.
|
||||
// EndPositionTicks == startPositionTicks means (end - start) == 0, not < 0,
|
||||
// so SkipWhile stops and the subtitle is retained.
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Boundary subtitle")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(10).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "In range")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Equal(2, track.TrackEvents.Count);
|
||||
Assert.Equal("1", track.TrackEvents[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SpanningBoundaryWithTimestampAdjustment_DoesNotProduceNegativeTimestamps()
|
||||
{
|
||||
// Subtitle starts at 5s, ends at 15s.
|
||||
// Segment requested from 10s to 20s, preserveTimestamps = false.
|
||||
// The subtitle spans the boundary and is retained, but shifting
|
||||
// StartPositionTicks by -10s would produce -5s (negative).
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Spans boundary")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "Fully in range")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: false);
|
||||
|
||||
Assert.Equal(2, track.TrackEvents.Count);
|
||||
// Subtitle 1: start should be clamped to 0, not -5s
|
||||
Assert.True(track.TrackEvents[0].StartPositionTicks >= 0, "StartPositionTicks must not be negative");
|
||||
Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].EndPositionTicks);
|
||||
// Subtitle 2: normal shift (12s - 10s = 2s, 17s - 10s = 7s)
|
||||
Assert.Equal(TimeSpan.FromSeconds(2).Ticks, track.TrackEvents[1].StartPositionTicks);
|
||||
Assert.Equal(TimeSpan.FromSeconds(7).Ticks, track.TrackEvents[1].EndPositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_NoEndTimeTicks_ReturnsAllFromStartPosition()
|
||||
{
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Before")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(2).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(4).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "After")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("3", "Much later")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(500).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(505).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: 0,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Equal(2, track.TrackEvents.Count);
|
||||
Assert.Equal("2", track.TrackEvents[0].Id);
|
||||
Assert.Equal("3", track.TrackEvents[1].Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,4 +83,26 @@ public class SeasonPathParserTests
|
||||
Assert.Equal(seasonNumber, result.SeasonNumber);
|
||||
Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/Drive/300 Collection/300 (2006)", "/Drive/300 Collection", null, false)]
|
||||
[InlineData("/Drive/300 Collection/300 Rise of an Empire", "/Drive/300 Collection", null, false)]
|
||||
[InlineData("/Drive/300 Collection/1", "/Drive/300 Collection", null, false)]
|
||||
[InlineData("/Drive/300 Collection/300 Disc 1", "/Drive/300 Collection", null, false)]
|
||||
[InlineData("/Drive/28 Years Later Collection/28 Days Later", "/Drive/28 Years Later Collection", null, false)]
|
||||
[InlineData("/Drive/28 Years Later Collection/28 Weeks Later (2007)", "/Drive/28 Years Later Collection", null, false)]
|
||||
[InlineData("/Drive/28 Years Later Collection/28 Years Later 2025", "/Drive/28 Years Later Collection", null, false)]
|
||||
[InlineData("/Drive/300 Collection/Season 1", "/Drive/300 Collection", 1, true)]
|
||||
[InlineData("/Drive/28 Years Later Collection/Season 01", "/Drive/28 Years Later Collection", 1, true)]
|
||||
[InlineData("/Drive/300 Collection/S01", "/Drive/300 Collection", 1, true)]
|
||||
[InlineData("/Drive/300 Collection/S1", "/Drive/300 Collection", 1, true)]
|
||||
|
||||
public void GetSeasonNumberFromPathMixedLibraryTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory)
|
||||
{
|
||||
var result = SeasonPathParser.Parse(path, parentPath, false, false);
|
||||
|
||||
Assert.Equal(result.SeasonNumber is not null, result.Success);
|
||||
Assert.Equal(seasonNumber, result.SeasonNumber);
|
||||
Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using Emby.Server.Implementations.Dto;
|
||||
using Emby.Server.Implementations.Playlists;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Controller.Chapters;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Dto;
|
||||
|
||||
public class DtoServiceImageInheritanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage()
|
||||
{
|
||||
var displayParent = new PlaylistsFolder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ImageInfos =
|
||||
[
|
||||
new ItemImageInfo
|
||||
{
|
||||
Type = ImageType.Primary,
|
||||
Path = "/images/playlists-custom.jpg",
|
||||
DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var userView = new UserView
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ViewType = CollectionType.playlists,
|
||||
DisplayParentId = displayParent.Id,
|
||||
ImageInfos =
|
||||
[
|
||||
new ItemImageInfo
|
||||
{
|
||||
Type = ImageType.Primary,
|
||||
Path = "/images/generated.png",
|
||||
DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dtoService = BuildDtoService(displayParent);
|
||||
|
||||
var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
|
||||
|
||||
Assert.NotNull(dto.ParentPrimaryImageItemId);
|
||||
Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId);
|
||||
Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag);
|
||||
Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage()
|
||||
{
|
||||
var displayParent = new PlaylistsFolder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ImageInfos = []
|
||||
};
|
||||
|
||||
var userView = new UserView
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ViewType = CollectionType.playlists,
|
||||
DisplayParentId = displayParent.Id,
|
||||
ImageInfos =
|
||||
[
|
||||
new ItemImageInfo
|
||||
{
|
||||
Type = ImageType.Primary,
|
||||
Path = "/images/generated.png",
|
||||
DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dtoService = BuildDtoService(displayParent);
|
||||
|
||||
var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
|
||||
|
||||
Assert.Null(dto.ParentPrimaryImageItemId);
|
||||
Assert.Null(dto.ParentPrimaryImageTag);
|
||||
Assert.NotNull(dto.ImageTags);
|
||||
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
|
||||
Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]);
|
||||
}
|
||||
|
||||
private static DtoService BuildDtoService(BaseItem displayParent)
|
||||
{
|
||||
var libraryManager = new Mock<ILibraryManager>();
|
||||
var userDataManager = new Mock<IUserDataManager>();
|
||||
var imageProcessor = new Mock<IImageProcessor>();
|
||||
var providerManager = new Mock<IProviderManager>();
|
||||
var recordingsManager = new Mock<IRecordingsManager>();
|
||||
var appHost = new Mock<IApplicationHost>();
|
||||
var mediaSourceManager = new Mock<IMediaSourceManager>();
|
||||
var liveTvManager = new Mock<ILiveTvManager>();
|
||||
var trickplayManager = new Mock<ITrickplayManager>();
|
||||
var chapterManager = new Mock<IChapterManager>();
|
||||
var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>();
|
||||
|
||||
libraryManager
|
||||
.Setup(x => x.GetItemById(displayParent.Id))
|
||||
.Returns(displayParent);
|
||||
|
||||
imageProcessor
|
||||
.Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
|
||||
.Returns<BaseItem, ItemImageInfo>((_, image) => image.Path);
|
||||
|
||||
return new DtoService(
|
||||
logger.Object,
|
||||
libraryManager.Object,
|
||||
userDataManager.Object,
|
||||
imageProcessor.Object,
|
||||
providerManager.Object,
|
||||
recordingsManager.Object,
|
||||
appHost.Object,
|
||||
mediaSourceManager.Object,
|
||||
new Lazy<ILiveTvManager>(() => liveTvManager.Object),
|
||||
trickplayManager.Object,
|
||||
chapterManager.Object);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using Emby.Server.Implementations.Dto;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Controller.Chapters;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Dto;
|
||||
|
||||
public class DtoServiceTests
|
||||
{
|
||||
private readonly Mock<ILibraryManager> _libraryManagerMock;
|
||||
private readonly DtoService _dtoService;
|
||||
|
||||
public DtoServiceTests()
|
||||
{
|
||||
_libraryManagerMock = new Mock<ILibraryManager>();
|
||||
|
||||
var imageProcessor = new Mock<IImageProcessor>();
|
||||
// Deterministic tag derived from the image so each item gets a distinct, assertable tag.
|
||||
imageProcessor
|
||||
.Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
|
||||
.Returns((BaseItem _, ItemImageInfo image) => "tag:" + image.Path);
|
||||
|
||||
var appHost = new Mock<IApplicationHost>();
|
||||
appHost.Setup(x => x.SystemId).Returns("test-server");
|
||||
|
||||
// Video.SourceType probes the active-recording manager; provide one so it doesn't NRE.
|
||||
Video.RecordingsManager = new Mock<IRecordingsManager>().Object;
|
||||
|
||||
_dtoService = new DtoService(
|
||||
NullLogger<DtoService>.Instance,
|
||||
_libraryManagerMock.Object,
|
||||
new Mock<IUserDataManager>().Object,
|
||||
imageProcessor.Object,
|
||||
new Mock<IProviderManager>().Object,
|
||||
new Mock<IRecordingsManager>().Object,
|
||||
appHost.Object,
|
||||
new Mock<IMediaSourceManager>().Object,
|
||||
new Lazy<ILiveTvManager>(() => new Mock<ILiveTvManager>().Object),
|
||||
new Mock<ITrickplayManager>().Object,
|
||||
new Mock<IChapterManager>().Object);
|
||||
|
||||
// Episode.Series / Episode.Season resolve through the static BaseItem.LibraryManager.
|
||||
BaseItem.LibraryManager = _libraryManagerMock.Object;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries()
|
||||
{
|
||||
var (episode, season, series) = BuildEpisode(seasonHasPoster: true);
|
||||
var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
|
||||
|
||||
var dto = _dtoService.GetBaseItemDto(episode, options);
|
||||
|
||||
// The episode's own 16:9 primary is dropped in favor of the season's portrait poster.
|
||||
Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
|
||||
Assert.Null(dto.SeriesPrimaryImageTag);
|
||||
Assert.Equal(season.Id, dto.ParentPrimaryImageItemId);
|
||||
Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
|
||||
// Aspect ratio follows the (portrait) poster, not the episode's 16:9 image.
|
||||
Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster()
|
||||
{
|
||||
var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
|
||||
var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
|
||||
|
||||
var dto = _dtoService.GetBaseItemDto(episode, options);
|
||||
|
||||
Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
|
||||
Assert.Null(dto.SeriesPrimaryImageTag);
|
||||
Assert.Equal(series.Id, dto.ParentPrimaryImageItemId);
|
||||
Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary()
|
||||
{
|
||||
var (episode, _, _) = BuildEpisode(seasonHasPoster: true);
|
||||
var options = new DtoOptions(false);
|
||||
|
||||
var dto = _dtoService.GetBaseItemDto(episode, options);
|
||||
|
||||
// Default behavior: the episode keeps its own primary and exposes the series poster as a tag.
|
||||
Assert.NotNull(dto.ImageTags);
|
||||
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
|
||||
Assert.NotNull(dto.SeriesPrimaryImageTag);
|
||||
Assert.Null(dto.ParentPrimaryImageItemId);
|
||||
}
|
||||
|
||||
private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster)
|
||||
{
|
||||
// Non-local (http) paths keep aspect-ratio resolution off the image processor and on the
|
||||
// item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode.
|
||||
var series = new Series { Id = Guid.NewGuid(), Name = "Series" };
|
||||
series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
|
||||
|
||||
var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id };
|
||||
if (seasonHasPoster)
|
||||
{
|
||||
season.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/season.jpg" }, 0);
|
||||
}
|
||||
|
||||
var episode = new Episode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Episode",
|
||||
SeasonId = season.Id,
|
||||
SeriesId = series.Id
|
||||
};
|
||||
episode.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/episode.jpg" }, 0);
|
||||
|
||||
_libraryManagerMock.Setup(x => x.GetItemById(season.Id)).Returns(season);
|
||||
_libraryManagerMock.Setup(x => x.GetItemById(series.Id)).Returns(series);
|
||||
|
||||
return (episode, season, series);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.Implementations.Users;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Users
|
||||
{
|
||||
public class UserManagerLockHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
|
||||
{
|
||||
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||
using var helper = new UserManager.LockHelper();
|
||||
var key = Guid.NewGuid();
|
||||
|
||||
Assert.True(helper.ShouldLock());
|
||||
|
||||
var outerHandle = await helper.LockAsync(key);
|
||||
Assert.False(helper.ShouldLock());
|
||||
|
||||
var innerHandle = await helper.LockAsync(key);
|
||||
Assert.False(helper.ShouldLock());
|
||||
|
||||
innerHandle.Dispose();
|
||||
Assert.False(helper.ShouldLock());
|
||||
|
||||
outerHandle.Dispose();
|
||||
Assert.True(helper.ShouldLock());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
|
||||
{
|
||||
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||
using var helper = new UserManager.LockHelper();
|
||||
var key = Guid.NewGuid();
|
||||
|
||||
var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var secondEntered = false;
|
||||
|
||||
var firstTask = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
using var firstHandle = await helper.LockAsync(key);
|
||||
firstAcquired.SetResult(true);
|
||||
await releaseFirst.Task;
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await firstAcquired.Task;
|
||||
|
||||
var secondTask = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
using var secondHandle = await helper.LockAsync(key);
|
||||
secondEntered = true;
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
Assert.False(secondEntered);
|
||||
|
||||
releaseFirst.SetResult(true);
|
||||
|
||||
await Task.WhenAll(firstTask, secondTask);
|
||||
Assert.True(secondEntered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
|
||||
{
|
||||
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||
using var helper = new UserManager.LockHelper();
|
||||
helper.Dispose();
|
||||
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
|
||||
{
|
||||
UserManager.LockHelper.IsNestedLock.Value = 0;
|
||||
using var helper = new UserManager.LockHelper();
|
||||
|
||||
helper.Dispose();
|
||||
var ex = Record.Exception(() => helper.Dispose());
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Users;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Users
|
||||
{
|
||||
public sealed class UserManagerNormalizedUsernameTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly UserManager _userManager;
|
||||
|
||||
public UserManagerNormalizedUsernameTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
// Create the schema
|
||||
using var ctx = CreateDbContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateDbContext);
|
||||
|
||||
var cryptoProvider = new Mock<ICryptoProvider>();
|
||||
var configManager = new Mock<IServerConfigurationManager>();
|
||||
var appPaths = new Mock<IServerApplicationPaths>();
|
||||
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
|
||||
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
|
||||
|
||||
var appHost = new Mock<IApplicationHost>();
|
||||
|
||||
var defaultAuthProvider = new DefaultAuthenticationProvider(
|
||||
NullLogger<DefaultAuthenticationProvider>.Instance,
|
||||
cryptoProvider.Object);
|
||||
var invalidAuthProvider = new InvalidAuthProvider();
|
||||
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
|
||||
configManager.Object,
|
||||
appHost.Object);
|
||||
|
||||
_userManager = new UserManager(
|
||||
factory.Object,
|
||||
new NoopEventManager(),
|
||||
new Mock<INetworkManager>().Object,
|
||||
appHost.Object,
|
||||
new Mock<IImageProcessor>().Object,
|
||||
NullLogger<UserManager>.Instance,
|
||||
configManager.Object,
|
||||
new IPasswordResetProvider[] { defaultPasswordResetProvider },
|
||||
new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_userManager.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
// ----- GetUserByName tests -----
|
||||
|
||||
[Theory]
|
||||
// German umlauts
|
||||
[InlineData("münchen", "MÜNCHEN")]
|
||||
// Spanish tilde-n
|
||||
[InlineData("Ñoño", "ÑOÑO")]
|
||||
// ASCII, invariant uppercase lookup
|
||||
[InlineData("jellyfin", "JELLYFIN")]
|
||||
// Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130)
|
||||
[InlineData("Çelebi", "ÇELEBI")]
|
||||
public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName(
|
||||
string username, string normalizedLookup)
|
||||
{
|
||||
await _userManager.CreateUserAsync(username);
|
||||
|
||||
var found = _userManager.GetUserByName(normalizedLookup);
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(username, found.Username);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// German umlaut, look up by both upper and lower case
|
||||
[InlineData("münchen")]
|
||||
// Spanish tilde-n
|
||||
[InlineData("Ñoño")]
|
||||
// lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ'
|
||||
[InlineData("ali")]
|
||||
// mixed ASCII + umlaut
|
||||
[InlineData("testüser")]
|
||||
public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username)
|
||||
{
|
||||
await _userManager.CreateUserAsync(username);
|
||||
|
||||
var upperFound = _userManager.GetUserByName(username.ToUpperInvariant());
|
||||
var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant());
|
||||
var exactFound = _userManager.GetUserByName(username);
|
||||
|
||||
Assert.NotNull(upperFound);
|
||||
Assert.NotNull(lowerFound);
|
||||
Assert.NotNull(exactFound);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nonexistent")]
|
||||
// No user with NormalizedUsername = "MÜNCHEN" has been created
|
||||
[InlineData("MÜNCHEN")]
|
||||
public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName)
|
||||
{
|
||||
var result = _userManager.GetUserByName(lookupName);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ----- CreateUserAsync duplicate detection tests -----
|
||||
|
||||
[Theory]
|
||||
// German umlaut, case-swapped duplicate
|
||||
[InlineData("münchen", "MÜNCHEN")]
|
||||
// Spanish tilde-n, lowercase duplicate
|
||||
[InlineData("Ñoño", "ñoño")]
|
||||
// ASCII, uppercase duplicate
|
||||
[InlineData("alice", "ALICE")]
|
||||
// Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant()
|
||||
[InlineData("çelebi", "ÇELEBI")]
|
||||
public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException(
|
||||
string existingUsername, string duplicateUsername)
|
||||
{
|
||||
await _userManager.CreateUserAsync(existingUsername);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _userManager.CreateUserAsync(duplicateUsername));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Different non-ASCII names that do not collide after normalization
|
||||
[InlineData("münchen", "münchen2")]
|
||||
[InlineData("ali", "ali2")]
|
||||
// Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E)
|
||||
[InlineData("noño", "nono")]
|
||||
public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers(
|
||||
string firstUsername, string secondUsername)
|
||||
{
|
||||
var first = await _userManager.CreateUserAsync(firstUsername);
|
||||
var second = await _userManager.CreateUserAsync(secondUsername);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.NotEqual(first.Id, second.Id);
|
||||
}
|
||||
|
||||
// ----- RenameUser tests -----
|
||||
|
||||
[Theory]
|
||||
// Rename to non-ASCII name
|
||||
[InlineData("alice", "münchen")]
|
||||
// Rename between similar non-ASCII and ASCII
|
||||
[InlineData("müller", "mueller")]
|
||||
// Contains 'i': invariant uppercase is always 'I', never Turkish 'İ'
|
||||
[InlineData("ali", "ALI2")]
|
||||
// Rename to Spanish tilde-n name
|
||||
[InlineData("testuser", "Ñoño")]
|
||||
public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant(
|
||||
string originalName, string newName)
|
||||
{
|
||||
var user = await _userManager.CreateUserAsync(originalName);
|
||||
|
||||
await _userManager.RenameUser(user.Id, originalName, newName);
|
||||
|
||||
var renamed = _userManager.GetUserById(user.Id);
|
||||
Assert.NotNull(renamed);
|
||||
Assert.Equal(newName, renamed.Username);
|
||||
Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Same name different case: NormalizedUsername already taken
|
||||
[InlineData("münchen", "MÜNCHEN")]
|
||||
// Spanish, lowercase conflicts with existing uppercase-normalised entry
|
||||
[InlineData("Ñoño", "ñoño")]
|
||||
// ASCII, capitalised conflict
|
||||
[InlineData("alice", "Alice")]
|
||||
// Mixed ASCII + umlaut
|
||||
[InlineData("testüser", "TESTÜSER")]
|
||||
public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException(
|
||||
string existingUsername, string conflictingNewName)
|
||||
{
|
||||
var targetUser = await _userManager.CreateUserAsync("renametarget");
|
||||
await _userManager.CreateUserAsync(existingUsername);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName));
|
||||
}
|
||||
|
||||
private sealed class NoopEventManager : IEventManager
|
||||
{
|
||||
public void Publish<T>(T eventArgs)
|
||||
where T : EventArgs
|
||||
{
|
||||
}
|
||||
|
||||
public Task PublishAsync<T>(T eventArgs)
|
||||
where T : EventArgs
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public sealed class LibraryControllerTests : IClassFixture<JellyfinApplicationFa
|
||||
[InlineData("Items/{0}/ThemeMedia")]
|
||||
[InlineData("Items/{0}/Ancestors")]
|
||||
[InlineData("Items/{0}/Download")]
|
||||
[InlineData("Items/{0}/Collections")]
|
||||
[InlineData("Artists/{0}/Similar")]
|
||||
[InlineData("Items/{0}/Similar")]
|
||||
[InlineData("Albums/{0}/Similar")]
|
||||
|
||||
Reference in New Issue
Block a user