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