Kavita/API/Services/StatisticService.cs
Joe Milazzo e1f421ccc0
Stats Page Overhaul (#4292)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
2025-12-19 12:23:55 -08:00

1751 lines
66 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.ManualMigrations;
using API.DTOs;
using API.DTOs.Metadata;
using API.DTOs.Person;
using API.DTOs.Statistics;
using API.DTOs.Stats;
using API.DTOs.Stats.V3.ClientDevice;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Services;
#nullable enable
public interface IStatisticService
{
Task<ServerStatisticsDto> GetServerStatistics();
Task<UserReadStatistics> GetUserReadStatistics(int userId, IList<int> libraryIds);
Task<IEnumerable<StatCount<int>>> GetYearCount();
Task<IEnumerable<StatCount<int>>> GetTopYears();
Task<IList<StatBucketDto>> GetPopularDecades();
Task<IList<StatCount<LibraryDto>>> GetPopularLibraries();
Task<IList<StatCount<SeriesDto>>> GetPopularSeries();
Task<IList<StatCount<GenreTagDto>>> GetPopularGenres();
Task<IList<StatCount<TagDto>>> GetPopularTags();
Task<IList<StatCount<PersonDto>>> GetPopularPerson(PersonRole role);
Task<IEnumerable<StatCount<PublicationStatus>>> GetPublicationCount();
Task<IEnumerable<StatCount<MangaFormat>>> GetMangaFormatCount();
Task<FileExtensionBreakdownDto> GetFileBreakdown();
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
Task<IEnumerable<StatCountWithFormat<DateTime>>> ReadCountByDay(int userId = 0, int days = 0);
Task<IEnumerable<StatCountWithFormat<DateTime>>> ReadCounts(StatsFilterDto filter, int userId = 0);
Task<IList<StatCount<DayOfWeek>>> GetDayBreakdown(int userId = 0);
Task<IList<StatCount<int>>> GetPagesReadCountByYear(int userId = 0);
Task<IList<StatCount<int>>> GetWordsReadCountByYear(int userId = 0);
Task UpdateServerStatistics();
Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension);
Task<DeviceClientBreakdownDto> GetClientTypeBreakdown(DateTime fromDateUtc);
Task<IList<StatCount<string>>> GetDeviceTypeCounts(DateTime fromDateUtc);
Task<ReadingActivityGraphDto> GetReadingActivityGraphData(StatsFilterDto filter, int userId, int year, int requestingUserId);
Task<ReadingPaceDto> GetReadingPaceForUser(StatsFilterDto filter, int userId, int year, bool booksOnly, int requestingUserID);
Task<BreakDownDto<string>> GetGenreBreakdownForUser(StatsFilterDto filter, int userId, int requestingUserId);
Task<BreakDownDto<string>> GetTagBreakdownForUser(StatsFilterDto filter, int userId, int requestingUserId);
Task<SpreadStatsDto> GetPageSpreadForUser(StatsFilterDto filter, int userId, int requestingUserId);
Task<SpreadStatsDto> GetWordSpreadForUser(StatsFilterDto filter, int userId, int requestingUserId);
Task<IList<StatCount<YearMonthGroupingDto>>> GetReadsPerMonth(StatsFilterDto filter, int userId, int requestingUserId);
Task<IList<MostReadAuthorsDto>> GetMostReadAuthors(StatsFilterDto filter, int userId, int requestingUserId);
Task<int> GetTotalReads(int userId, int requestingUserId);
Task<ReadTimeByHourDto?> GetTimeReadingByHour(StatsFilterDto filter, int userId, int requestingUserId);
Task<ProfileStatBarDto> GetUserStatBar(StatsFilterDto filter, int userId, int requestingUserId);
Task<IList<MostActiveUserDto>> GetMostActiveUsers(StatsFilterDto filter);
Task<IList<StatCountWithFormat<DateTime>>> GetFilesAddedOverTime();
}
/// <summary>
/// Responsible for computing statistics for the server
/// </summary>
/// <remarks>This performs raw queries and does not use a repository</remarks>
public class StatisticService(ILogger<StatisticService> logger, DataContext context, IMapper mapper, IUnitOfWork unitOfWork): IStatisticService
{
public async Task<UserReadStatistics> GetUserReadStatistics(int userId, IList<int> libraryIds)
{
if (libraryIds.Count == 0)
{
libraryIds = await context.Library.GetUserLibraries(userId).ToListAsync();
}
var activityData = await context.AppUserReadingSessionActivityData
.AsNoTracking()
.Where(a => a.ReadingSession.AppUserId == userId)
.Where(a => libraryIds.Contains(a.LibraryId))
.Select(a => new
{
a.PagesRead,
a.WordsRead,
a.TotalPages,
a.StartTimeUtc,
a.EndTimeUtc,
a.LibraryId,
a.ChapterId
})
.ToListAsync();
var totalPagesRead = activityData.Sum(a => a.PagesRead);
var totalWordsRead = activityData.Sum(a => (long)a.WordsRead);
var timeSpentReading = (long)Math.Round(activityData
.Where(a => a.EndTimeUtc != null)
.Sum(a => (a.EndTimeUtc!.Value - a.StartTimeUtc).TotalHours));
var lastActive = await context.AppUserReadingSession
.AsNoTracking()
.Where(s => s.AppUserId == userId)
.Select(s => s.EndTimeUtc)
.DefaultIfEmpty()
.MaxAsync();
// Average reading time per week
var earliestReadDate = activityData
.Select(a => a.StartTimeUtc)
.DefaultIfEmpty(DateTime.UtcNow)
.Min();
var avgHoursPerWeek = 0f;
if (activityData.Count > 0 && earliestReadDate != DateTime.UtcNow)
{
var timeDifference = DateTime.UtcNow - earliestReadDate;
var deltaWeeks = Math.Max(1, (int)Math.Ceiling(timeDifference.TotalDays / 7));
avgHoursPerWeek = (float)timeSpentReading / deltaWeeks;
}
return new UserReadStatistics
{
TotalPagesRead = totalPagesRead,
TotalWordsRead = totalWordsRead,
TimeSpentReading = timeSpentReading,
LastActiveUtc = lastActive,
AvgHoursPerWeekSpentReading = avgHoursPerWeek
};
}
/// <summary>
/// Returns the Release Years and their count
/// </summary>
/// <returns></returns>
public async Task<IEnumerable<StatCount<int>>> GetYearCount()
{
return await context.SeriesMetadata
.Where(sm => sm.ReleaseYear != 0)
.AsSplitQuery()
.GroupBy(sm => sm.ReleaseYear)
.Select(sm => new StatCount<int>
{
Value = sm.Key,
Count = context.SeriesMetadata.Where(sm2 => sm2.ReleaseYear == sm.Key).Distinct().Count()
})
.OrderByDescending(d => d.Value)
.ToListAsync();
}
public async Task<IEnumerable<StatCount<int>>> GetTopYears()
{
return await context.SeriesMetadata
.Where(sm => sm.ReleaseYear != 0)
.AsSplitQuery()
.GroupBy(sm => sm.ReleaseYear)
.Select(sm => new StatCount<int>
{
Value = sm.Key,
Count = context.SeriesMetadata.Where(sm2 => sm2.ReleaseYear == sm.Key).Distinct().Count()
})
.OrderByDescending(d => d.Count)
.Take(5)
.ToListAsync();
}
public async Task<IList<StatBucketDto>> GetPopularDecades()
{
var decadeGroups = await context.SeriesMetadata
.Where(sm => sm.ReleaseYear != 0)
.GroupBy(sm => (sm.ReleaseYear / 10) * 10) // Floor to decade
.Select(g => new
{
Decade = g.Key,
Count = g.Count()
})
.ToListAsync();
var totalCount = decadeGroups.Sum(d => d.Count);
return decadeGroups
.OrderByDescending(d => d.Decade)
.Select(d => new StatBucketDto
{
RangeStart = d.Decade,
RangeEnd = d.Decade + 9,
Count = d.Count,
Percentage = totalCount > 0
? Math.Round((decimal)d.Count / totalCount * 100, 2)
: 0
})
.ToList();
}
public async Task<IList<StatCount<LibraryDto>>> GetPopularLibraries()
{
var counts = await context.AppUserProgresses
.Where(p => p.LibraryId > 0)
.GetTopCounts(p => p.LibraryId, take: 5);
var libraries = await context.Library
.Where(l => counts.Select(c => c.Id).Contains(l.Id))
.ProjectTo<LibraryDto>(mapper.ConfigurationProvider)
.ToDictionaryAsync(l => l.Id);
return counts
.Where(c => libraries.ContainsKey(c.Id))
.Select(lc => new StatCount<LibraryDto>
{
Value = libraries[lc.Id],
Count = lc.Count
})
.ToList();
}
public async Task<IList<StatCount<SeriesDto>>> GetPopularSeries()
{
var counts = await context.AppUserProgresses
.GetTopCounts(p => p.SeriesId, take: 5);
if (counts.Count == 0) return [];
var series = await context.Series
.Where(s => counts.Select(c => c.Id).Contains(s.Id))
.ProjectTo<SeriesDto>(mapper.ConfigurationProvider)
.ToDictionaryAsync(s => s.Id);
return counts
.Where(c => series.ContainsKey(c.Id))
.Select(sc => new StatCount<SeriesDto>
{
Value = series[sc.Id],
Count = sc.Count
})
.ToList();
}
/// <summary>
/// Top 5 genres where there is some reading activity
/// </summary>
/// <remarks>Since most users only tag the Series level metadata, this will only check against Series. Will count series * totalReads of series</remarks>
/// <returns></returns>
public async Task<IList<StatCount<GenreTagDto>>> GetPopularGenres()
{
var counts = await context.AppUserProgresses
.GetTopCounts(p => p.SeriesId);
if (counts.Count == 0) return [];
var countDict = counts.ToDictionary(c => c.Id, c => c.Count);
var genreStats = await context.Genre
.SelectMany(g => g.SeriesMetadatas, (genre, sm) => new
{
Genre = genre,
sm.SeriesId
})
.Where(x => countDict.Keys.Contains(x.SeriesId))
.ToListAsync();
return genreStats
.GroupBy(x => x.Genre)
.Select(g => new StatCount<GenreTagDto>
{
Value = new GenreTagDto
{
Id = g.Key.Id,
Title = g.Key.Title
},
Count = g.Sum(x => countDict.GetValueOrDefault(x.SeriesId, 0))
})
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
}
public async Task<IList<StatCount<TagDto>>> GetPopularTags()
{
var counts = await context.AppUserProgresses
.GetTopCounts(p => p.SeriesId);
if (counts.Count == 0)
return [];
var countDict = counts.ToDictionary(c => c.Id, c => c.Count);
var genreStats = await context.Tag
.SelectMany(g => g.SeriesMetadatas, (tag, sm) => new
{
Tag = tag,
sm.SeriesId
})
.Where(x => countDict.Keys.Contains(x.SeriesId))
.ToListAsync();
return genreStats
.GroupBy(x => x.Tag)
.Select(g => new StatCount<TagDto>
{
Value = new TagDto
{
Id = g.Key.Id,
Title = g.Key.Title
},
Count = g.Sum(x => countDict.GetValueOrDefault(x.SeriesId, 0))
})
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
}
public async Task<IList<StatCount<PersonDto>>> GetPopularPerson(PersonRole role)
{
var counts = await context.AppUserProgresses
.GetTopCounts(p => p.SeriesId);
if (counts.Count == 0) return [];
var countDict = counts.ToDictionary(c => c.Id, c => c.Count);
var authorStats = await context.SeriesMetadataPeople
.Where(smp => smp.Role == role)
.Where(smp => countDict.Keys.Contains(smp.SeriesMetadata.SeriesId))
.Select(smp => new
{
smp.Person,
smp.SeriesMetadata.SeriesId
})
.ToListAsync();
return authorStats
.GroupBy(x => x.Person)
.Select(g => new StatCount<PersonDto>
{
Value = new PersonDto
{
Id = g.Key.Id,
Name = g.Key.Name,
CoverImage = g.Key.CoverImage,
PrimaryColor = g.Key.PrimaryColor,
SecondaryColor = g.Key.SecondaryColor,
Description = g.Key.Description
},
Count = g.Sum(x => countDict.GetValueOrDefault(x.SeriesId, 0))
})
.OrderByDescending(x => x.Count)
.Take(5)
.ToList();
}
public async Task<IEnumerable<StatCount<PublicationStatus>>> GetPublicationCount()
{
return await context.SeriesMetadata
.AsSplitQuery()
.GroupBy(sm => sm.PublicationStatus)
.Select(sm => new StatCount<PublicationStatus>
{
Value = sm.Key,
Count = context.SeriesMetadata.Where(sm2 => sm2.PublicationStatus == sm.Key).Distinct().Count()
})
.ToListAsync();
}
public async Task<IEnumerable<StatCount<MangaFormat>>> GetMangaFormatCount()
{
return await context.MangaFile
.AsSplitQuery()
.GroupBy(sm => sm.Format)
.Select(mf => new StatCount<MangaFormat>
{
Value = mf.Key,
Count = context.MangaFile.Where(mf2 => mf2.Format == mf.Key).Distinct().Count()
})
.ToListAsync();
}
public async Task<ServerStatisticsDto> GetServerStatistics()
{
var counts = await context.Chapter
.Select(_ => new
{
Chapters = context.Chapter.Count(),
Series = context.Series.Count(),
Files = context.MangaFile.Count(),
Genres = context.Genre.Count(),
People = context.Person.Select(p => p.NormalizedName).Distinct().Count(),
Tags = context.Tag.Count(),
Volumes = context.Volume.Count(v => Math.Abs(v.MinNumber - Parser.LooseLeafVolumeNumber) > 0.001f),
TotalBytes = context.MangaFile.Sum(m => m.Bytes)
})
.FirstAsync();
var totalReadingHours = await context.AppUserReadingSessionActivityData
.Where(a => a.EndTimeUtc != null)
.Select(a => new { a.StartTimeUtc, EndTimeUtc = a.EndTimeUtc!.Value })
.ToListAsync()
.ContinueWith(t => t.Result.Sum(a => (a.EndTimeUtc - a.StartTimeUtc).TotalHours));
return new ServerStatisticsDto
{
ChapterCount = counts.Chapters,
SeriesCount = counts.Series,
TotalFiles = counts.Files,
TotalGenres = counts.Genres,
TotalPeople = counts.People,
TotalSize = counts.TotalBytes,
TotalTags = counts.Tags,
VolumeCount = counts.Volumes,
TotalReadingTime = (long) totalReadingHours
};
}
public async Task<FileExtensionBreakdownDto> GetFileBreakdown()
{
return new FileExtensionBreakdownDto()
{
FileBreakdown = await context.MangaFile
.AsSplitQuery()
.AsNoTracking()
.GroupBy(sm => sm.Extension)
.Select(mf => new FileExtensionDto()
{
Extension = mf.Key,
Format =context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Select(mf2 => mf2.Format).Single(),
TotalSize = context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Sum(mf2 => mf2.Bytes),
TotalFiles = context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Count()
})
.OrderBy(d => d.TotalFiles)
.ToListAsync(),
TotalFileSize = await context.MangaFile
.AsNoTracking()
.AsSplitQuery()
.SumAsync(f => f.Bytes)
};
}
public async Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId)
{
return await context.AppUserProgresses
.Where(u => u.AppUserId == userId)
.AsNoTracking()
.AsSplitQuery()
.Select(u => new ReadHistoryEvent
{
UserId = u.AppUserId,
UserName = context.AppUser.Single(u2 => u2.Id == userId).UserName,
SeriesName = context.Series.Single(s => s.Id == u.SeriesId).Name,
SeriesId = u.SeriesId,
LibraryId = u.LibraryId,
ReadDate = u.LastModified,
ReadDateUtc = u.LastModifiedUtc,
ChapterId = u.ChapterId,
ChapterNumber = context.Chapter.Single(c => c.Id == u.ChapterId).MinNumber
})
.OrderByDescending(d => d.ReadDate)
.ToListAsync();
}
public async Task<IEnumerable<StatCountWithFormat<DateTime>>> ReadCountByDay(int userId = 0, int days = 0)
{
var query = context.AppUserProgresses
.AsSplitQuery()
.AsNoTracking()
.Join(context.Chapter, appUserProgresses => appUserProgresses.ChapterId, chapter => chapter.Id,
(appUserProgresses, chapter) => new {appUserProgresses, chapter})
.Join(context.Volume, x => x.chapter.VolumeId, volume => volume.Id,
(x, volume) => new {x.appUserProgresses, x.chapter, volume})
.Join(context.Series, x => x.appUserProgresses.SeriesId, series => series.Id,
(x, series) => new {x.appUserProgresses, x.chapter, x.volume, series})
.WhereIf(userId > 0, x => x.appUserProgresses.AppUserId == userId)
.WhereIf(days > 0, x => x.appUserProgresses.LastModified >= DateTime.Now.AddDays(days * -1));
var results = await query.GroupBy(x => new
{
Day = x.appUserProgresses.LastModified.Date,
x.series.Format,
})
.Select(g => new StatCountWithFormat<DateTime>
{
Value = g.Key.Day,
Format = g.Key.Format,
Count = (long) g.Sum(x =>
x.chapter.AvgHoursToRead * (x.appUserProgresses.PagesRead / (1.0f * x.chapter.Pages)))
})
.OrderBy(d => d.Value)
.ToListAsync();
if (results.Count > 0)
{
var minDay = results.Min(d => d.Value);
for (var date = minDay; date < DateTime.Now; date = date.AddDays(1))
{
var resultsForDay = results.Where(d => d.Value == date).ToList();
if (resultsForDay.Count > 0)
{
// Add in types that aren't there (there is a bug in UI library that will cause dates to get out of order)
var existingFormats = resultsForDay.Select(r => r.Format).Distinct();
foreach (var format in Enum.GetValues(typeof(MangaFormat)).Cast<MangaFormat>().Where(f => f != MangaFormat.Unknown && !existingFormats.Contains(f)))
{
results.Add(new StatCountWithFormat<DateTime>()
{
Format = format,
Value = date,
Count = 0
});
}
continue;
}
results.Add(new StatCountWithFormat<DateTime>()
{
Format = MangaFormat.Archive,
Value = date,
Count = 0
});
results.Add(new StatCountWithFormat<DateTime>()
{
Format = MangaFormat.Epub,
Value = date,
Count = 0
});
results.Add(new StatCountWithFormat<DateTime>()
{
Format = MangaFormat.Pdf,
Value = date,
Count = 0
});
results.Add(new StatCountWithFormat<DateTime>()
{
Format = MangaFormat.Image,
Value = date,
Count = 0
});
}
}
return results.OrderBy(r => r.Value);
}
public async Task<IEnumerable<StatCountWithFormat<DateTime>>> ReadCounts(StatsFilterDto filter, int userId = 0)
{
var startDate = filter.StartDate?.ToUniversalTime() ?? DateTime.MinValue;
var endDate = filter.EndDate?.ToUniversalTime() ?? DateTime.UtcNow;
var results = await context.AppUserReadingSessionActivityData
.AsNoTracking()
.Where(a => a.StartTimeUtc >= startDate && a.StartTimeUtc <= endDate)
.WhereIf(userId > 0, a => a.ReadingSession.AppUserId == userId)
.WhereIf(filter.Libraries is { Count: > 0 }, a => filter.Libraries.Contains(a.LibraryId))
.GroupBy(a => new { Day = a.StartTimeUtc.Date, a.Format })
.Select(g => new StatCountWithFormat<DateTime>
{
Value = g.Key.Day,
Format = g.Key.Format,
Count = (long)g.Sum(a =>
(double)(a.EndTimeUtc!.Value.Ticks - a.StartTimeUtc.Ticks) / TimeSpan.TicksPerHour)
})
.OrderBy(d => d.Value)
.ToListAsync();
FillMissingDaysAndFormats(results, startDate, endDate);
return results.OrderBy(r => r.Value);
}
private static void FillMissingDaysAndFormats(List<StatCountWithFormat<DateTime>> results, DateTime startDate, DateTime endDate)
{
if (results.Count == 0)
return;
var validFormats = Enum.GetValues<MangaFormat>()
.Where(f => f != MangaFormat.Unknown)
.ToArray();
var minDay = results.Min(d => d.Value);
var effectiveStart = minDay > startDate.Date ? minDay : startDate.Date;
var effectiveEnd = endDate.Date < DateTime.UtcNow.Date ? endDate.Date : DateTime.UtcNow.Date;
var existingEntries = results
.Select(r => (r.Value, r.Format))
.ToHashSet();
for (var date = effectiveStart; date <= effectiveEnd; date = date.AddDays(1))
{
foreach (var format in validFormats)
{
if (existingEntries.Contains((date, format)))
continue;
results.Add(new StatCountWithFormat<DateTime>
{
Format = format,
Value = date,
Count = 0
});
}
}
}
public async Task<IList<StatCount<DayOfWeek>>> GetDayBreakdown(int userId = 0)
{
return await context.AppUserReadingSessionActivityData
.AsNoTracking()
.WhereIf(userId > 0, a => a.ReadingSession.AppUserId == userId)
.GroupBy(a => a.StartTimeUtc.DayOfWeek)
.OrderBy(g => g.Key)
.Select(g => new StatCount<DayOfWeek>
{
Value = g.Key,
Count = g.Count()
})
.ToListAsync();
}
/// <summary>
/// Return a list of pages read per year for the given userId
/// </summary>
public async Task<IList<StatCount<int>>> GetPagesReadCountByYear(int userId = 0)
{
return await context.AppUserReadingSessionActivityData
.AsNoTracking()
.WhereIf(userId > 0, a => a.ReadingSession.AppUserId == userId)
.GroupBy(a => a.StartTimeUtc.Year)
.OrderBy(g => g.Key)
.Select(g => new StatCount<int>
{
Value = g.Key,
Count = g.Sum(a => a.PagesRead)
})
.ToListAsync();
}
/// <summary>
/// Return a list of words read per year for the given userId
/// </summary>
public async Task<IList<StatCount<int>>> GetWordsReadCountByYear(int userId = 0)
{
return await context.AppUserReadingSessionActivityData
.AsNoTracking()
.Where(a => a.WordsRead > 0)
.WhereIf(userId > 0, a => a.ReadingSession.AppUserId == userId)
.GroupBy(a => a.StartTimeUtc.Year)
.OrderBy(g => g.Key)
.Select(g => new StatCount<int>
{
Value = g.Key,
Count = g.Sum(a => a.WordsRead)
})
.ToListAsync();
}
/// <summary>
/// Updates the ServerStatistics table for the current year
/// </summary>
/// <remarks>This commits</remarks>
/// <returns></returns>
public async Task UpdateServerStatistics()
{
var year = DateTime.Today.Year;
var existingRecord = await context.ServerStatistics.SingleOrDefaultAsync(s => s.Year == year) ?? new ServerStatistics();
existingRecord.Year = year;
existingRecord.ChapterCount = await context.Chapter.CountAsync();
existingRecord.VolumeCount = await context.Volume.CountAsync();
existingRecord.FileCount = await context.MangaFile.CountAsync();
existingRecord.SeriesCount = await context.Series.CountAsync();
existingRecord.UserCount = await context.Users.CountAsync();
existingRecord.GenreCount = await context.Genre.CountAsync();
existingRecord.TagCount = await context.Tag.CountAsync();
existingRecord.PersonCount = context.Person
.AsSplitQuery()
.AsEnumerable()
.GroupBy(sm => sm.NormalizedName)
.Select(sm => sm.Key)
.Distinct()
.Count();
context.ServerStatistics.Attach(existingRecord);
if (existingRecord.Id > 0)
{
context.Entry(existingRecord).State = EntityState.Modified;
}
await unitOfWork.CommitAsync();
}
public async Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds)
{
var query = context.AppUserProgresses
.WhereIf(userIds.Any(), p => userIds.Contains(p.AppUserId))
.WhereIf(libraryIds.Any(), p => libraryIds.Contains(p.LibraryId))
.AsSplitQuery();
return (long) Math.Round(await query
.Join(context.Chapter,
p => p.ChapterId,
c => c.Id,
(progress, chapter) => new {chapter, progress})
.Where(p => p.chapter.AvgHoursToRead > 0)
.SumAsync(p =>
p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages))));
}
public async Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension)
{
var query = context.MangaFile
.Where(f => f.Extension == fileExtension)
.ProjectTo<FileExtensionExportDto>(mapper.ConfigurationProvider)
.OrderBy(f => f.FilePath);
return await query.ToListAsync();
}
public async Task<DeviceClientBreakdownDto> GetClientTypeBreakdown(DateTime fromDateUtc)
{
var devices = await context.ClientDevice
.Where(d => d.IsActive && d.FirstSeenUtc >= fromDateUtc)
.Select(d => d.CurrentClientInfo.ClientType)
.ToListAsync();
var grouped = devices
.GroupBy(clientType => clientType)
.Select(g => new StatCount<ClientDeviceType>
{
Value = g.Key,
Count = g.Count()
})
.OrderByDescending(s => s.Count)
.ToList();
return new DeviceClientBreakdownDto
{
Records = grouped,
TotalCount = devices.Count
};
}
public async Task<IList<StatCount<string>>> GetDeviceTypeCounts(DateTime fromDateUtc)
{
var devices = await context.ClientDevice
.Where(d => d.IsActive && d.FirstSeenUtc >= fromDateUtc)
.Select(d => d.CurrentClientInfo.DeviceType)
.ToListAsync();
// Define the expected device types
var knownDeviceTypes = new[] { "mobile", "desktop", "tablet" };
var grouped = devices
.Where(deviceType => !string.IsNullOrEmpty(deviceType))
.GroupBy(deviceType => deviceType!.ToLowerInvariant())
.ToDictionary(g => g.Key, g => (long)g.Count());
// Ensure all known types are present, even with 0 count
var result = knownDeviceTypes
.Select(deviceType => new StatCount<string>
{
Value = deviceType,
Count = grouped.GetValueOrDefault(deviceType, 0)
})
.OrderByDescending(s => s.Count)
.ToList();
return result;
}
public async Task<ReadingActivityGraphDto> GetReadingActivityGraphData(StatsFilterDto filter, int userId, int year,
int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var startDate = filter.StartDate?.ToUniversalTime() ?? DateTime.MinValue;
var endDate = filter.EndDate?.ToUniversalTime() ?? DateTime.UtcNow;
var sessionActivityData = await context.AppUserReadingSession
.Where(s => s.AppUserId == userId)
.Where(s => s.StartTimeUtc >= startDate && s.EndTimeUtc <= endDate)
.Where(s => s.EndTimeUtc != null)
.Join(
context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, false, true),
session => session.Id,
activity => activity.AppUserReadingSessionId,
(session, activity) => new
{
SessionDate = session.StartTimeUtc.Date,
SessionId = session.Id,
SessionStartUtc = session.StartTimeUtc,
SessionEndUtc = session.EndTimeUtc!.Value,
activity.ChapterId,
activity.PagesRead,
activity.WordsRead,
activity.TotalPages
})
.ToListAsync();
var result = new ReadingActivityGraphDto();
if (sessionActivityData.Count == 0) return result;
var dailyStats = sessionActivityData
.GroupBy(x => x.SessionDate)
.Select(dayGroup => new
{
Date = dayGroup.Key,
DateKey = dayGroup.Key.ToString("yyyy-MM-dd"),
// Sum durations across all sessions for this day
TotalTimeReadingSeconds = dayGroup
.GroupBy(x => x.SessionId)
.Sum(sessionGroup =>
(int) (sessionGroup.First().SessionEndUtc - sessionGroup.First().SessionStartUtc).TotalSeconds),
// Sum pages/words across all activities
TotalPages = dayGroup.Sum(x => x.PagesRead),
TotalWords = dayGroup.Sum(x => x.WordsRead),
// Count distinct chapters that were fully read per day
TotalChaptersFullyRead = dayGroup
.Where(x => x.PagesRead > 0 && x.TotalPages > 0 && x.PagesRead >= x.TotalPages)
.Select(x => x.ChapterId)
.Distinct()
.Count()
})
.ToList();
foreach (var stat in dailyStats)
{
result[stat.DateKey] = new ReadingActivityGraphEntryDto
{
Date = stat.Date,
TotalTimeReadingSeconds = stat.TotalTimeReadingSeconds,
TotalPages = stat.TotalPages,
TotalWords = stat.TotalWords,
TotalChaptersFullyRead = stat.TotalChaptersFullyRead
};
if (result.Count <= 0) return result;
var currentDate = startDate;
while (currentDate.Year == year)
{
var dateKey = currentDate.ToString("yyyy-MM-dd");
if (!result.ContainsKey(dateKey))
{
result[dateKey] = new ReadingActivityGraphEntryDto
{
Date = currentDate,
TotalTimeReadingSeconds = 0,
TotalPages = 0,
TotalWords = 0,
TotalChaptersFullyRead = 0
};
}
currentDate = currentDate.AddDays(1);
}
}
return result;
}
public async Task<ReadingPaceDto> GetReadingPaceForUser(StatsFilterDto filter, int userId, int year, bool booksOnly, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var firstProgress = await unitOfWork.AppUserProgressRepository.GetFirstProgressForUser(userId);
if (firstProgress == null)
{
return new ReadingPaceDto();
}
filter.StartDate ??= firstProgress;
filter.StartDate = filter.StartDate > firstProgress ? filter.StartDate : firstProgress;
filter.EndDate ??= DateTime.UtcNow;
filter.EndDate = filter.EndDate < DateTime.UtcNow ? filter.EndDate : DateTime.UtcNow;
var activities = await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true)
.Select(a => new
{
a.PagesRead,
a.WordsRead,
a.ChapterId,
a.SeriesId,
SeriesFormat = a.Series.Format,
SessionStart = a.ReadingSession.StartTimeUtc,
SessionEnd = a.ReadingSession.EndTimeUtc
})
.WhereIf(booksOnly, d => d.SeriesFormat == MangaFormat.Pdf || d.SeriesFormat == MangaFormat.Epub)
.WhereIf(!booksOnly, d => d.SeriesFormat != MangaFormat.Pdf && d.SeriesFormat != MangaFormat.Epub)
.ToListAsync();
var sessionDurations = activities
.Where(a => a.SessionEnd.HasValue)
.GroupBy(a => new { a.SessionStart, a.SessionEnd })
.Sum(g => (g.Key.SessionEnd!.Value - g.Key.SessionStart).TotalHours);
var booksRead = new HashSet<int>();
var comicsRead = new HashSet<int>();
var pagesRead = 0;
var wordsRead = 0;
foreach (var activity in activities)
{
pagesRead += activity.PagesRead;
wordsRead += activity.WordsRead;
if (activity.SeriesFormat is MangaFormat.Epub or MangaFormat.Pdf)
booksRead.Add(activity.ChapterId);
else
comicsRead.Add(activity.ChapterId);
}
var timeSpan = (filter.EndDate - filter.StartDate).Value;
var daysInRange = (int)timeSpan.TotalDays + 1;
return new ReadingPaceDto
{
HoursRead = (int)Math.Round(sessionDurations),
PagesRead = pagesRead,
WordsRead = wordsRead,
BooksRead = booksRead.Count,
ComicsRead = comicsRead.Count,
DaysInRange = daysInRange
};
}
public async Task<BreakDownDto<string>> GetGenreBreakdownForUser(StatsFilterDto filter, int userId, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var readsPerGenre = await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.GroupBy(d => d.SeriesId)
.Select(d => new
{
SeriesId = d.Key,
TotalReads = d.Count(),
})
.Join(context.SeriesMetadata, x => x.SeriesId, sm => sm.SeriesId, (x, sm) => new
{
x.SeriesId,
x.TotalReads,
SeriesMetadataId = sm.Id,
})
.Join(context.GenreSeriesMetadata, x => x.SeriesMetadataId, gsm => gsm.SeriesMetadatasId, (x, gsm) => new
{
x.SeriesId,
x.TotalReads,
gsm.GenresId,
})
.Join(context.Genre, x => x.GenresId, g => g.Id, (x, g) => new
{
x.SeriesId,
x.TotalReads,
Genre = g,
})
.GroupBy(x => new
{
x.Genre.Id,
x.Genre.Title,
})
.Select(g => new StatCount<string>
{
Value = g.Key.Title,
Count = g.Select(x => x.SeriesId).Distinct().Count(),
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var totalMissingData = await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.Select(p => p.SeriesId)
.Distinct()
.Join(context.SeriesMetadata, p => p, sm => sm.SeriesId, (g, m) => m.Genres)
.CountAsync(g => !g.Any());
var totalReads = await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.Select(p => p.SeriesId)
.Distinct()
.CountAsync();
var totalReadGenres = await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.Join(context.Chapter, p => p.ChapterId, c => c.Id, (p, c) => c.Genres)
.SelectMany(g => g.Select(gg => gg.NormalizedTitle))
.Distinct()
.CountAsync();
return new BreakDownDto<string>()
{
Data = readsPerGenre,
Missing = totalMissingData,
Total = totalReads,
TotalOptions = totalReadGenres,
};
}
public async Task<BreakDownDto<string>> GetTagBreakdownForUser(StatsFilterDto filter, int userId, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var readsPerTagTask = context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.GroupBy(d => d.SeriesId)
.Select(d => new
{
SeriesId = d.Key,
TotalReads = d.Count(),
})
.Join(context.SeriesMetadata, x => x.SeriesId, sm => sm.SeriesId, (x, sm) => new
{
x.SeriesId,
x.TotalReads,
SeriesMetadataId = sm.Id,
})
.Join(context.SeriesMetadataTag, x => x.SeriesMetadataId, smt => smt.SeriesMetadatasId, (x, smt) => new
{
x.SeriesId,
x.TotalReads,
smt.TagsId,
})
.Join(context.Tag, x => x.TagsId, t => t.Id, (x, t) => new
{
x.SeriesId,
x.TotalReads,
Tag = t,
})
.GroupBy(x => new
{
x.Tag.Id,
x.Tag.Title,
})
.Select(g => new StatCount<string>
{
Value = g.Key.Title,
Count = g.Select(x => x.SeriesId).Distinct().Count(),
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var totalMissingDataTask = context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.Select(p => p.SeriesId)
.Distinct()
.Join(context.SeriesMetadata, p => p, sm => sm.SeriesId, (g, m) => m.Tags)
.CountAsync(g => !g.Any());
var totalReadsTask = context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.Select(p => p.SeriesId)
.Distinct()
.CountAsync();
var totalReadTagsTask = context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.Join(context.Chapter, p => p.ChapterId, c => c.Id, (p, c) => c.Tags)
.SelectMany(g => g.Select(gg => gg.NormalizedTitle))
.Distinct()
.CountAsync();
await Task.WhenAll(readsPerTagTask, totalMissingDataTask, totalReadsTask, totalReadTagsTask);
return new BreakDownDto<string>()
{
Data = await readsPerTagTask,
Missing = await totalMissingDataTask,
Total = await totalReadsTask,
TotalOptions = await totalReadTagsTask,
};
}
public async Task<SpreadStatsDto> GetPageSpreadForUser(StatsFilterDto filter, int userId, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var fullyReadChapters = await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true)
.Join(
context.Chapter,
progress => progress.ChapterId,
chapter => chapter.Id,
(progress, chapter) => new { progress, chapter }
)
.Select(x => x.chapter.Pages)
.ToListAsync();
var totalCount = fullyReadChapters.Count;
var highest = fullyReadChapters.MaxOrDefault(x => x, 0);
if (highest == 0)
{
return new SpreadStatsDto()
{
Buckets = [],
TotalCount = 0
};
}
var magnitude = (int) Math.Floor(Math.Log10(highest));
var bucketSize = (int) Math.Pow(10, magnitude - 1);
var bucketCount = 8;
var buckets = Enumerable.Range(0, bucketCount).Select(i =>
{
var isLastBucket = i + 1 == bucketCount;
var start = i * bucketSize;
var end = isLastBucket ? int.MaxValue : (i + 1) * bucketSize;
var count = fullyReadChapters.Count(pages =>
pages >= start &&
(pages <= end)
);
return new StatBucketDto
{
RangeStart = start,
RangeEnd = isLastBucket ? null : end,
Count = count,
Percentage = totalCount > 0 ? (decimal)count / totalCount * 100 : 0
};
}).ToList();
return new SpreadStatsDto
{
Buckets = buckets,
TotalCount = totalCount,
};
}
public async Task<SpreadStatsDto> GetWordSpreadForUser(StatsFilterDto filter, int userId, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var wordsInFullyReadChapters = await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true)
.Join(
context.Chapter,
progress => progress.ChapterId,
chapter => chapter.Id,
(progress, chapter) => new { progress, chapter }
)
.Where(x => x.chapter.WordCount > 0)
.Select(x => x.chapter.WordCount)
.ToListAsync();
var totalCount = wordsInFullyReadChapters.Count;
var highest = wordsInFullyReadChapters.MaxOrDefault(x => x, 0);
if (highest == 0)
{
return new SpreadStatsDto()
{
Buckets = [],
TotalCount = 0
};
}
var magnitude = (int) Math.Floor(Math.Log10(highest));
var bucketSize = (int) Math.Pow(10, magnitude - 1);
var bucketCount = 8;
var buckets = Enumerable.Range(0, bucketCount)
.Select(i =>
{
var isLastBucket = i + 1 == bucketCount;
var start = i * bucketSize;
var end = isLastBucket ? int.MaxValue : (i + 1) * bucketSize;
var count = wordsInFullyReadChapters
.Count(v => v >= start && v < end);
return new StatBucketDto
{
RangeStart = start,
RangeEnd = isLastBucket ? null : end,
Count = count,
Percentage = totalCount > 0 ? (decimal)count / totalCount * 100 : 0,
};
})
.ToList();
return new SpreadStatsDto
{
Buckets = buckets,
TotalCount = totalCount,
};
}
public async Task<ReadTimeByHourDto?> GetTimeReadingByHour(StatsFilterDto filter, int userId, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var sessionRecordedSince = await unitOfWork.DataContext.ManualMigrationHistory
.FirstOrDefaultAsync(mm => mm.Name == MigrateProgressToReadingSessions.Name);
if (sessionRecordedSince == null)
{
logger.LogWarning("{Migration} never happened? Cannot compute time by hour", MigrateProgressToReadingSessions.Name);
return null;
}
var daysSinceCounting = (DateTime.UtcNow - sessionRecordedSince.RanAt).Days;
var sessions = await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true)
.Where(session => session.ReadingSession.CreatedUtc > sessionRecordedSince.RanAt)
.ToListAsync();
var hourStats = sessions
.SelectMany(session =>
{
var hours = new List<(DateOnly day, int hour, TimeSpan timeSpent)>();
var current = session.StartTime;
while (current < session.EndTime)
{
var hourEnd = current.AddHours(1);
var sessionEnd = session.EndTime ?? current;
var endOfPeriod = new[] { hourEnd, sessionEnd }.Min();
var timeSpent = endOfPeriod - current;
hours.Add((DateOnly.FromDateTime(current), current.Hour, timeSpent));
current = endOfPeriod;
}
return hours;
})
.GroupBy(x => new { x.day, x.hour })
.Select(g => new
{
g.Key.day,
g.Key.hour,
totalTimeSpent = g.Sum(x => x.timeSpent.TotalMinutes)
})
.GroupBy(x => x.hour)
.ToDictionary(
g => g.Key,
g => g.Sum(x => x.totalTimeSpent) / daysSinceCounting
);
var data = Enumerable.Range(0, 24)
.Select(hour => new StatCount<int>
{
Value = hour,
Count = (long) Math.Ceiling(hourStats.TryGetValue(hour, out var value) ? value : 0),
})
.ToList();
return new ReadTimeByHourDto
{
DataSince = sessionRecordedSince.RanAt,
Stats = data,
};
}
public async Task<ProfileStatBarDto> GetUserStatBar(StatsFilterDto filter, int userId, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var chapterData = await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true)
.Select(d => new
{
d.ChapterId,
FormatType = d.Chapter.Files.First().Format,
d.PagesRead,
d.WordsRead,
})
.ToListAsync();
// Early exit if no data
if (chapterData.Count == 0)
{
// Still need reviews/ratings - run in parallel
var (reviews, ratings) = await GetReviewsAndRatings(filter, userId, socialPreferences);
return new ProfileStatBarDto
{
Reviews = reviews,
Ratings = ratings
};
}
// Group by ChapterId to deduplicate, then aggregate
var byChapter = chapterData
.GroupBy(x => x.ChapterId)
.Select(g => new
{
ChapterId = g.Key,
g.First().FormatType,
// Take max to handle potential duplicates with different values
PagesRead = g.Max(x => x.PagesRead),
WordsRead = g.Max(x => x.WordsRead)
})
.ToList();
var chapterIds = byChapter
.Select(x => x.ChapterId)
.ToHashSet();
var booksRead = 0;
var comicsRead = 0;
var pagesRead = 0L;
var wordsRead = 0L;
foreach (var ch in byChapter)
{
pagesRead += ch.PagesRead;
wordsRead += ch.WordsRead;
switch (ch.FormatType)
{
case MangaFormat.Pdf or MangaFormat.Epub:
booksRead++;
break;
case MangaFormat.Archive or MangaFormat.Image or MangaFormat.Unknown:
comicsRead++;
break;
}
}
var authorsTask = GetAuthorsCount(chapterIds);
var reviewsRatingsTask = GetReviewsAndRatings(filter, userId, socialPreferences);
await Task.WhenAll(authorsTask, reviewsRatingsTask);
var (reviewCount, ratingCount) = await reviewsRatingsTask;
return new ProfileStatBarDto
{
BooksRead = booksRead,
ComicsRead = comicsRead,
PagesRead = (int)pagesRead,
WordsRead = (int)wordsRead,
AuthorsRead = await authorsTask,
Reviews = reviewCount,
Ratings = ratingCount
};
}
public async Task<IList<MostActiveUserDto>> GetMostActiveUsers(StatsFilterDto filter)
{
var startDate = filter.StartDate?.ToUniversalTime() ?? DateTime.MinValue;
var endDate = filter.EndDate?.ToUniversalTime() ?? DateTime.UtcNow;
// Fetch activity data for all users in the time period
var activityData = await context.AppUserReadingSessionActivityData
.Between(a => a.StartTimeUtc, startDate, endDate)
.Where(a => a.EndTimeUtc != null)
.Select(a => new
{
a.ReadingSession.AppUserId,
a.ChapterId,
a.SeriesId,
a.Chapter.Files.First().Format,
a.StartTimeUtc,
EndTimeUtc = a.EndTimeUtc!.Value
})
.ToListAsync();
if (activityData.Count == 0) return [];
// Group by user and calculate stats, take top 5 by hours
var userStats = activityData
.GroupBy(a => a.AppUserId)
.Select(userGroup =>
{
var userId = userGroup.Key;
var hoursRead = userGroup.Sum(a => (a.EndTimeUtc - a.StartTimeUtc).TotalHours);
var bookChapters = userGroup
.Where(a => a.Format is MangaFormat.Epub or MangaFormat.Pdf)
.Select(a => a.ChapterId)
.Distinct()
.Count();
var comicChapters = userGroup
.Where(a => a.Format is not MangaFormat.Epub and not MangaFormat.Pdf)
.Select(a => a.ChapterId)
.Distinct()
.Count();
var seriesIds = userGroup
.Select(a => a.SeriesId)
.Distinct()
.ToList();
return new
{
UserId = userId,
HoursRead = hoursRead,
BooksRead = bookChapters,
ComicsRead = comicChapters,
SeriesIds = seriesIds
};
})
.OrderByDescending(u => u.HoursRead)
.Take(5)
.ToList();
if (userStats.Count == 0) return [];
var userIds = userStats.Select(u => u.UserId).ToList();
// Fetch user details
var users = await context.AppUser
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, u.UserName, u.CoverImage })
.ToDictionaryAsync(u => u.Id);
// Fetch TotalReads for each user's series
var allSeriesIds = userStats
.SelectMany(u => u.SeriesIds)
.Distinct()
.ToList();
var progressData = await context.AppUserProgresses
.Where(p => userIds.Contains(p.AppUserId) && allSeriesIds.Contains(p.SeriesId))
.GroupBy(p => new { p.AppUserId, p.SeriesId })
.Select(g => new
{
g.Key.AppUserId,
g.Key.SeriesId,
MinTotalReads = g.Min(p => p.TotalReads)
})
.ToListAsync();
var progressLookup = progressData.ToLookup(p => p.AppUserId);
// Fetch series for projection
var seriesLookup = await context.Series
.Where(s => allSeriesIds.Contains(s.Id))
.ProjectTo<SeriesDto>(mapper.ConfigurationProvider)
.ToDictionaryAsync(s => s.Id);
var result = new List<MostActiveUserDto>();
foreach (var stat in userStats)
{
if (!users.TryGetValue(stat.UserId, out var user))
continue;
var topSeries = progressLookup[stat.UserId]
.Where(p => stat.SeriesIds.Contains(p.SeriesId))
.OrderByDescending(p => p.MinTotalReads)
.Take(5)
.Select(p => seriesLookup.GetValueOrDefault(p.SeriesId))
.Where(s => s != null)
.Cast<SeriesDto>()
.ToList();
result.Add(new MostActiveUserDto
{
UserId = stat.UserId,
Username = user.UserName ?? string.Empty,
CoverImage = user.CoverImage,
TimePeriodHours = (int)Math.Round(stat.HoursRead),
TotalHours = (int)Math.Round(stat.HoursRead),
TotalComics = stat.ComicsRead,
TotalBooks = stat.BooksRead,
TopSeries = topSeries
});
}
return result;
}
public async Task<IList<StatCountWithFormat<DateTime>>> GetFilesAddedOverTime()
{
var results = await context.MangaFile
.AsNoTracking()
.GroupBy(f => new { Date = f.CreatedUtc.Date, f.Format })
.Select(g => new StatCountWithFormat<DateTime>
{
Value = g.Key.Date,
Count = g.Count(),
Format = g.Key.Format
})
.OrderBy(d => d.Value)
.ToListAsync();
return results;
}
private async Task<int> GetAuthorsCount(HashSet<int> chapterIds)
{
if (chapterIds.Count == 0) return 0;
// For large sets, batch to avoid SQLite parameter limits (max ~999)
if (chapterIds.Count <= 500)
{
return await context.ChapterPeople
.Where(cp => cp.Role == PersonRole.Writer && chapterIds.Contains(cp.ChapterId))
.Select(cp => cp.PersonId)
.Distinct()
.CountAsync();
}
// Batch approach for large chapter sets
var authorIds = new HashSet<int>();
foreach (var batch in chapterIds.Chunk(500))
{
var batchSet = batch.ToHashSet();
var batchAuthors = await context.ChapterPeople
.Where(cp => cp.Role == PersonRole.Writer && batchSet.Contains(cp.ChapterId))
.Select(cp => cp.PersonId)
.ToListAsync();
foreach (var id in batchAuthors)
authorIds.Add(id);
}
return authorIds.Count;
}
private async Task<(int Reviews, int Ratings)> GetReviewsAndRatings(
StatsFilterDto filter, int userId, AppUserSocialPreferences socialPreferences)
{
var baseQuery = BuildRatingQuery(filter, userId, socialPreferences);
// Single query with conditional counting
var counts = await baseQuery
.GroupBy(r => 1)
.Select(g => new
{
Reviews = g.Count(r => r.Review != null && r.Review != ""),
Ratings = g.Count(r => r.HasBeenRated)
})
.FirstOrDefaultAsync();
return counts != null ? (counts.Reviews, counts.Ratings) : (0, 0);
}
private IQueryable<AppUserRating> BuildRatingQuery(
StatsFilterDto filter, int userId, AppUserSocialPreferences socialPreferences)
{
return context.AppUserRating
.Where(r => r.AppUserId == userId)
.WhereIf(filter.Libraries is { Count: > 0 },
r => filter.Libraries!.Contains(r.Series.LibraryId))
.WhereIf(filter.StartDate != null,
r => r.CreatedUtc >= filter.StartDate!.Value.ToUniversalTime())
.WhereIf(filter.EndDate != null,
r => r.CreatedUtc <= filter.EndDate!.Value.ToUniversalTime())
.WhereIf(socialPreferences.SocialLibraries.Count > 0,
r => socialPreferences.SocialLibraries.Contains(r.Series.LibraryId))
.WhereIf(socialPreferences.SocialMaxAgeRating != AgeRating.NotApplicable,
r => (socialPreferences.SocialMaxAgeRating >= r.Series.Metadata.AgeRating &&
r.Series.Metadata.AgeRating != AgeRating.Unknown) ||
(socialPreferences.SocialIncludeUnknowns &&
r.Series.Metadata.AgeRating == AgeRating.Unknown));
}
public async Task<IList<StatCount<YearMonthGroupingDto>>> GetReadsPerMonth(StatsFilterDto filter, int userId, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
// It makes no sense to filter this in by month etc. Trim to year
filter.StartDate = filter.StartDate.HasValue
? new DateTime(filter.StartDate.Value.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc)
: null;
filter.EndDate = filter.EndDate.HasValue
? new DateTime(filter.EndDate.Value.Year, 12, 31, 23, 59, 59, 0, 0, DateTimeKind.Utc)
: null;
return await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true)
.GroupBy(s => new {s.ReadingSession.CreatedUtc.Year, s.ReadingSession.CreatedUtc.Month})
.Select(g => new StatCount<YearMonthGroupingDto>()
{
Value = new YearMonthGroupingDto()
{
Year = g.Key.Year,
Month = g.Key.Month,
},
Count = g.Count(),
}).ToListAsync();
}
public async Task<IList<MostReadAuthorsDto>> GetMostReadAuthors(StatsFilterDto filter, int userId, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var res = await context.ChapterPeople
.Where(cp => cp.Role == PersonRole.Writer)
.Join(
context.AppUserReadingSessionActivityData.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser),
cp => cp.ChapterId,
d => d.ChapterId,
(cp, data) => new { cp.PersonId, cp.ChapterId, cp.Person.Name }
)
.GroupBy(x => new { x.PersonId, x.Name })
.Select(g => new
{
g.Key.PersonId,
AuthorName = g.Key.Name,
TotalChaptersRead = g.Select(x => x.ChapterId).Distinct().Count(),
ChapterIds = g.Select(x => x.ChapterId).OrderBy(x => EF.Functions.Random()).Take(5).ToList(),
})
.OrderByDescending(x => x.TotalChaptersRead)
.Take(5)
.ToListAsync();
var final = new List<MostReadAuthorsDto>();
foreach (var m in res)
{
var randomChapters = await context.Chapter
.Where(c => m.ChapterIds.Contains(c.Id))
.Select(c => new
{
Chapter = c,
SeriesId = c.Volume.Series.Id,
LibraryId = c.Volume.Series.LibraryId,
})
.ToListAsync();
final.Add(new MostReadAuthorsDto
{
AuthorId = m.PersonId,
AuthorName = m.AuthorName,
TotalChaptersRead = m.TotalChaptersRead,
Chapters = randomChapters.Select(x => new AuthorChapterDto
{
LibraryId = x.LibraryId,
SeriesId = x.SeriesId,
ChapterId = x.Chapter.Id,
Title = x.Chapter.TitleName, // TODO: Use that method that makes a smart title? Do we have that? Where it falls back to Chapter #3 or whatever
}).ToList(),
});
}
return final;
}
public async Task<int> GetTotalReads(int userId, int requestingUserId)
{
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var librariesForUser = await unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId);
var filter = new StatsFilterDto
{
Libraries = librariesForUser,
};
return await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true)
.CountAsync();
}
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
{
var libraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
var users = (await unitOfWork.UserRepository.GetAllUsersAsync()).ToList();
var minDate = DateTime.Now.Subtract(TimeSpan.FromDays(days));
var topUsersAndReadChapters = context.AppUserProgresses
.AsSplitQuery()
.AsEnumerable()
.GroupBy(sm => sm.AppUserId)
.Select(sm => new
{
User = context.AppUser.Single(u => u.Id == sm.Key),
Chapters = context.Chapter.Where(c => context.AppUserProgresses
.Where(u => u.AppUserId == sm.Key)
.Where(p => p.PagesRead > 0)
.Where(p => days == 0 || (p.Created >= minDate && p.LastModified >= minDate))
.Select(p => p.ChapterId)
.Distinct()
.Contains(c.Id))
})
.OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead))
.ToList();
// Need a mapping of Library to chapter ids
var chapterIdWithLibraryId = topUsersAndReadChapters
.SelectMany(u => u.Chapters
.Select(c => c.Id)).Select(d => new
{
LibraryId = context.Chapter.Where(c => c.Id == d).AsSplitQuery().Select(c => c.Volume).Select(v => v.Series).Select(s => s.LibraryId).Single(),
ChapterId = d
})
.ToList();
var chapterLibLookup = new Dictionary<int, int>();
foreach (var cl in chapterIdWithLibraryId.Where(cl => !chapterLibLookup.ContainsKey(cl.ChapterId)))
{
chapterLibLookup.Add(cl.ChapterId, cl.LibraryId);
}
var user = new Dictionary<int, Dictionary<LibraryType, float>>();
foreach (var userChapter in topUsersAndReadChapters)
{
if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, []);
var libraryTimes = user[userChapter.User.Id];
foreach (var chapter in userChapter.Chapters)
{
var library = libraries.First(l => l.Id == chapterLibLookup[chapter.Id]);
libraryTimes.TryAdd(library.Type, 0f);
var existingHours = libraryTimes[library.Type];
libraryTimes[library.Type] = existingHours + chapter.AvgHoursToRead;
}
user[userChapter.User.Id] = libraryTimes;
}
return user.Keys.Select(userId => new TopReadDto()
{
UserId = userId,
Username = users.First(u => u.Id == userId).UserName,
BooksTime = user[userId].TryGetValue(LibraryType.Book, out var bookTime) ? bookTime : 0 +
(user[userId].TryGetValue(LibraryType.LightNovel, out var bookTime2) ? bookTime2 : 0),
ComicsTime = user[userId].TryGetValue(LibraryType.Comic, out var comicTime) ? comicTime : 0,
MangaTime = user[userId].TryGetValue(LibraryType.Manga, out var mangaTime) ? mangaTime : 0,
})
.ToList();
}
}