mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-05-24 08:32:29 -04:00
Progress Overhaul + Profile Page and a LOT more! (#4262)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com> Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
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<T>
|
||||
{
|
||||
public required T Value { get; set; }
|
||||
/// <summary>
|
||||
/// Expiration time in Utc
|
||||
/// </summary>
|
||||
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<ReadingSessionService> _logger;
|
||||
private readonly HybridCache _cache;
|
||||
private readonly ConcurrentDictionary<string, SessionTimeout<int>> _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<ReadingSessionService> 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<DataContext>();
|
||||
|
||||
// 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<ICacheService>();
|
||||
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<IBookService>();
|
||||
|
||||
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<MangaFormat> 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<DataContext>();
|
||||
|
||||
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<AppUserReadingSession> GetOrCreateSession(int userId, ProgressDto dto)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
// 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<int>()
|
||||
{
|
||||
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<DataContext>();
|
||||
var eventHub = scope.ServiceProvider.GetRequiredService<IEventHub>();
|
||||
|
||||
|
||||
// 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<DataContext>();
|
||||
|
||||
// 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<DataContext>();
|
||||
var eventHub = scope.ServiceProvider.GetRequiredService<IEventHub>();
|
||||
|
||||
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}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user