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:
Daniel Țuțuianu
2026-06-17 06:16:42 +03:00
155 changed files with 10139 additions and 1738 deletions

View 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);
}
}
}

View File

@@ -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);
}
}
}

View 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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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")]