using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Data; using API.DTOs; using API.DTOs.Statistics; using API.Entities.Enums; using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services; public interface IStatisticService { Task GetServerStatistics(); Task GetUserReadStatistics(int userId, IList libraryIds); Task>> GetYearCount(); Task>> GetTopYears(); Task>> GetPublicationCount(); Task>> GetMangaFormatCount(); Task GetFileBreakdown(); Task> GetTopUsers(int days); Task> GetReadingHistory(int userId); Task> GetHistory(); } /// /// Responsible for computing statistics for the server /// /// This performs raw queries and does not use a repository public class StatisticService : IStatisticService { private readonly DataContext _context; private readonly IMapper _mapper; private readonly IUnitOfWork _unitOfWork; public StatisticService(DataContext context, IMapper mapper, IUnitOfWork unitOfWork) { _context = context; _mapper = mapper; _unitOfWork = unitOfWork; } public async Task GetUserReadStatistics(int userId, IList libraryIds) { if (libraryIds.Count == 0) libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); // Total Pages Read var totalPagesRead = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) .Where(p => libraryIds.Contains(p.LibraryId)) .SumAsync(p => p.PagesRead); var ids = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) .Where(p => libraryIds.Contains(p.LibraryId)) .Where(p => p.PagesRead > 0) .Select(p => new {p.ChapterId, p.SeriesId}) .ToListAsync(); var chapterIds = ids.Select(id => id.ChapterId); var timeSpentReading = await _context.Chapter .Where(c => chapterIds.Contains(c.Id)) .SumAsync(c => c.AvgHoursToRead); // Maybe make this top 5 genres? But usually there are 3-5 genres that are always common... // Maybe use rating to calculate top genres? // var genres = await _context.Series // .Where(s => seriesIds.Contains(s.Id)) // .Select(s => s.Metadata) // .SelectMany(sm => sm.Genres) // //.DistinctBy(g => g.NormalizedTitle) // .ToListAsync(); // How many series of each format have you read? (Epub, Archive, etc) // Percentage of libraries read. For each library, get the total pages vs read //var allLibraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); var chaptersRead = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) .Where(p => libraryIds.Contains(p.LibraryId)) .Where(p => p.PagesRead >= _context.Chapter.Single(c => c.Id == p.ChapterId).Pages) .CountAsync(); var lastActive = await _context.AppUserProgresses .OrderByDescending(p => p.LastModified) .Select(p => p.LastModified) .FirstOrDefaultAsync(); //var return new UserReadStatistics() { TotalPagesRead = totalPagesRead, TimeSpentReading = timeSpentReading, ChaptersRead = chaptersRead, LastActive = lastActive, }; } /// /// Returns the Release Years and their count /// /// public async Task>> GetYearCount() { return await _context.SeriesMetadata .Where(sm => sm.ReleaseYear != 0) .AsSplitQuery() .GroupBy(sm => sm.ReleaseYear) .Select(sm => new StatCount { Value = sm.Key, Count = _context.SeriesMetadata.Where(sm2 => sm2.ReleaseYear == sm.Key).Distinct().Count() }) .OrderByDescending(d => d.Value) .ToListAsync(); } public async Task>> GetTopYears() { return await _context.SeriesMetadata .Where(sm => sm.ReleaseYear != 0) .AsSplitQuery() .GroupBy(sm => sm.ReleaseYear) .Select(sm => new StatCount { Value = sm.Key, Count = _context.SeriesMetadata.Where(sm2 => sm2.ReleaseYear == sm.Key).Distinct().Count() }) .OrderByDescending(d => d.Count) .Take(5) .ToListAsync(); } public async Task>> GetPublicationCount() { return await _context.SeriesMetadata .AsSplitQuery() .GroupBy(sm => sm.PublicationStatus) .Select(sm => new StatCount { Value = sm.Key, Count = _context.SeriesMetadata.Where(sm2 => sm2.PublicationStatus == sm.Key).Distinct().Count() }) .ToListAsync(); } public async Task>> GetMangaFormatCount() { return await _context.MangaFile .AsSplitQuery() .GroupBy(sm => sm.Format) .Select(mf => new StatCount { Value = mf.Key, Count = _context.MangaFile.Where(mf2 => mf2.Format == mf.Key).Distinct().Count() }) .ToListAsync(); } public async Task GetServerStatistics() { var mostActiveUsers = _context.AppUserProgresses .AsSplitQuery() .AsEnumerable() .GroupBy(sm => sm.AppUserId) .Select(sm => new StatCount { Value = _context.AppUser.Where(u => u.Id == sm.Key).ProjectTo(_mapper.ConfigurationProvider) .Single(), Count = _context.AppUserProgresses.Where(u => u.AppUserId == sm.Key).Distinct().Count() }) .OrderByDescending(d => d.Count) .Take(5); var mostActiveLibrary = _context.AppUserProgresses .AsSplitQuery() .AsEnumerable() .Where(sm => sm.LibraryId > 0) .GroupBy(sm => sm.LibraryId) .Select(sm => new StatCount { Value = _context.Library.Where(u => u.Id == sm.Key).ProjectTo(_mapper.ConfigurationProvider) .Single(), Count = _context.AppUserProgresses.Where(u => u.LibraryId == sm.Key).Distinct().Count() }) .OrderByDescending(d => d.Count) .Take(5); var mostPopularSeries = _context.AppUserProgresses .AsSplitQuery() .AsEnumerable() .GroupBy(sm => sm.SeriesId) .Select(sm => new StatCount { Value = _context.Series.Where(u => u.Id == sm.Key).ProjectTo(_mapper.ConfigurationProvider) .Single(), Count = _context.AppUserProgresses.Where(u => u.SeriesId == sm.Key).Distinct().Count() }) .OrderByDescending(d => d.Count) .Take(5); var mostReadSeries = _context.AppUserProgresses .AsSplitQuery() .AsEnumerable() .GroupBy(sm => sm.SeriesId) .Select(sm => new StatCount { Value = _context.Series.Where(u => u.Id == sm.Key).ProjectTo(_mapper.ConfigurationProvider) .Single(), Count = _context.AppUserProgresses.Where(u => u.SeriesId == sm.Key).AsEnumerable().DistinctBy(p => p.AppUserId).Count() }) .OrderByDescending(d => d.Count) .Take(5); var seriesIds = (await _context.AppUserProgresses .AsSplitQuery() .OrderByDescending(d => d.LastModified) .Select(d => d.SeriesId) .ToListAsync()) .Distinct() .Take(5); var recentlyRead = _context.Series .AsSplitQuery() .Where(s => seriesIds.Contains(s.Id)) .ProjectTo(_mapper.ConfigurationProvider) .AsEnumerable(); var distinctPeople = _context.Person .AsSplitQuery() .AsEnumerable() .GroupBy(sm => sm.NormalizedName) .Select(sm => sm.Key) .Distinct() .Count(); return new ServerStatistics() { ChapterCount = await _context.Chapter.CountAsync(), SeriesCount = await _context.Series.CountAsync(), TotalFiles = await _context.MangaFile.CountAsync(), TotalGenres = await _context.Genre.CountAsync(), TotalPeople = distinctPeople, TotalSize = await _context.MangaFile.SumAsync(m => m.Bytes), TotalTags = await _context.Tag.CountAsync(), VolumeCount = await _context.Volume.Where(v => v.Number != 0).CountAsync(), MostActiveUsers = mostActiveUsers, MostActiveLibraries = mostActiveLibrary, MostPopularSeries = mostPopularSeries, MostReadSeries = mostReadSeries, RecentlyRead = recentlyRead }; } public async Task 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() }) .ToListAsync(), TotalFileSize = await _context.MangaFile .AsNoTracking() .AsSplitQuery() .SumAsync(f => f.Bytes) }; } public async Task> 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(u => u.Id == userId).UserName, SeriesName = _context.Series.Single(s => s.Id == u.SeriesId).Name, SeriesId = u.SeriesId, LibraryId = u.LibraryId, ReadDate = u.LastModified, ChapterId = u.ChapterId, ChapterNumber = _context.Chapter.Single(c => c.Id == u.ChapterId).Number }) .OrderByDescending(d => d.ReadDate) .ToListAsync(); } public void ReadCountByDay() { // _context.AppUserProgresses // .GroupBy(p => p.LastModified.Day) // .Select(g => // { // Day = g.Key, // }) } public Task> GetHistory() { // _context.AppUserProgresses // .AsSplitQuery() // .AsEnumerable() // .GroupBy(sm => sm.LastModified) // .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) // .Select(p => p.ChapterId) // .Distinct() // .Contains(c.Id)) // }) // .OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead)) // .Take(5) // .ToList(); var firstOfWeek = DateTime.Now.StartOfWeek(DayOfWeek.Monday); var groupedReadingDays = _context.AppUserProgresses .Where(x => x.LastModified >= firstOfWeek) .GroupBy(x => x.LastModified.Day) .Select(g => new StatCount() { Value = g.Key, Count = _context.AppUserProgresses.Where(p => p.LastModified.Day == g.Key).Select(p => p.ChapterId).Distinct().Count() }) .AsEnumerable(); // var records = firstOfWeek.Range(7) // .GroupJoin(groupedReadingDays, wd => wd.Day, lg => lg.Key, (_, lg) => lg.Any() ? lg.First().Count() : 0).ToArray(); return Task.FromResult>(null); } public async Task> 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)) .Take(5) .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(); foreach (var cl in chapterIdWithLibraryId) { if (chapterLibLookup.ContainsKey(cl.ChapterId)) continue; chapterLibLookup.Add(cl.ChapterId, cl.LibraryId); } var user = new Dictionary>(); foreach (var userChapter in topUsersAndReadChapters) { if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, new Dictionary()); var libraryTimes = user[userChapter.User.Id]; foreach (var chapter in userChapter.Chapters) { var library = libraries.First(l => l.Id == chapterLibLookup[chapter.Id]); if (!libraryTimes.ContainsKey(library.Type)) libraryTimes.Add(library.Type, 0L); var existingHours = libraryTimes[library.Type]; libraryTimes[library.Type] = existingHours + chapter.AvgHoursToRead; } user[userChapter.User.Id] = libraryTimes; } var ret = new List(); foreach (var userId in user.Keys) { ret.Add(new TopReadDto() { UserId = userId, Username = users.First(u => u.Id == userId).UserName, BooksTime = user[userId].ContainsKey(LibraryType.Book) ? user[userId][LibraryType.Book] : 0, ComicsTime = user[userId].ContainsKey(LibraryType.Comic) ? user[userId][LibraryType.Comic] : 0, MangaTime = user[userId].ContainsKey(LibraryType.Manga) ? user[userId][LibraryType.Manga] : 0, }); } return ret; } }