using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Session; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StackExchange.Redis; namespace Emby.Server.Implementations.Session; /// /// A Redis-backed . Each entry is a key with an expiry, so the sessions of /// an instance that stops refreshing them disappear on their own. /// public sealed class RedisSessionDirectory : ISessionDirectory { private const string KeyPrefix = "jellyfin:session:"; private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly IConnectionMultiplexer _redis; private readonly IDatabase _db; private readonly SessionDirectoryOptions _options; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The Redis connection multiplexer. /// The session directory configuration options. /// The logger. public RedisSessionDirectory( IConnectionMultiplexer redis, IOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(redis); ArgumentNullException.ThrowIfNull(options); _redis = redis; _db = redis.GetDatabase(); _options = options.Value; _logger = logger; } private TimeSpan EntryTtl => TimeSpan.FromSeconds(Math.Max(1, _options.EntryTtlSeconds)); /// public async Task PublishAsync(SessionDirectoryEntry entry, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); var sessionId = entry.Session?.Id; if (string.IsNullOrEmpty(sessionId)) { return; } try { await _db.StringSetAsync(KeyPrefix + sessionId, JsonSerializer.Serialize(entry, _jsonOptions), EntryTtl).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to publish session {SessionId}; it stays invisible to the other instances.", sessionId); } } /// public async Task RemoveAsync(string sessionId, CancellationToken cancellationToken = default) { try { await _db.KeyDeleteAsync(KeyPrefix + sessionId).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to remove session {SessionId}; it expires on its own.", sessionId); } } /// public async Task> GetAllAsync(CancellationToken cancellationToken = default) { var entries = new List(); try { foreach (var server in _redis.GetServers()) { if (!server.IsConnected) { continue; } var keys = new List(); await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false)) { keys.Add(key); } var values = await Task.WhenAll(keys.Select(key => _db.StringGetAsync(key))).ConfigureAwait(false); foreach (var raw in values) { if (!raw.HasValue) { continue; } try { var entry = JsonSerializer.Deserialize(raw.ToString(), _jsonOptions); if (entry?.Session is not null) { entries.Add(entry); } } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to deserialize a session directory entry."); } } } } catch (Exception ex) { // Degrade to the sessions this instance holds rather than failing the request outright. _logger.LogWarning(ex, "Failed to read the session directory; only local sessions are reported."); return Array.Empty(); } return entries; } }