diff --git a/Jellyfin.Server.Implementations/Metrics/TranscodingMetrics.cs b/Jellyfin.Server.Implementations/Metrics/TranscodingMetrics.cs new file mode 100644 index 0000000000..75e74afe05 --- /dev/null +++ b/Jellyfin.Server.Implementations/Metrics/TranscodingMetrics.cs @@ -0,0 +1,51 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Session; +using Prometheus; + +namespace Jellyfin.Server.Implementations.Metrics; + +/// +/// Exposes Prometheus metrics describing the active transcoding sessions. +/// +public sealed class TranscodingMetrics : IMetricsCollector +{ + private static readonly Gauge _transcodes = Prometheus.Metrics + .CreateGauge("jellyfin_transcoding_sessions", "Number of active transcoding sessions grouped by hardware acceleration usage.", "hardware"); + + private static readonly Gauge _transcodingBitrate = Prometheus.Metrics + .CreateGauge("jellyfin_transcoding_bitrate_bps", "Aggregated bitrate of all active transcoding sessions, in bits per second."); + + private readonly ISessionManager _sessionManager; + + /// + /// Initializes a new instance of the class. + /// + /// The session manager. + public TranscodingMetrics(ISessionManager sessionManager) + { + _sessionManager = sessionManager; + } + + /// + public string Name => nameof(TranscodingMetrics); + + /// + public Task CollectAsync(CancellationToken cancellationToken) + { + var transcodes = _sessionManager.Sessions + .Where(s => s.TranscodingInfo is not null) + .Select(s => s.TranscodingInfo) + .ToList(); + + var hardwareAccelerated = transcodes.Count(t => t.HardwareAccelerationType is not null); + _transcodes.WithLabels("true").Set(hardwareAccelerated); + _transcodes.WithLabels("false").Set(transcodes.Count - hardwareAccelerated); + + var totalBitrate = transcodes.Sum(t => t.Bitrate ?? 0); + _transcodingBitrate.Set(totalBitrate); + + return Task.CompletedTask; + } +} diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index fce5fa20a1..cb4e3a6d54 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -92,6 +92,7 @@ namespace Jellyfin.Server serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddHostedService(); // TODO search the assemblies instead of adding them manually? diff --git a/tests/Jellyfin.Server.Implementations.Tests/Metrics/LibraryMetricsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Metrics/LibraryMetricsTests.cs new file mode 100644 index 0000000000..fb2ede2016 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Metrics/LibraryMetricsTests.cs @@ -0,0 +1,42 @@ +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.Metrics; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Metrics; + +public class LibraryMetricsTests +{ + [Fact] + public async Task CollectAsync_QueriesEveryTrackedKind() + { + var libraryManager = new Mock(); + libraryManager.Setup(m => m.GetCount(It.IsAny())).Returns(7); + + var collector = new LibraryMetrics(libraryManager.Object); + await collector.CollectAsync(CancellationToken.None); + + libraryManager.Verify(m => m.GetCount(It.IsAny()), Times.AtLeastOnce); + } + + [Fact] + public async Task CollectAsync_WithEmptyLibrary_DoesNotThrow() + { + var libraryManager = new Mock(); + libraryManager.Setup(m => m.GetCount(It.IsAny())).Returns(0); + + var collector = new LibraryMetrics(libraryManager.Object); + await collector.CollectAsync(CancellationToken.None); + } + + [Fact] + public void Name_ReturnsExpectedValue() + { + var collector = new LibraryMetrics(Mock.Of()); + + Assert.Equal(nameof(LibraryMetrics), collector.Name); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Metrics/MetricsHostedServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Metrics/MetricsHostedServiceTests.cs new file mode 100644 index 0000000000..2f4e073129 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Metrics/MetricsHostedServiceTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.Metrics; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Metrics; + +public class MetricsHostedServiceTests +{ + [Fact] + public async Task ExecuteAsync_WhenMetricsDisabled_DoesNotInvokeCollectors() + { + var collector = new Mock(); + collector.SetupGet(c => c.Name).Returns("Mock"); + var configManager = new Mock(); + configManager.SetupGet(c => c.Configuration).Returns(new ServerConfiguration { EnableMetrics = false }); + + using var cts = new CancellationTokenSource(); + var service = new MetricsHostedService( + configManager.Object, + new[] { collector.Object }, + NullLogger.Instance); + + // Cancel immediately so the loop exits before the first wait completes. + await cts.CancelAsync(); + await service.StartAsync(cts.Token); + await service.StopAsync(CancellationToken.None); + + collector.Verify(c => c.CollectAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ExecuteAsync_WhenCollectorThrows_DoesNotPropagate() + { + var failing = new Mock(); + failing.SetupGet(c => c.Name).Returns("Failing"); + failing.Setup(c => c.CollectAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + var configManager = new Mock(); + configManager.SetupGet(c => c.Configuration).Returns(new ServerConfiguration { EnableMetrics = true }); + + using var cts = new CancellationTokenSource(); + var service = new MetricsHostedService( + configManager.Object, + new[] { failing.Object }, + NullLogger.Instance); + + await service.StartAsync(cts.Token); + // Give the BackgroundService one scheduling slice to enter the loop. + await Task.Yield(); + await cts.CancelAsync(); + await service.StopAsync(CancellationToken.None); + + // The host must still be alive — no unobserved exception escaped the catch block. + Assert.True(true); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Metrics/SessionMetricsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Metrics/SessionMetricsTests.cs new file mode 100644 index 0000000000..2374410daf --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Metrics/SessionMetricsTests.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.Metrics; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Metrics; + +public class SessionMetricsTests +{ + [Fact] + public async Task CollectAsync_WithMixedSessions_CompletesWithoutError() + { + var sessionManager = new Mock(); + var playing = new SessionInfo(sessionManager.Object, NullLogger.Instance) { Client = "Web", NowPlayingItem = new BaseItemDto() }; + playing.PlayState!.PlayMethod = PlayMethod.DirectPlay; + var idle = new SessionInfo(sessionManager.Object, NullLogger.Instance) { Client = "Android" }; + var transcoding = new SessionInfo(sessionManager.Object, NullLogger.Instance) { Client = "AndroidTV", NowPlayingItem = new BaseItemDto(), TranscodingInfo = new TranscodingInfo() }; + transcoding.PlayState!.PlayMethod = PlayMethod.Transcode; + sessionManager.SetupGet(m => m.Sessions).Returns(new[] { playing, idle, transcoding }); + + var collector = new SessionMetrics(sessionManager.Object); + await collector.CollectAsync(CancellationToken.None); + + sessionManager.VerifyGet(m => m.Sessions, Times.Once); + } + + [Fact] + public async Task CollectAsync_WithNoSessions_DoesNotThrow() + { + var sessionManager = new Mock(); + sessionManager.SetupGet(m => m.Sessions).Returns(new List()); + + var collector = new SessionMetrics(sessionManager.Object); + await collector.CollectAsync(CancellationToken.None); + + sessionManager.VerifyGet(m => m.Sessions, Times.Once); + } + + [Fact] + public void Name_ReturnsExpectedValue() + { + var collector = new SessionMetrics(Mock.Of()); + + Assert.Equal(nameof(SessionMetrics), collector.Name); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Metrics/TranscodingMetricsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Metrics/TranscodingMetricsTests.cs new file mode 100644 index 0000000000..6e1c9103a0 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Metrics/TranscodingMetricsTests.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.Metrics; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Session; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Metrics; + +public class TranscodingMetricsTests +{ + [Fact] + public async Task CollectAsync_WithHardwareAndSoftwareTranscodes_CompletesWithoutError() + { + var sessionManager = new Mock(); + var hardware = new SessionInfo(sessionManager.Object, NullLogger.Instance) + { + TranscodingInfo = new TranscodingInfo + { + HardwareAccelerationType = HardwareAccelerationType.nvenc, + Bitrate = 4_000_000, + }, + }; + var software = new SessionInfo(sessionManager.Object, NullLogger.Instance) + { + TranscodingInfo = new TranscodingInfo + { + HardwareAccelerationType = null, + Bitrate = 2_000_000, + }, + }; + var notTranscoding = new SessionInfo(sessionManager.Object, NullLogger.Instance); + sessionManager.SetupGet(m => m.Sessions).Returns(new[] { hardware, software, notTranscoding }); + + var collector = new TranscodingMetrics(sessionManager.Object); + await collector.CollectAsync(CancellationToken.None); + + sessionManager.VerifyGet(m => m.Sessions, Times.Once); + } + + [Fact] + public async Task CollectAsync_WithNoActiveTranscodes_DoesNotThrow() + { + var sessionManager = new Mock(); + sessionManager.SetupGet(m => m.Sessions).Returns(new List()); + + var collector = new TranscodingMetrics(sessionManager.Object); + await collector.CollectAsync(CancellationToken.None); + } + + [Fact] + public void Name_ReturnsExpectedValue() + { + var collector = new TranscodingMetrics(Mock.Of()); + + Assert.Equal(nameof(TranscodingMetrics), collector.Name); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Metrics/UserMetricsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Metrics/UserMetricsTests.cs new file mode 100644 index 0000000000..149770ae0f --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Metrics/UserMetricsTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.Implementations.Metrics; +using MediaBrowser.Controller.Library; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Metrics; + +public class UserMetricsTests +{ + [Fact] + public async Task CollectAsync_WithMixedUsers_CompletesWithoutError() + { + var users = new[] + { + CreateUser("admin", isAdmin: true, isDisabled: false, lastActivityDaysAgo: 1, failedLogins: 0), + CreateUser("regular", isAdmin: false, isDisabled: false, lastActivityDaysAgo: 5, failedLogins: 2), + CreateUser("disabled", isAdmin: false, isDisabled: true, lastActivityDaysAgo: 90, failedLogins: 0), + }; + var userManager = new Mock(); + userManager.Setup(m => m.GetUsers()).Returns(users); + + var collector = new UserMetrics(userManager.Object); + await collector.CollectAsync(CancellationToken.None); + + userManager.Verify(m => m.GetUsers(), Times.Once); + } + + [Fact] + public async Task CollectAsync_WithNoUsers_DoesNotThrow() + { + var userManager = new Mock(); + userManager.Setup(m => m.GetUsers()).Returns(Array.Empty()); + + var collector = new UserMetrics(userManager.Object); + await collector.CollectAsync(CancellationToken.None); + + userManager.Verify(m => m.GetUsers(), Times.Once); + } + + [Fact] + public void Name_ReturnsExpectedValue() + { + var collector = new UserMetrics(Mock.Of()); + + Assert.Equal(nameof(UserMetrics), collector.Name); + } + + private static User CreateUser(string name, bool isAdmin, bool isDisabled, int lastActivityDaysAgo, int failedLogins) + { + var user = new User(name, "DefaultAuthenticationProvider", "DefaultPasswordResetProvider") + { + LastActivityDate = DateTime.UtcNow.AddDays(-lastActivityDaysAgo), + InvalidLoginAttemptCount = failedLogins, + }; + user.SetPermission(PermissionKind.IsAdministrator, isAdmin); + user.SetPermission(PermissionKind.IsDisabled, isDisabled); + return user; + } +}