using System; using System.Collections.Concurrent; using System.Linq; using System.Threading; using System.Threading.Tasks; using API.Data; using API.DTOs.Progress; using API.Entities.Enums; using API.Entities.Progress; using API.Extensions; using API.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace API.Services.Reading; #nullable enable public interface IReadingSessionService { Task UpdateProgress(int userId, ProgressDto progressDto, ClientInfoData? clientInfo, int? deviceId); } internal sealed record SessionTimeout { public required T Value { get; set; } /// /// Expiration time in Utc /// public DateTime Expiration { get; set; } public DateTime LastTimerRefresh { get; set; } public Timer? TimeoutTimer { get; set; } } public class ReadingSessionService : IReadingSessionService, IDisposable, IAsyncDisposable { private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ILogger _logger; private readonly HybridCache _cache; private readonly ConcurrentDictionary> _activeSessions = new(); private readonly int _defaultTimeoutMinutes; private readonly int _timerRefreshDebounceSeconds; private Timer? _midnightRolloverTimer; private bool _disposed; private static readonly HybridCacheEntryOptions ChapterFormatCacheOptions = new() { Expiration = TimeSpan.FromMinutes(30), LocalCacheExpiration = TimeSpan.FromMinutes(30) }; public ReadingSessionService(IServiceScopeFactory serviceScopeFactory, ILogger logger, HybridCache cache, int defaultTimeoutMinutes = 30, int timerRefreshDebounceSeconds = 5) { _serviceScopeFactory = serviceScopeFactory; _logger = logger; _cache = cache; _defaultTimeoutMinutes = defaultTimeoutMinutes; _timerRefreshDebounceSeconds = timerRefreshDebounceSeconds; ScheduleMidnightRollover(); } public async Task UpdateProgress(int userId, ProgressDto progressDto, ClientInfoData? clientInfo, int? deviceId) { _logger.LogDebug("Creating/Updating Reading Session for {UserId} on {ChapterId}", userId, progressDto.ChapterId); var session = await GetOrCreateSession(userId, progressDto); using var scope = _serviceScopeFactory.CreateScope(); // Update session activity data in DB var context = scope.ServiceProvider.GetRequiredService(); // If Chapter doesn't exist already, add var existingChapterActivity = session.ActivityData.FirstOrDefault(d => d.ChapterId == progressDto.ChapterId); if (existingChapterActivity != null) { existingChapterActivity.PagesRead = progressDto.PageNum - existingChapterActivity.StartPage; existingChapterActivity.EndPage = progressDto.PageNum; existingChapterActivity.EndTime = DateTime.Now; existingChapterActivity.EndTimeUtc = DateTime.UtcNow; if (deviceId.HasValue) { existingChapterActivity.DeviceIds.Add(deviceId.Value); } existingChapterActivity.DeviceIds = existingChapterActivity.DeviceIds.Distinct().ToList(); // Update client info if it changed (e.g., user switched devices) if (clientInfo != null) { existingChapterActivity.ClientInfo = clientInfo; } var cacheService = scope.ServiceProvider.GetRequiredService(); var chapter = await cacheService.Ensure(progressDto.ChapterId); // Use cached chapter format to avoid repeated DB queries var chapterFormat = await GetChapterFormatAsync(progressDto.ChapterId, context); // Store total pages/words in case it changes in the future existingChapterActivity.TotalPages = chapter?.Pages ?? 0; existingChapterActivity.TotalWords = chapter?.WordCount ?? 0; if (chapterFormat == MangaFormat.Epub && !string.IsNullOrEmpty(progressDto.BookScrollId)) { var bookService = scope.ServiceProvider.GetRequiredService(); var cachedFilePath = cacheService.GetCachedFile(chapter!); // First update - capture starting position if (string.IsNullOrEmpty(existingChapterActivity.StartBookScrollId)) { existingChapterActivity.StartBookScrollId = progressDto.BookScrollId; existingChapterActivity.WordsRead = 0; } else { // Calculate total words read from start to current position try { existingChapterActivity.WordsRead = await bookService.GetWordCountBetweenXPaths( cachedFilePath, existingChapterActivity.StartBookScrollId, progressDto.BookScrollId ); } catch (Exception ex) { _logger.LogError(ex, "There was an error calculating words read for reading session {SessionId} on book {File}", session.Id, cachedFilePath); } } // Always update the current end position existingChapterActivity.EndBookScrollId = progressDto.BookScrollId; } } else { // Add new ActivityData for a different chapter in the same session var newActivity = NewActivityData(progressDto); if (clientInfo != null) { newActivity.ClientInfo = clientInfo; newActivity.DeviceIds.Add(deviceId!.Value); newActivity.DeviceIds = newActivity.DeviceIds.Distinct().ToList(); } session.ActivityData.Add(newActivity); } // Update session timestamps session.LastModified = DateTime.Now; session.LastModifiedUtc = DateTime.UtcNow; // Save changes context.AppUserReadingSession.Update(session); await context.SaveChangesAsync(); // Refresh timeout var cacheKey = GenerateCacheKey(userId, progressDto.ChapterId); RefreshSessionTimeout(cacheKey, session.Id); } private async Task GetChapterFormatAsync(int chapterId, DataContext context) { var cacheKey = GetChapterFormatCacheKey(chapterId); return await _cache.GetOrCreateAsync( cacheKey, (chapterId, context), async (state, cancel) => await state.context.MangaFile .Where(f => f.ChapterId == state.chapterId) .Select(f => f.Format) .FirstOrDefaultAsync(cancel), ChapterFormatCacheOptions); } private async Task ClearChapterFormatCache(int chapterId) { var cacheKey = GetChapterFormatCacheKey(chapterId); await _cache.RemoveAsync(cacheKey); } private static string GetChapterFormatCacheKey(int chapterId) { return $"readingsession_chapter_format_{chapterId}"; } private async Task ClearSessionChapterCaches(int sessionId) { try { using var scope = _serviceScopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var chapterIds = await context.AppUserReadingSession .Where(s => s.Id == sessionId) .SelectMany(s => s.ActivityData) .Select(ad => ad.ChapterId) .Distinct() .ToListAsync(); foreach (var chapterId in chapterIds) { await ClearChapterFormatCache(chapterId); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to clear chapter format caches for session {SessionId}", sessionId); } } private async Task GetOrCreateSession(int userId, ProgressDto dto) { using var scope = _serviceScopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); // Check if we have an existing cached reading session that is active var cacheKey = GenerateCacheKey(userId, dto.ChapterId); if (_activeSessions.TryGetValue(cacheKey, out var sessionTimeout)) { if (sessionTimeout.Expiration <= DateTime.Now) { // Expired - close it and create new one await CloseSession(cacheKey, sessionTimeout.Value); } else { var session = await context.AppUserReadingSession .Where(s => s.Id == sessionTimeout.Value) .Include(s => s.ActivityData) .FirstOrDefaultAsync(); if (session != null) return session; } } // Look up in the DB for an active reading session var dbSession = await context.AppUserReadingSession .Where(s => s.IsActive && s.AppUserId == userId) .Include(s => s.ActivityData) .FirstOrDefaultAsync(); if (dbSession != null) { // Re-add to cache with timer RefreshSessionTimeout(cacheKey, dbSession.Id); return dbSession; } // Create a new session and return it var newSession = new AppUserReadingSession() { AppUserId = userId, StartTime = DateTime.Now, StartTimeUtc = DateTime.UtcNow, IsActive = true, ActivityData = [ NewActivityData(dto), ] }; await context.AppUserReadingSession.AddAsync(newSession); await context.SaveChangesAsync(); RefreshSessionTimeout(cacheKey, newSession.Id); return newSession; } private static AppUserReadingSessionActivityData NewActivityData(ProgressDto dto) { return new AppUserReadingSessionActivityData { ChapterId = dto.ChapterId, VolumeId = dto.VolumeId, SeriesId = dto.SeriesId, LibraryId = dto.LibraryId, StartPage = dto.PageNum, EndPage = dto.PageNum, StartTime = DateTime.Now, StartTimeUtc = DateTime.UtcNow, EndTime = null, PagesRead = 0, WordsRead = 0, ClientInfo = null, DeviceIds = [], }; } private void RefreshSessionTimeout(string cacheKey, int sessionId) { var now = DateTime.Now; _activeSessions.AddOrUpdate(cacheKey, // Add new key => new SessionTimeout() { Value = sessionId, Expiration = now.AddMinutes(_defaultTimeoutMinutes), LastTimerRefresh = now, TimeoutTimer = CreateSessionTimer(key, sessionId) }, // Update Existing (_, existing) => { // Always update expiration existing.Expiration = now.AddMinutes(_defaultTimeoutMinutes); // Debounce timer refresh (avoid excessive timer churn) var secondsSinceLastRefresh = (now - existing.LastTimerRefresh).TotalSeconds; if (secondsSinceLastRefresh >= _timerRefreshDebounceSeconds) { existing.TimeoutTimer?.Change(TimeSpan.FromMinutes(_defaultTimeoutMinutes), TimeSpan.Zero); existing.LastTimerRefresh = now; } return existing; } ); } private Timer CreateSessionTimer(string cacheKey, int sessionId) { return new Timer( callback: _ => OnSessionTimeout(cacheKey, sessionId), state: null, dueTime: TimeSpan.FromMinutes(_defaultTimeoutMinutes), period: TimeSpan.Zero ); } private void OnSessionTimeout(string cacheKey, int sessionId) { _ = Task.Run(async () => { await CloseSession(cacheKey, sessionId); await ClearSessionChapterCaches(sessionId); }) .ContinueWith(t => { if (t.IsFaulted) { _logger.LogError(t.Exception, "There was an issue closing session {SessionId} with CacheKey: {CacheKey}", sessionId, cacheKey); } }); } private async Task CloseSession(string cacheKey, int sessionId) { // Remove from cache and dispose timer if (_activeSessions.TryRemove(cacheKey, out var session) && session.TimeoutTimer != null) { await session.TimeoutTimer.DisposeAsync(); } using var scope = _serviceScopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var eventHub = scope.ServiceProvider.GetRequiredService(); // Mark session as inactive in DB await context.AppUserReadingSession .Where(s => s.Id == sessionId) .ExecuteUpdateAsync(s => s .SetProperty(x => x.IsActive, false) .SetProperty(x => x.EndTime, DateTime.Now.Subtract(TimeSpan.FromMinutes(_defaultTimeoutMinutes))) .SetProperty(x => x.EndTimeUtc, DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(_defaultTimeoutMinutes))) .SetProperty(x => x.LastModified, DateTime.Now) .SetProperty(x => x.LastModifiedUtc, DateTime.UtcNow)); await UpdateTotalReadsOnSessionClose(sessionId); // Trigger a SessionClose Event so Activity Feed can update await eventHub.SendMessageAsync(MessageFactory.SessionClose, MessageFactory.SessionCloseEvent(sessionId)); } private async Task UpdateTotalReadsOnSessionClose(int sessionId) { using var scope = _serviceScopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); // Check if the user fully read any chapter and increment totalReads for said chapter var sessionEntry = await context.AppUserReadingSession .Where(s => s.Id == sessionId) .Include(s => s.ActivityData) .FirstAsync(); var chapterIds = sessionEntry.ActivityData .Where(d => d.EndPage >= d.TotalPages) .Select(d => d.ChapterId) .ToList(); if (chapterIds.Count > 0) { await context.AppUserProgresses .Where(p => chapterIds.Contains(p.ChapterId)) .ExecuteUpdateAsync(setters => setters .SetProperty(x => x.TotalReads, x => x.TotalReads + 1)); } } private void ScheduleMidnightRollover() { var now = DateTime.Now; var nextMidnight = now.Date.AddDays(1); var timeUntilMidnight = nextMidnight - now; _midnightRolloverTimer = new Timer( callback: _ => { // Synchronous callback that starts async work OnMidnightRolloverAsync().ContinueWith(t => { if (t.IsFaulted) { _logger.LogCritical("There was an issue closing midnight sessions"); } }); }, state: null, dueTime: timeUntilMidnight, period: TimeSpan.Zero ); } private async Task OnMidnightRolloverAsync() { var endOfYesterday = DateTime.Now.Date.AddTicks(-1); // 23:59:59.9999999 var endOfYesterdayUtc = DateTime.UtcNow.Date.AddTicks(-1); // 23:59:59.9999999 var sessionsToClose = _activeSessions.ToArray(); if (sessionsToClose.Length > 0) { using var scope = _serviceScopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var eventHub = scope.ServiceProvider.GetRequiredService(); var sessionIds = sessionsToClose.Select(kvp => kvp.Value.Value).ToList(); // Batch close all sessions in DB await context.AppUserReadingSession .Where(s => sessionIds.Contains(s.Id)) .ExecuteUpdateAsync(s => s .SetProperty(x => x.IsActive, false) .SetProperty(x => x.EndTime, endOfYesterday) .SetProperty(x => x.EndTimeUtc, endOfYesterdayUtc) .SetProperty(x => x.LastModified, DateTime.Now) .SetProperty(x => x.LastModifiedUtc, DateTime.UtcNow)); // Ensure we increment total reads for any closed sessions var chapterIds = await context.AppUserReadingSession .Where(s => sessionIds.Contains(s.Id)) .Include(s => s.ActivityData) .SelectMany(s => s.ActivityData .Where(d => d.EndPage >= d.TotalPages) .Select(d => d.ChapterId)) .Distinct() .ToListAsync(); if (chapterIds.Count > 0) { await context.AppUserProgresses .Where(p => chapterIds.Contains(p.ChapterId)) .ExecuteUpdateAsync(setters => setters .SetProperty(x => x.TotalReads, x => x.TotalReads + 1)); } foreach (var sessionId in sessionIds) { await ClearSessionChapterCaches(sessionId); // Trigger a SessionClose Event so Activity Feed can update await eventHub.SendMessageAsync(MessageFactory.SessionClose, MessageFactory.SessionCloseEvent(sessionId)); } // Clear cache and dispose all timers foreach (var kvp in sessionsToClose) { if (kvp.Value.TimeoutTimer != null) await kvp.Value.TimeoutTimer.DisposeAsync(); _activeSessions.TryRemove(kvp.Key, out _); } } // Schedule next midnight Rollover ScheduleMidnightRollover(); } public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } public async ValueTask DisposeAsync() { await DisposeAsyncCore().ConfigureAwait(false); Dispose(disposing: false); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { foreach (var session in _activeSessions.Values) { session.TimeoutTimer?.Dispose(); } _midnightRolloverTimer?.Dispose(); _activeSessions.Clear(); } _disposed = true; } protected virtual async ValueTask DisposeAsyncCore() { if (_disposed) return; // Dispose managed resources asynchronously foreach (var session in _activeSessions.Values) { if (session.TimeoutTimer != null) { await session.TimeoutTimer.DisposeAsync().ConfigureAwait(false); } } if (_midnightRolloverTimer != null) { await _midnightRolloverTimer.DisposeAsync().ConfigureAwait(false); } _activeSessions.Clear(); _disposed = true; } private static string GenerateCacheKey(int userId, int chapterId) { return $"{userId}_{chapterId}"; } }