Basic Stats (#1673)

* Refactored ResponseCache profiles into consts

* Refactored code to use an extension method for getting user library ids.

* Started server statistics, added a charting library, and added a table sort column (not finished)

* Refactored code and have a fully working example of sortable headers. Still doesn't work with default sorting state, will work on that later.

* Implemented file size, but it's too expensive, so commented out.

* Added a migration to provide extension and length/size information in the DB to allow for faster stat apis.

* Added the ability to force a library scan from library settings.

* Refactored some apis to provide more of a file breakdown rather than just file size.

* Working on visualization of file breakdown

* Fixed the file breakdown visual

* Fixed up 2 visualizations

* Added back an api for member names, started work on top reads

* Hooked up the other library types and username/days.

* Preparing to remove top reads and refactor into Top users

* Added LibraryId to AppUserProgress to help with complex lookups.

* Added the new libraryId hook into some stats methods

* Updated api methods to use libraryId for progress

* More places where LibraryId is needed

* Added some high level server stats

* Got a ton done on server stats

* Updated default theme (dark) to be the default root variables. This will allow user themes to override just what they want, rather than maintain their own css variables.

* Implemented a monster query for top users by reading time. It's very slow and can be cleaned up likely.

* Hooked up top reads. Code needs a big refactor. Handing off for Robbie treatment and I'll switch to User stats.

* Implemented last 5 recently read series (broken) and added some basic css

* Fixed recently read query

* Cleanup the css a bit, Robbie we need you

* More css love

* Cleaned up DTOs that aren't needed anymore

* Fixed top readers query

* When calculating top readers, don't include read events where nothing is read (0 pages)

* Hooked up the date into GetTopUsers

* Hooked top readers up with days and refactored and cleaned up componets not used

* Fixed up query

* Started on a day by day breakdown, but going to take a break from stats.

* Added a temp task to run some migration manually for stats to work

* Ensure OPDS-PS uses new libraryId for progress reporting

* Fixed a code smell

* Adding some styling

* adding more styles

* Removed some debug stuff from user stats

* Bump qs from 6.5.2 to 6.5.3 in /UI/Web

Bumps [qs](https://github.com/ljharb/qs) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.5.2...v6.5.3)

---
updated-dependencies:
- dependency-name: qs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Tweaked some code for bad data cases

* Refactored a chapter lookup to remove un-needed Volume join in 5 places across the code.

* API push

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2022-12-07 08:01:49 -06:00 committed by GitHub
parent 4724dc5a76
commit c361e66b35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
106 changed files with 6898 additions and 170 deletions

View File

@ -0,0 +1,17 @@
namespace API.Constants;
public static class ResponseCacheProfiles
{
public const string Images = "Images";
public const string Hour = "Hour";
public const string TenMinute = "10Minute";
public const string FiveMinute = "5Minute";
/// <summary>
/// 6 hour long cache as underlying API is expensive
/// </summary>
public const string Statistics = "Statistics";
/// <summary>
/// Instant is a very quick cache, because we can't bust based on the query params, but rather body
/// </summary>
public const string Instant = "Instant";
}

View File

@ -1,5 +1,6 @@
using System.IO;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities.Enums;
using API.Extensions;
@ -31,7 +32,7 @@ public class ImageController : BaseApiController
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-cover")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
public async Task<ActionResult> GetChapterCoverImage(int chapterId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId));
@ -47,7 +48,7 @@ public class ImageController : BaseApiController
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpGet("library-cover")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
public async Task<ActionResult> GetLibraryCoverImage(int libraryId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.LibraryRepository.GetLibraryCoverImageAsync(libraryId));
@ -63,7 +64,7 @@ public class ImageController : BaseApiController
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("volume-cover")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
public async Task<ActionResult> GetVolumeCoverImage(int volumeId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId));
@ -78,7 +79,7 @@ public class ImageController : BaseApiController
/// </summary>
/// <param name="seriesId">Id of Series</param>
/// <returns></returns>
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
[HttpGet("series-cover")]
public async Task<ActionResult> GetSeriesCoverImage(int seriesId)
{
@ -97,7 +98,7 @@ public class ImageController : BaseApiController
/// <param name="collectionTagId"></param>
/// <returns></returns>
[HttpGet("collection-cover")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
public async Task<ActionResult> GetCollectionCoverImage(int collectionTagId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
@ -113,7 +114,7 @@ public class ImageController : BaseApiController
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("readinglist-cover")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
public async Task<ActionResult> GetReadingListCoverImage(int readingListId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId));
@ -132,7 +133,7 @@ public class ImageController : BaseApiController
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
/// <returns></returns>
[HttpGet("bookmark")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -154,7 +155,7 @@ public class ImageController : BaseApiController
/// <returns></returns>
[Authorize(Policy="RequireAdminRole")]
[HttpGet("cover-upload")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
public ActionResult GetCoverUploadImage(string filename)
{
if (filename.Contains("..")) return BadRequest("Invalid Filename");

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs;
using API.DTOs.Filtering;
@ -84,7 +85,7 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
/// <returns></returns>
[ResponseCache(CacheProfileName = "5Minute", VaryByQueryKeys = new [] {"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})]
[HttpGet("age-ratings")]
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
{
@ -107,7 +108,7 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all publication status</param>
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
/// <returns></returns>
[ResponseCache(CacheProfileName = "5Minute", VaryByQueryKeys = new [] {"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})]
[HttpGet("publication-status")]
public ActionResult<IList<AgeRatingDto>> GetAllPublicationStatus(string? libraryIds)
{

View File

@ -787,7 +787,7 @@ public class OpdsController : BaseApiController
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
// We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
accLink,
CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey)
CreatePageStreamLink(series.LibraryId,seriesId, volumeId, chapterId, mangaFile, apiKey)
},
Content = new FeedEntryContent()
{
@ -800,7 +800,7 @@ public class OpdsController : BaseApiController
}
[HttpGet("{apiKey}/image")]
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
{
if (pageNumber < 0) return BadRequest("Page cannot be less than 0");
var chapter = await _cacheService.Ensure(chapterId);
@ -823,7 +823,8 @@ public class OpdsController : BaseApiController
ChapterId = chapterId,
PageNum = pageNumber,
SeriesId = seriesId,
VolumeId = volumeId
VolumeId = volumeId,
LibraryId =libraryId
}, await GetUser(apiKey));
return File(content, "image/" + format);
@ -866,9 +867,9 @@ public class OpdsController : BaseApiController
throw new KavitaException("User does not exist");
}
private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
private static FeedLink CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
{
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
link.TotalPages = mangaFile.Pages;
return link;
}

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
@ -56,7 +57,7 @@ public class ReaderController : BaseApiController
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("pdf")]
[ResponseCache(CacheProfileName = "Hour")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
public async Task<ActionResult> GetPdf(int chapterId)
{
var chapter = await _cacheService.Ensure(chapterId);
@ -90,7 +91,7 @@ public class ReaderController : BaseApiController
/// <param name="page"></param>
/// <returns></returns>
[HttpGet("image")]
[ResponseCache(CacheProfileName = "Hour")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
[AllowAnonymous]
public async Task<ActionResult> GetImage(int chapterId, int page)
{
@ -122,7 +123,7 @@ public class ReaderController : BaseApiController
/// <remarks>We must use api key as bookmarks could be leaked to other users via the API</remarks>
/// <returns></returns>
[HttpGet("bookmark-image")]
[ResponseCache(CacheProfileName = "Hour")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
[AllowAnonymous]
public async Task<ActionResult> GetBookmarkImage(int seriesId, string apiKey, int page)
{

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
@ -383,7 +384,7 @@ public class SeriesController : BaseApiController
/// <param name="seriesId"></param>
/// <returns></returns>
/// <remarks>Do not rely on this API externally. May change without hesitation. </remarks>
[ResponseCache(CacheProfileName = "5Minute", VaryByQueryKeys = new [] {"seriesId"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"seriesId"})]
[HttpGet("series-detail")]
public async Task<ActionResult<SeriesDetailDto>> GetSeriesDetailBreakdown(int seriesId)
{

View File

@ -35,10 +35,11 @@ public class ServerController : BaseApiController
private readonly ICleanupService _cleanupService;
private readonly IEmailService _emailService;
private readonly IBookmarkService _bookmarkService;
private readonly IScannerService _scannerService;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger,
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService)
ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService, IScannerService scannerService)
{
_applicationLifetime = applicationLifetime;
_logger = logger;
@ -49,6 +50,7 @@ public class ServerController : BaseApiController
_cleanupService = cleanupService;
_emailService = emailService;
_bookmarkService = bookmarkService;
_scannerService = scannerService;
}
/// <summary>
@ -85,7 +87,7 @@ public class ServerController : BaseApiController
public ActionResult CleanupWantToRead()
{
_logger.LogInformation("{UserName} is clearing running want to read cleanup from admin dashboard", User.GetUsername());
RecurringJob.TriggerJob(API.Services.TaskScheduler.RemoveFromWantToReadTaskId);
RecurringJob.TriggerJob(TaskScheduler.RemoveFromWantToReadTaskId);
return Ok();
}
@ -98,7 +100,23 @@ public class ServerController : BaseApiController
public ActionResult BackupDatabase()
{
_logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername());
RecurringJob.TriggerJob(API.Services.TaskScheduler.BackupTaskId);
RecurringJob.TriggerJob(TaskScheduler.BackupTaskId);
return Ok();
}
/// <summary>
/// This is a one time task that needs to be ran for v0.7 statistics to work
/// </summary>
/// <returns></returns>
[HttpPost("analyze-files")]
public ActionResult AnalyzeFiles()
{
_logger.LogInformation("{UserName} is performing file analysis from admin dashboard", User.GetUsername());
if (TaskScheduler.HasAlreadyEnqueuedTask(ScannerService.Name, "AnalyzeFiles",
Array.Empty<object>(), TaskScheduler.DefaultQueue, true))
return Ok("Job already running");
BackgroundJob.Enqueue(() => _scannerService.AnalyzeFiles());
return Ok();
}

View File

@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Statistics;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
public class StatsController : BaseApiController
{
private readonly IStatisticService _statService;
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<AppUser> _userManager;
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, UserManager<AppUser> userManager)
{
_statService = statService;
_unitOfWork = unitOfWork;
_userManager = userManager;
}
[HttpGet("user/{userId}/read")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<UserReadStatistics>> GetUserReadStatistics(int userId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Id != userId && !await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
return Unauthorized("You are not authorized to view another user's statistics");
return Ok(await _statService.GetUserReadStatistics(userId, new List<int>()));
}
[Authorize("RequireAdminRole")]
[HttpGet("server/stats")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<ServerStatistics>> GetHighLevelStats()
{
return Ok(await _statService.GetServerStatistics());
}
[Authorize("RequireAdminRole")]
[HttpGet("server/count/year")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetYearStatistics()
{
return Ok(await _statService.GetYearCount());
}
[Authorize("RequireAdminRole")]
[HttpGet("server/count/publication-status")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<PublicationStatus>>>> GetPublicationStatus()
{
return Ok(await _statService.GetPublicationCount());
}
[Authorize("RequireAdminRole")]
[HttpGet("server/count/manga-format")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<MangaFormat>>>> GetMangaFormat()
{
return Ok(await _statService.GetMangaFormatCount());
}
[Authorize("RequireAdminRole")]
[HttpGet("server/top/years")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetTopYears()
{
return Ok(await _statService.GetTopYears());
}
/// <summary>
/// Returns
/// </summary>
/// <param name="days"></param>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("server/top/users")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<TopReadDto>>> GetTopReads(int days = 0)
{
return Ok(await _statService.GetTopUsers(days));
}
[Authorize("RequireAdminRole")]
[HttpGet("server/file-breakdown")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<FileExtensionBreakdownDto>>> GetFileSize()
{
return Ok(await _statService.GetFileBreakdown());
}
[HttpGet("user/reading-history")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)
{
// TODO: Put a check in if the calling user is said userId or has admin
return Ok(await _statService.GetReadingHistory(userId));
}
}

View File

@ -60,7 +60,7 @@ public class UsersController : BaseApiController
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None);
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId));
}
@ -115,6 +115,10 @@ public class UsersController : BaseApiController
return BadRequest("There was an issue saving preferences.");
}
/// <summary>
/// Returns the preferences of the user
/// </summary>
/// <returns></returns>
[HttpGet("get-preferences")]
public async Task<ActionResult<UserPreferencesDto>> GetPreferences()
{
@ -122,4 +126,15 @@ public class UsersController : BaseApiController
await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername()));
}
/// <summary>
/// Returns a list of the user names within the system
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("names")]
public async Task<ActionResult<IEnumerable<string>>> GetUserNames()
{
return Ok((await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(u => u.UserName));
}
}

View File

@ -12,6 +12,8 @@ public class ProgressDto
public int PageNum { get; set; }
[Required]
public int SeriesId { get; set; }
[Required]
public int LibraryId { get; set; }
/// <summary>
/// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position
/// on pages that combine multiple "chapters".

View File

@ -0,0 +1,7 @@
namespace API.DTOs.Statistics;
public class StatCount<T> : ICount<T>
{
public T Value { get; set; }
public int Count { get; set; }
}

View File

@ -0,0 +1,22 @@
using System.Collections.Generic;
using API.Entities.Enums;
namespace API.DTOs.Statistics;
public class FileExtensionDto
{
public string Extension { get; set; }
public MangaFormat Format { get; set; }
public long TotalSize { get; set; }
public long TotalFiles { get; set; }
}
public class FileExtensionBreakdownDto
{
/// <summary>
/// Total bytes for all files
/// </summary>
public long TotalFileSize { get; set; }
public IList<FileExtensionDto> FileBreakdown { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace API.DTOs.Statistics;
public interface ICount<T>
{
public T Value { get; set; }
public int Count { get; set; }
}

View File

@ -0,0 +1,18 @@
using System;
namespace API.DTOs.Statistics;
/// <summary>
/// Represents a single User's reading event
/// </summary>
public class ReadHistoryEvent
{
public int UserId { get; set; }
public string UserName { get; set; }
public int LibraryId { get; set; }
public int SeriesId { get; set; }
public string SeriesName { get; set; }
public DateTime ReadDate { get; set; }
public int ChapterId { get; set; }
public string ChapterNumber { get; set; }
}

View File

@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
namespace API.DTOs.Statistics;
public class ServerStatistics
{
public long ChapterCount { get; set; }
public long VolumeCount { get; set; }
public long SeriesCount { get; set; }
public long TotalFiles { get; set; }
public long TotalSize { get; set; }
public long TotalGenres { get; set; }
public long TotalTags { get; set; }
public long TotalPeople { get; set; }
public IEnumerable<ICount<SeriesDto>> MostReadSeries { get; set; }
/// <summary>
/// Total users who have started/reading/read per series
/// </summary>
public IEnumerable<ICount<SeriesDto>> MostPopularSeries { get; set; }
public IEnumerable<ICount<UserDto>> MostActiveUsers { get; set; }
public IEnumerable<ICount<LibraryDto>> MostActiveLibraries { get; set; }
/// <summary>
/// Last 5 Series read
/// </summary>
public IEnumerable<SeriesDto> RecentlyRead { get; set; }
}

View File

@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace API.DTOs.Statistics;
public class TopReadDto
{
public int UserId { get; set; }
public string Username { get; set; }
/// <summary>
/// Amount of time read on Comic libraries
/// </summary>
public long ComicsTime { get; set; }
/// <summary>
/// Amount of time read on
/// </summary>
public long BooksTime { get; set; }
public long MangaTime { get; set; }
}

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace API.DTOs.Statistics;
public class UserReadStatistics
{
/// <summary>
/// Total number of pages read
/// </summary>
public long TotalPagesRead { get; set; }
/// <summary>
/// Total time spent reading based on estimates
/// </summary>
public long TimeSpentReading { get; set; }
/// <summary>
/// A list of genres mapped with genre and number of series that fall into said genre
/// </summary>
public ICollection<Tuple<string, long>> FavoriteGenres { get; set; }
public long ChaptersRead { get; set; }
public DateTime LastActive { get; set; }
public long AvgHoursPerWeekSpentReading { get; set; }
}

View File

@ -20,7 +20,7 @@ public static class MigrateChangePasswordRoles
var usersWithRole = await userManager.GetUsersInRoleAsync(PolicyConstants.ChangePasswordRole);
if (usersWithRole.Count != 0) return;
var allUsers = await unitOfWork.UserRepository.GetAllUsers();
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync();
foreach (var user in allUsers)
{
await userManager.RemoveFromRoleAsync(user, "ChangePassword");

View File

@ -24,7 +24,7 @@ public static class MigrateChangeRestrictionRoles
logger.LogCritical("Running MigrateChangeRestrictionRoles migration");
var allUsers = await unitOfWork.UserRepository.GetAllUsers();
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync();
foreach (var user in allUsers)
{
await userManager.RemoveFromRoleAsync(user, PolicyConstants.ChangeRestrictionRole);

View File

@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using API.Entities.Enums;
using API.Entities.Metadata;
using CsvHelper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data;
/// <summary>
/// Introduced in v0.6.1.8 and v0.7, this adds library ids to all User Progress to allow for easier queries against progress
/// </summary>
public static class MigrateUserProgressLibraryId
{
public static async Task Migrate(IUnitOfWork unitOfWork, ILogger<Program> logger)
{
logger.LogCritical("Running MigrateUserProgressLibraryId migration - Please be patient, this may take some time. This is not an error");
var progress = await unitOfWork.AppUserProgressRepository.GetAnyProgress();
if (progress == null || progress.LibraryId != 0)
{
logger.LogCritical("Running MigrateUserProgressLibraryId migration - complete. Nothing to do");
return;
}
var seriesIdsWithLibraryIds = await unitOfWork.SeriesRepository.GetLibraryIdsForSeriesAsync();
foreach (var prog in await unitOfWork.AppUserProgressRepository.GetAllProgress())
{
prog.LibraryId = seriesIdsWithLibraryIds[prog.SeriesId];
unitOfWork.AppUserProgressRepository.Update(prog);
}
await unitOfWork.CommitAsync();
logger.LogCritical("Running MigrateSeriesRelationsImport migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class FileLengthAndExtension : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "Bytes",
table: "MangaFile",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<string>(
name: "Extension",
table: "MangaFile",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Bytes",
table: "MangaFile");
migrationBuilder.DropColumn(
name: "Extension",
table: "MangaFile");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class UserProgressLibraryId : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "LibraryId",
table: "AppUserProgresses",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LibraryId",
table: "AppUserProgresses");
}
}
}

View File

@ -280,6 +280,9 @@ namespace API.Data.Migrations
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
@ -588,12 +591,18 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Bytes")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Extension")
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.HasColumnType("TEXT");

View File

@ -14,7 +14,13 @@ public interface IAppUserProgressRepository
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
Task<bool> HasAnyProgressOnSeriesAsync(int seriesId, int userId);
/// <summary>
/// This is built exclusively for <see cref="MigrateUserProgressLibraryId"/>
/// </summary>
/// <returns></returns>
Task<AppUserProgress> GetAnyProgress();
Task<IEnumerable<AppUserProgress>> GetUserProgressForSeriesAsync(int seriesId, int userId);
Task<IEnumerable<AppUserProgress>> GetAllProgress();
}
public class AppUserProgressRepository : IAppUserProgressRepository
@ -85,6 +91,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository
.AnyAsync(aup => aup.PagesRead > 0 && aup.AppUserId == userId && aup.SeriesId == seriesId);
}
public async Task<AppUserProgress> GetAnyProgress()
{
return await _context.AppUserProgresses.FirstOrDefaultAsync();
}
/// <summary>
/// This will return any user progress. This filters out progress rows that have no pages read.
/// </summary>
@ -98,6 +109,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository
.ToListAsync();
}
public async Task<IEnumerable<AppUserProgress>> GetAllProgress()
{
return await _context.AppUserProgresses.ToListAsync();
}
public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
{
return await _context.AppUserProgresses

View File

@ -1,20 +1,29 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.Entities;
using API.Extensions;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
[Flags]
public enum ChapterIncludes
{
None = 1,
Volumes = 2,
}
public interface IChapterRepository
{
void Update(Chapter chapter);
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds);
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None);
Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId);
Task<int> GetChapterTotalPagesAsync(int chapterId);
Task<Chapter> GetChapterAsync(int chapterId);
@ -43,11 +52,11 @@ public class ChapterRepository : IChapterRepository
_context.Entry(chapter).State = EntityState.Modified;
}
public async Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds)
public async Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes)
{
return await _context.Chapter
.Where(c => chapterIds.Contains(c.Id))
.Include(c => c.Volume)
.Includes(includes)
.AsSplitQuery()
.ToListAsync();
}

View File

@ -1,4 +1,7 @@
using API.Entities;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using AutoMapper;
using Microsoft.EntityFrameworkCore;
@ -7,6 +10,8 @@ namespace API.Data.Repositories;
public interface IMangaFileRepository
{
void Update(MangaFile file);
Task<bool> AnyMissingExtension();
Task<IList<MangaFile>> GetAllWithMissingExtension();
}
public class MangaFileRepository : IMangaFileRepository
@ -24,4 +29,16 @@ public class MangaFileRepository : IMangaFileRepository
{
_context.Entry(file).State = EntityState.Modified;
}
public async Task<bool> AnyMissingExtension()
{
return (await _context.MangaFile.CountAsync(f => string.IsNullOrEmpty(f.Extension))) > 0;
}
public async Task<IList<MangaFile>> GetAllWithMissingExtension()
{
return await _context.MangaFile
.Where(f => string.IsNullOrEmpty(f.Extension))
.ToListAsync();
}
}

View File

@ -119,6 +119,11 @@ public interface ISeriesRepository
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
/// <summary>
/// This is only used for <see cref="MigrateUserProgressLibraryId"/>
/// </summary>
/// <returns></returns>
Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
}
public class SeriesRepository : ISeriesRepository
@ -283,14 +288,7 @@ public class SeriesRepository : ISeriesRepository
{
if (libraryId == 0)
{
return await _context.Library
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(user => user.Id == userId))
.IsRestricted(queryContext)
.AsNoTracking()
.AsSplitQuery()
.Select(library => library.Id)
.ToListAsync();
return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync();
}
return new List<int>()
@ -513,6 +511,21 @@ public class SeriesRepository : ISeriesRepository
return seriesChapters;
}
public async Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync()
{
var seriesChapters = new Dictionary<int, int>();
var series = await _context.Series.Select(s => new
{
Id = s.Id, LibraryId = s.LibraryId
}).ToListAsync();
foreach (var s in series)
{
seriesChapters.Add(s.Id, s.LibraryId);
}
return seriesChapters;
}
public async Task AddSeriesModifiers(int userId, List<SeriesDto> series)
{
var userProgress = await _context.AppUserProgresses
@ -672,7 +685,8 @@ public class SeriesRepository : ISeriesRepository
var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30);
var cutoffLastAddedPoint = DateTime.Now - TimeSpan.FromDays(7);
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard);
var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Dashboard)
.Where(id => libraryId == 0 || id == libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
@ -1046,7 +1060,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind)
{
var libraryIds = GetLibraryIdsForUser(userId);
var libraryIds = _context.Library.GetUserLibraries(userId);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var usersSeriesIds = _context.Series
@ -1073,7 +1087,8 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended)
.Where(id => libraryId == 0 || id == libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
@ -1100,7 +1115,8 @@ public class SeriesRepository : ISeriesRepository
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended)
.Where(id => libraryId == 0 || id == libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
.Where(s => usersSeriesIds.Contains(s.SeriesId))
@ -1119,7 +1135,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId)
{
var libraryIds = GetLibraryIdsForUser(userId, 0, QueryContext.Search);
var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Search);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.MangaFile
@ -1136,7 +1152,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<SeriesDto> GetSeriesForChapter(int chapterId, int userId)
{
var libraryIds = GetLibraryIdsForUser(userId);
var libraryIds = _context.Library.GetUserLibraries(userId);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Chapter
.Where(m => m.Id == chapterId)
@ -1278,7 +1294,8 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended)
.Where(id => libraryId == 0 || id == libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithHighRating = _context.AppUserRating
.Where(s => usersSeriesIds.Contains(s.SeriesId) && s.Rating > 4)
@ -1299,7 +1316,8 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetQuickReads(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended)
.Where(id => libraryId == 0 || id == libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
.Where(s => usersSeriesIds.Contains(s.SeriesId))
@ -1325,7 +1343,8 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended);
var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended)
.Where(id => libraryId == 0 || id == libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
.Where(s => usersSeriesIds.Contains(s.SeriesId))
@ -1350,37 +1369,9 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
/// <summary>
/// Returns all library ids for a user
/// </summary>
/// <param name="userId"></param>
/// <param name="libraryId">0 for no library filter</param>
/// <param name="queryContext">Defaults to None - The context behind this query, so appropriate restrictions can be placed</param>
/// <returns></returns>
private IQueryable<int> GetLibraryIdsForUser(int userId, int libraryId = 0, QueryContext queryContext = QueryContext.None)
{
var user = _context.AppUser
.AsSplitQuery()
.AsNoTracking()
.Where(u => u.Id == userId)
.AsSingleQuery();
if (libraryId == 0)
{
return user.SelectMany(l => l.Libraries)
.IsRestricted(queryContext)
.Select(lib => lib.Id);
}
return user.SelectMany(l => l.Libraries)
.Where(lib => lib.Id == libraryId)
.IsRestricted(queryContext)
.Select(lib => lib.Id);
}
public async Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId)
{
var libraryIds = GetLibraryIdsForUser(userId);
var libraryIds = _context.Library.GetUserLibraries(userId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
@ -1486,7 +1477,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
{
var libraryIds = GetLibraryIdsForUser(userId);
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
var query = _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
@ -1501,8 +1492,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<bool> IsSeriesInWantToRead(int userId, int seriesId)
{
// BUG: This is always returning true for any series
var libraryIds = GetLibraryIdsForUser(userId);
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
return await _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead.Where(s => s.Id == seriesId && libraryIds.Contains(s.LibraryId)))

View File

@ -59,10 +59,9 @@ public interface IUserRepository
Task<int> GetUserIdByUsernameAsync(string username);
Task<IList<AppUserBookmark>> GetAllBookmarksByIds(IList<int> bookmarkIds);
Task<AppUser> GetUserByEmailAsync(string email);
Task<IEnumerable<AppUser>> GetAllUsers();
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
Task<bool> HasAccessToLibrary(int libraryId, int userId);
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags);
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None);
Task<AppUser> GetUserByConfirmationToken(string token);
}
@ -241,11 +240,6 @@ public class UserRepository : IUserRepository
return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(lowerEmail));
}
public async Task<IEnumerable<AppUser>> GetAllUsers()
{
return await _context.AppUser
.ToListAsync();
}
public async Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId)
{
@ -264,7 +258,7 @@ public class UserRepository : IUserRepository
.AnyAsync(library => library.AppUsers.Any(user => user.Id == userId));
}
public async Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags)
public async Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None)
{
var query = AddIncludesToQuery(_context.Users.AsQueryable(), includeFlags);
return await query.ToListAsync();

View File

@ -7,7 +7,6 @@ namespace API.Entities;
/// <summary>
/// Represents the progress a single user has on a given Chapter.
/// </summary>
//[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), nameof(AppUserId), IsUnique = true)]
public class AppUserProgress : IEntityDate
{
/// <summary>
@ -27,6 +26,10 @@ public class AppUserProgress : IEntityDate
/// </summary>
public int SeriesId { get; set; }
/// <summary>
/// Library belonging to Chapter
/// </summary>
public int LibraryId { get; set; }
/// <summary>
/// Chapter
/// </summary>
public int ChapterId { get; set; }

View File

@ -20,8 +20,9 @@ public enum MangaFormat
[Description("Archive")]
Archive = 1,
/// <summary>
/// Unknown. Not used.
/// Unknown
/// </summary>
/// <remarks>Default state for all files, but at end of processing, will never be Unknown.</remarks>
[Description("Unknown")]
Unknown = 2,
/// <summary>

View File

@ -21,9 +21,16 @@ public class MangaFile : IEntityDate
/// </summary>
public int Pages { get; set; }
public MangaFormat Format { get; set; }
/// <summary>
/// How many bytes make up this file
/// </summary>
public long Bytes { get; set; }
/// <summary>
/// File extension
/// </summary>
public string Extension { get; set; }
/// <inheritdoc cref="IEntityDate.Created"/>
public DateTime Created { get; set; }
/// <summary>
/// Last time underlying file was modified
/// </summary>

View File

@ -48,6 +48,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IProcessSeries, ProcessSeries>();
services.AddScoped<IReadingListService, ReadingListService>();
services.AddScoped<IDeviceService, DeviceService>();
services.AddScoped<IStatisticService, StatisticService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>();

View File

@ -15,4 +15,10 @@ public static class DateTimeExtensions
{
return new DateTime(date.Ticks - (date.Ticks % resolution), date.Kind);
}
public static DateTime StartOfWeek(this DateTime dt, DayOfWeek startOfWeek)
{
int diff = (7 + (dt.DayOfWeek - startOfWeek)) % 7;
return dt.AddDays(-1 * diff).Date;
}
}

View File

@ -1,4 +1,6 @@
using System.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data.Misc;
using API.Data.Repositories;
@ -123,6 +125,17 @@ public static class QueryableExtensions
return queryable.AsSplitQuery();
}
public static IQueryable<Chapter> Includes(this IQueryable<Chapter> queryable,
ChapterIncludes includes)
{
if (includes.HasFlag(ChapterIncludes.Volumes))
{
queryable = queryable.Include(v => v.Volume);
}
return queryable.AsSplitQuery();
}
public static IQueryable<Series> Includes(this IQueryable<Series> query,
SeriesIncludes includeFlags)
{
@ -186,4 +199,25 @@ public static class QueryableExtensions
return query;
}
/// <summary>
/// Returns all libraries for a given user
/// </summary>
/// <param name="library"></param>
/// <param name="userId"></param>
/// <param name="queryContext"></param>
/// <returns></returns>
public static IQueryable<int> GetUserLibraries(this IQueryable<Library> library, int userId, QueryContext queryContext = QueryContext.None)
{
return library
.Include(l => l.AppUsers)
.Where(lib => lib.AppUsers.Any(user => user.Id == userId))
.IsRestricted(queryContext)
.AsNoTracking()
.AsSplitQuery()
.Select(lib => lib.Id);
}
public static IEnumerable<DateTime> Range(this DateTime startDate, int numberOfDays) =>
Enumerable.Range(0, numberOfDays).Select(e => startDate.AddDays(e));
}

View File

@ -103,6 +103,7 @@ public class ReaderService : IReaderService
public async Task MarkChaptersAsRead(AppUser user, int seriesId, IList<Chapter> chapters)
{
var seenVolume = new Dictionary<int, bool>();
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
foreach (var chapter in chapters)
{
var userProgress = GetUserProgressForChapter(user, chapter);
@ -114,7 +115,8 @@ public class ReaderService : IReaderService
PagesRead = chapter.Pages,
VolumeId = chapter.VolumeId,
SeriesId = seriesId,
ChapterId = chapter.Id
ChapterId = chapter.Id,
LibraryId = series.LibraryId
});
}
else
@ -239,6 +241,7 @@ public class ReaderService : IReaderService
VolumeId = progressDto.VolumeId,
SeriesId = progressDto.SeriesId,
ChapterId = progressDto.ChapterId,
LibraryId = progressDto.LibraryId,
BookScrollId = progressDto.BookScrollId,
LastModified = DateTime.Now
});
@ -249,6 +252,7 @@ public class ReaderService : IReaderService
userProgress.PagesRead = progressDto.PageNum;
userProgress.SeriesId = progressDto.SeriesId;
userProgress.VolumeId = progressDto.VolumeId;
userProgress.LibraryId = progressDto.LibraryId;
userProgress.BookScrollId = progressDto.BookScrollId;
userProgress.LastModified = DateTime.Now;
_unitOfWork.AppUserProgressRepository.Update(userProgress);

View File

@ -195,7 +195,7 @@ public class ReadingListService : IReadingListService
}
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds))
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes))
.OrderBy(c => Tasks.Scanner.Parser.Parser.MinNumberFromRange(c.Volume.Name))
.ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting)
.ToList();

View File

@ -0,0 +1,417 @@
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<ServerStatistics> GetServerStatistics();
Task<UserReadStatistics> GetUserReadStatistics(int userId, IList<int> libraryIds);
Task<IEnumerable<StatCount<int>>> GetYearCount();
Task<IEnumerable<StatCount<int>>> GetTopYears();
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<ReadHistoryEvent>> GetHistory();
}
/// <summary>
/// Responsible for computing statistics for the server
/// </summary>
/// <remarks>This performs raw queries and does not use a repository</remarks>
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<UserReadStatistics> GetUserReadStatistics(int userId, IList<int> 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,
};
}
/// <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<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<ServerStatistics> GetServerStatistics()
{
var mostActiveUsers = _context.AppUserProgresses
.AsSplitQuery()
.AsEnumerable()
.GroupBy(sm => sm.AppUserId)
.Select(sm => new StatCount<UserDto>
{
Value = _context.AppUser.Where(u => u.Id == sm.Key).ProjectTo<UserDto>(_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<LibraryDto>
{
Value = _context.Library.Where(u => u.Id == sm.Key).ProjectTo<LibraryDto>(_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<SeriesDto>
{
Value = _context.Series.Where(u => u.Id == sm.Key).ProjectTo<SeriesDto>(_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<SeriesDto>
{
Value = _context.Series.Where(u => u.Id == sm.Key).ProjectTo<SeriesDto>(_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<SeriesDto>(_mapper.ConfigurationProvider)
.AsEnumerable();
return new ServerStatistics()
{
ChapterCount = await _context.Chapter.CountAsync(),
SeriesCount = await _context.Series.CountAsync(),
TotalFiles = await _context.MangaFile.CountAsync(),
TotalGenres = await _context.Genre.CountAsync(),
TotalPeople = await _context.Person.CountAsync(),
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<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()
})
.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(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 Task<IEnumerable<ReadHistoryEvent>> 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<int>()
{
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<IEnumerable<ReadHistoryEvent>>(null);
}
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))
.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<int, int>();
foreach (var cl in chapterIdWithLibraryId)
{
if (chapterLibLookup.ContainsKey(cl.ChapterId)) continue;
chapterLibLookup.Add(cl.ChapterId, cl.LibraryId);
}
var user = new Dictionary<int, Dictionary<LibraryType, long>>();
foreach (var userChapter in topUsersAndReadChapters)
{
if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, new Dictionary<LibraryType, long>());
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<TopReadDto>();
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;
}
}

View File

@ -27,7 +27,7 @@ public interface IProcessSeries
/// </summary>
/// <returns></returns>
Task Prime();
Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library);
Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, bool forceUpdate = false);
void EnqueuePostSeriesProcessTasks(int libraryId, int seriesId, bool forceUpdate = false);
}
@ -75,7 +75,7 @@ public class ProcessSeries : IProcessSeries
_tags = await _unitOfWork.TagRepository.GetAllTagsAsync();
}
public async Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library)
public async Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, bool forceUpdate = false)
{
if (!parsedInfos.Any()) return;
@ -120,7 +120,7 @@ public class ProcessSeries : IProcessSeries
// parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort)
var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo);
UpdateVolumes(series, parsedInfos);
UpdateVolumes(series, parsedInfos, forceUpdate);
series.Pages = series.Volumes.Sum(v => v.Pages);
series.NormalizedName = Parser.Parser.Normalize(series.Name);
@ -430,7 +430,7 @@ public class ProcessSeries : IProcessSeries
});
}
private void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos)
private void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
{
var startingVolumeCount = series.Volumes.Count;
// Add new volumes and update chapters per volume
@ -465,7 +465,7 @@ public class ProcessSeries : IProcessSeries
_logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name);
var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray();
UpdateChapters(series, volume, infos);
UpdateChapters(series, volume, infos, forceUpdate);
volume.Pages = volume.Chapters.Sum(c => c.Pages);
// Update all the metadata on the Chapters
@ -512,7 +512,7 @@ public class ProcessSeries : IProcessSeries
series.Name, startingVolumeCount, series.Volumes.Count);
}
private void UpdateChapters(Series series, Volume volume, IList<ParserInfo> parsedInfos)
private void UpdateChapters(Series series, Volume volume, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
{
// Add new chapters
foreach (var info in parsedInfos)
@ -546,7 +546,7 @@ public class ProcessSeries : IProcessSeries
if (chapter == null) continue;
// Add files
var specialTreatment = info.IsSpecialInfo();
AddOrUpdateFileForChapter(chapter, info);
AddOrUpdateFileForChapter(chapter, info, forceUpdate);
chapter.Number = Parser.Parser.MinNumberFromRange(info.Chapters) + string.Empty;
chapter.Range = specialTreatment ? info.Filename : info.Chapters;
}
@ -572,22 +572,26 @@ public class ProcessSeries : IProcessSeries
}
}
private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info)
private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false)
{
chapter.Files ??= new List<MangaFile>();
var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath);
var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(info.FullFilePath);
if (existingFile != null)
{
existingFile.Format = info.Format;
if (!_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return;
if (!forceUpdate && !_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return;
existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format);
existingFile.Extension = fileInfo.Extension.ToLowerInvariant();
existingFile.Bytes = fileInfo.Length;
// We skip updating DB here with last modified time so that metadata refresh can do it
}
else
{
var file = DbFactory.MangaFile(info.FullFilePath, info.Format, _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format));
if (file == null) return;
file.Extension = fileInfo.Extension.ToLowerInvariant();
file.Bytes = fileInfo.Length;
chapter.Files.Add(file);
}
}

View File

@ -42,6 +42,7 @@ public interface IScannerService
Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true);
Task ScanFolder(string folder);
Task AnalyzeFiles();
}
@ -97,6 +98,35 @@ public class ScannerService : IScannerService
_wordCountAnalyzerService = wordCountAnalyzerService;
}
/// <summary>
/// This is only used for v0.7 to get files analyzed
/// </summary>
public async Task AnalyzeFiles()
{
_logger.LogInformation("Starting Analyze Files task");
var missingExtensions = await _unitOfWork.MangaFileRepository.GetAllWithMissingExtension();
if (missingExtensions.Count == 0)
{
_logger.LogInformation("Nothing to do");
return;
}
var sw = Stopwatch.StartNew();
foreach (var file in missingExtensions)
{
var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(file.FilePath);
if (!fileInfo.Exists)continue;
file.Extension = fileInfo.Extension.ToLowerInvariant();
file.Bytes = fileInfo.Length;
_unitOfWork.MangaFileRepository.Update(file);
}
await _unitOfWork.CommitAsync();
_logger.LogInformation("Completed Analyze Files task in {ElapsedTime}", sw.Elapsed);
}
/// <summary>
/// Given a generic folder path, will invoke a Series scan or Library scan.
/// </summary>
@ -483,7 +513,7 @@ public class ScannerService : IScannerService
seenSeries.Add(foundParsedSeries);
processTasks.Add(async () => await _processSeries.ProcessSeriesAsync(parsedFiles, library));
processTasks.Add(async () => await _processSeries.ProcessSeriesAsync(parsedFiles, library, forceUpdate));
return Task.CompletedTask;
}

View File

@ -121,7 +121,7 @@ public class StatsService : IStatsService
NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(),
NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(),
OPDSEnabled = serverSettings.EnableOpds,
NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsers()).Count(),
NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(),
TotalFiles = await _unitOfWork.LibraryRepository.GetTotalFiles(),
TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(),
TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(),

View File

@ -7,6 +7,7 @@ using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities;
using API.Entities.Enums;
@ -59,35 +60,40 @@ public class Startup
services.AddControllers(options =>
{
options.CacheProfiles.Add("Images",
options.CacheProfiles.Add(ResponseCacheProfiles.Images,
new CacheProfile()
{
Duration = 60,
Location = ResponseCacheLocation.None,
NoStore = false
});
options.CacheProfiles.Add("Hour",
options.CacheProfiles.Add(ResponseCacheProfiles.Hour,
new CacheProfile()
{
Duration = 60 * 60,
Location = ResponseCacheLocation.None,
NoStore = false
});
options.CacheProfiles.Add("10Minute",
options.CacheProfiles.Add(ResponseCacheProfiles.TenMinute,
new CacheProfile()
{
Duration = 60 * 10,
Location = ResponseCacheLocation.None,
NoStore = false
});
options.CacheProfiles.Add("5Minute",
options.CacheProfiles.Add(ResponseCacheProfiles.FiveMinute,
new CacheProfile()
{
Duration = 60 * 5,
Location = ResponseCacheLocation.None,
});
// Instant is a very quick cache, because we can't bust based on the query params, but rather body
options.CacheProfiles.Add("Instant",
options.CacheProfiles.Add(ResponseCacheProfiles.Statistics,
new CacheProfile()
{
Duration = 60 * 60 * 6,
Location = ResponseCacheLocation.None,
});
options.CacheProfiles.Add(ResponseCacheProfiles.Instant,
new CacheProfile()
{
Duration = 30,
@ -217,6 +223,9 @@ public class Startup
// v0.6.2 or v0.7
await MigrateSeriesRelationsImport.Migrate(dataContext, logger);
// v0.6.8 or v0.7
await MigrateUserProgressLibraryId.Migrate(unitOfWork, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
installVersion.Value = BuildInfo.Version.ToString();

Binary file not shown.

413
UI/Web/package-lock.json generated
View File

@ -6188,6 +6188,26 @@
"@sinonjs/commons": "^1.7.0"
}
},
"@swimlane/ngx-charts": {
"version": "20.1.0",
"resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-20.1.0.tgz",
"integrity": "sha512-PY/X+eW+ZEvF3N1kuUVV5H3NHoFXlIWOvNnCKAs874yye//ttgfL/Qf9haHQpki5WIHQtpwn8xM1ylVEQT98bg==",
"requires": {
"@types/d3-shape": "^2.0.0",
"d3-array": "^2.9.1",
"d3-brush": "^2.1.0",
"d3-color": "^2.0.0",
"d3-format": "^2.0.0",
"d3-hierarchy": "^2.0.0",
"d3-interpolate": "^2.0.1",
"d3-scale": "^3.2.3",
"d3-selection": "^2.0.0",
"d3-shape": "^2.0.0",
"d3-time-format": "^3.0.0",
"d3-transition": "^2.0.0",
"tslib": "^2.0.0"
}
},
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@ -6298,6 +6318,257 @@
"@types/node": "*"
}
},
"@types/d3": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.0.tgz",
"integrity": "sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==",
"dev": true,
"requires": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"@types/d3-array": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
"dev": true
},
"@types/d3-axis": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.1.tgz",
"integrity": "sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw==",
"dev": true,
"requires": {
"@types/d3-selection": "*"
}
},
"@types/d3-brush": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.1.tgz",
"integrity": "sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw==",
"dev": true,
"requires": {
"@types/d3-selection": "*"
}
},
"@types/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw==",
"dev": true
},
"@types/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
"dev": true
},
"@types/d3-contour": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.1.tgz",
"integrity": "sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==",
"dev": true,
"requires": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"@types/d3-delaunay": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
"dev": true
},
"@types/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw==",
"dev": true
},
"@types/d3-drag": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.1.tgz",
"integrity": "sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==",
"dev": true,
"requires": {
"@types/d3-selection": "*"
}
},
"@types/d3-dsv": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.0.tgz",
"integrity": "sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==",
"dev": true
},
"@types/d3-ease": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.0.tgz",
"integrity": "sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==",
"dev": true
},
"@types/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw==",
"dev": true,
"requires": {
"@types/d3-dsv": "*"
}
},
"@types/d3-force": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.3.tgz",
"integrity": "sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA==",
"dev": true
},
"@types/d3-format": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
"dev": true
},
"@types/d3-geo": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.0.2.tgz",
"integrity": "sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==",
"dev": true,
"requires": {
"@types/geojson": "*"
}
},
"@types/d3-hierarchy": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.0.tgz",
"integrity": "sha512-g+sey7qrCa3UbsQlMZZBOHROkFqx7KZKvUpRzI/tAp/8erZWpYq7FgNKvYwebi2LaEiVs1klhUfd3WCThxmmWQ==",
"dev": true
},
"@types/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
"dev": true,
"requires": {
"@types/d3-color": "*"
}
},
"@types/d3-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.2.tgz",
"integrity": "sha512-3YHpvDw9LzONaJzejXLOwZ3LqwwkoXb9LI2YN7Hbd6pkGo5nIlJ09ul4bQhBN4hQZJKmUpX8HkVqbzgUKY48cg=="
},
"@types/d3-polygon": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.0.tgz",
"integrity": "sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==",
"dev": true
},
"@types/d3-quadtree": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz",
"integrity": "sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==",
"dev": true
},
"@types/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==",
"dev": true
},
"@types/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
"dev": true,
"requires": {
"@types/d3-time": "*"
}
},
"@types/d3-scale-chromatic": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz",
"integrity": "sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==",
"dev": true
},
"@types/d3-selection": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.3.tgz",
"integrity": "sha512-Mw5cf6nlW1MlefpD9zrshZ+DAWL4IQ5LnWfRheW6xwsdaWOb6IRRu2H7XPAQcyXEx1D7XQWgdoKR83ui1/HlEA==",
"dev": true
},
"@types/d3-shape": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.1.3.tgz",
"integrity": "sha512-HAhCel3wP93kh4/rq+7atLdybcESZ5bRHDEZUojClyZWsRuEMo3A52NGYJSh48SxfxEU6RZIVbZL2YFZ2OAlzQ==",
"requires": {
"@types/d3-path": "^2"
}
},
"@types/d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
"dev": true
},
"@types/d3-time-format": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.0.tgz",
"integrity": "sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==",
"dev": true
},
"@types/d3-timer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz",
"integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==",
"dev": true
},
"@types/d3-transition": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.2.tgz",
"integrity": "sha512-jo5o/Rf+/u6uerJ/963Dc39NI16FQzqwOc54bwvksGAdVfvDrqDpVeq95bEvPtBwLCVZutAEyAtmSyEMxN7vxQ==",
"dev": true,
"requires": {
"@types/d3-selection": "*"
}
},
"@types/d3-zoom": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.1.tgz",
"integrity": "sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ==",
"dev": true,
"requires": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"@types/eslint": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz",
@ -6347,6 +6618,12 @@
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ=="
},
"@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"@types/graceful-fs": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
@ -8197,6 +8474,131 @@
}
}
},
"d3-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
"requires": {
"internmap": "^1.0.0"
}
},
"d3-brush": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-2.1.0.tgz",
"integrity": "sha512-cHLLAFatBATyIKqZOkk/mDHUbzne2B3ZwxkzMHvFTCZCmLaXDpZRihQSn8UNXTkGD/3lb/W2sQz0etAftmHMJQ==",
"requires": {
"d3-dispatch": "1 - 2",
"d3-drag": "2",
"d3-interpolate": "1 - 2",
"d3-selection": "2",
"d3-transition": "2"
}
},
"d3-color": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ=="
},
"d3-dispatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA=="
},
"d3-drag": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
"requires": {
"d3-dispatch": "1 - 2",
"d3-selection": "2"
}
},
"d3-ease": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ=="
},
"d3-format": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz",
"integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA=="
},
"d3-hierarchy": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-2.0.0.tgz",
"integrity": "sha512-SwIdqM3HxQX2214EG9GTjgmCc/mbSx4mQBn+DuEETubhOw6/U3fmnji4uCVrmzOydMHSO1nZle5gh6HB/wdOzw=="
},
"d3-interpolate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
"requires": {
"d3-color": "1 - 2"
}
},
"d3-path": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz",
"integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA=="
},
"d3-scale": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz",
"integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==",
"requires": {
"d3-array": "^2.3.0",
"d3-format": "1 - 2",
"d3-interpolate": "1.2.0 - 2",
"d3-time": "^2.1.1",
"d3-time-format": "2 - 3"
}
},
"d3-selection": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA=="
},
"d3-shape": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz",
"integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==",
"requires": {
"d3-path": "1 - 2"
}
},
"d3-time": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
"integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
"requires": {
"d3-array": "2"
}
},
"d3-time-format": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
"integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
"requires": {
"d3-time": "1 - 2"
}
},
"d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA=="
},
"d3-transition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
"requires": {
"d3-color": "1 - 2",
"d3-dispatch": "1 - 2",
"d3-ease": "1 - 2",
"d3-interpolate": "1 - 2",
"d3-timer": "1 - 2"
}
},
"damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -9820,6 +10222,11 @@
}
}
},
"internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="
},
"ip": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
@ -14591,9 +14998,9 @@
},
"dependencies": {
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
"integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
"dev": true
}
}

View File

@ -31,6 +31,7 @@
"@microsoft/signalr": "^6.0.2",
"@ng-bootstrap/ng-bootstrap": "^13.0.0",
"@popperjs/core": "^2.11.2",
"@swimlane/ngx-charts": "^20.1.0",
"@types/file-saver": "^2.0.5",
"bootstrap": "^5.2.0",
"bowser": "^2.11.0",
@ -54,6 +55,7 @@
"@angular/cli": "^14.1.1",
"@angular/compiler-cli": "^14.1.1",
"@playwright/test": "^1.23.2",
"@types/d3": "^7.4.0",
"@types/jest": "^27.4.0",
"@types/node": "^17.0.17",
"codelyzer": "^6.0.2",

View File

@ -245,8 +245,8 @@ export class ActionService implements OnDestroy {
* @param chapter Chapter, should have id, pages, volumeId populated
* @param callback Optional callback to perform actions after API completes
*/
markChapterAsRead(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
markChapterAsRead(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
chapter.pagesRead = chapter.pages;
this.toastr.success('Marked as Read');
if (callback) {
@ -261,8 +261,8 @@ export class ActionService implements OnDestroy {
* @param chapter Chapter, should have id, pages, volumeId populated
* @param callback Optional callback to perform actions after API completes
*/
markChapterAsUnread(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => {
markChapterAsUnread(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => {
chapter.pagesRead = 0;
this.toastr.success('Marked as Unread');
if (callback) {

View File

@ -106,8 +106,8 @@ export class ReaderService {
return this.httpClient.get<ChapterInfo>(this.baseUrl + 'reader/chapter-info?chapterId=' + chapterId);
}
saveProgress(seriesId: number, volumeId: number, chapterId: number, page: number, bookScrollId: string | null = null) {
return this.httpClient.post(this.baseUrl + 'reader/progress', {seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
saveProgress(libraryId: number, seriesId: number, volumeId: number, chapterId: number, page: number, bookScrollId: string | null = null) {
return this.httpClient.post(this.baseUrl + 'reader/progress', {libraryId, seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
}
markVolumeRead(seriesId: number, volumeId: number) {

View File

@ -34,6 +34,10 @@ export class ServerService {
return this.httpClient.post(this.baseUrl + 'server/backup-db', {});
}
analyzeFiles() {
return this.httpClient.post(this.baseUrl + 'server/analyze-files', {});
}
checkForUpdate() {
return this.httpClient.get<UpdateVersionEvent>(this.baseUrl + 'server/check-update', {});
}

View File

@ -0,0 +1,84 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
import { PublicationStatusPipe } from '../pipe/publication-status.pipe';
import { map } from 'rxjs';
import { MangaFormatPipe } from '../pipe/manga-format.pipe';
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown';
import { TopUserRead } from '../statistics/_models/top-reads';
import { ReadHistoryEvent } from '../statistics/_models/read-history-event';
import { ServerStatistics } from '../statistics/_models/server-statistics';
import { StatCount } from '../statistics/_models/stat-count';
import { PublicationStatus } from '../_models/metadata/publication-status';
import { MangaFormat } from '../_models/manga-format';
const publicationStatusPipe = new PublicationStatusPipe();
const mangaFormatPipe = new MangaFormatPipe();
@Injectable({
providedIn: 'root'
})
export class StatisticsService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
// TODO: Convert to httpParams object
let url = 'stats/user/' + userId + '/read';
if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(',');
return this.httpClient.get<UserReadStatistics>(this.baseUrl + url);
}
getServerStatistics() {
return this.httpClient.get<ServerStatistics>(this.baseUrl + 'stats/server/stats');
}
getYearRange() {
return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/count/year').pipe(
map(spreads => spreads.map(spread => {
return {name: spread.value + '', value: spread.count};
})));
}
getTopYears() {
return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/top/years').pipe(
map(spreads => spreads.map(spread => {
return {name: spread.value + '', value: spread.count};
})));
}
getTopUsers(days: number = 0) {
return this.httpClient.get<TopUserRead[]>(this.baseUrl + 'stats/server/top/users?days=' + days);
}
getReadingHistory(userId: number) {
return this.httpClient.get<ReadHistoryEvent[]>(this.baseUrl + 'stats/user/reading-history?userId=' + userId);
}
getPublicationStatus() {
return this.httpClient.get<StatCount<PublicationStatus>[]>(this.baseUrl + 'stats/server/count/publication-status').pipe(
map(spreads => spreads.map(spread => {
return {name: publicationStatusPipe.transform(spread.value), value: spread.count};
})));
}
getMangaFormat() {
return this.httpClient.get<StatCount<MangaFormat>[]>(this.baseUrl + 'stats/server/count/manga-format').pipe(
map(spreads => spreads.map(spread => {
return {name: mangaFormatPipe.transform(spread.value), value: spread.count};
})));
}
getTotalSize() {
return this.httpClient.get<number>(this.baseUrl + 'stats/server/file-size', { responseType: 'text' as 'json'});
}
getFileBreakdown() {
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
}
}

View File

@ -0,0 +1,30 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
export const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);
export type SortColumn<T> = keyof T | '';
export type SortDirection = 'asc' | 'desc' | '';
const rotate: { [key: string]: SortDirection } = { asc: 'desc', desc: 'asc', '': 'asc' };
export interface SortEvent<T> {
column: SortColumn<T>;
direction: SortDirection;
}
@Directive({
selector: 'th[sortable]',
host: {
'[class.asc]': 'direction === "asc"',
'[class.desc]': 'direction === "desc"',
'(click)': 'rotate()',
},
})
export class SortableHeader<T> {
@Input() sortable: SortColumn<T> = '';
@Input() direction: SortDirection = '';
@Output() sort = new EventEmitter<SortEvent<T>>();
rotate() {
this.direction = rotate[this.direction];
this.sort.emit({ column: this.sortable, direction: this.direction });
}
}

View File

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SortableHeader } from './_directives/sortable-header.directive';
@NgModule({
declarations: [
SortableHeader
],
imports: [
CommonModule
],
exports: [
SortableHeader
]
})
export class TableModule { }

View File

@ -24,6 +24,7 @@ import { ManageEmailSettingsComponent } from './manage-email-settings/manage-ema
import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tasks-settings.component';
import { ManageLogsComponent } from './manage-logs/manage-logs.component';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { StatisticsModule } from '../statistics/statistics.module';
@ -60,7 +61,9 @@ import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
PipeModule,
SidenavModule,
UserSettingsModule, // API-key componet
VirtualScrollerModule
VirtualScrollerModule,
StatisticsModule
],
providers: []
})

View File

@ -29,6 +29,9 @@
<ng-container *ngIf="tab.fragment === TabID.System">
<app-manage-system></app-manage-system>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Statistics">
<app-server-stats></app-server-stats>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Tasks">
<app-manage-tasks-settings></app-manage-tasks-settings>
</ng-container>

View File

@ -14,7 +14,9 @@ enum TabID {
System = 'system',
Plugins = 'plugins',
Tasks = 'tasks',
Logs = 'logs'
Logs = 'logs',
Statistics = 'statistics',
}
@Component({
@ -33,6 +35,7 @@ export class DashboardComponent implements OnInit {
{title: 'Email', fragment: TabID.Email},
//{title: 'Plugins', fragment: TabID.Plugins},
{title: 'Tasks', fragment: TabID.Tasks},
{title: 'Statistics', fragment: TabID.Statistics},
{title: 'System', fragment: TabID.System},
];
counter = this.tabs.length + 1;

View File

@ -63,6 +63,12 @@ export class ManageTasksSettingsComponent implements OnInit {
api: defer(() => of(this.downloadService.download('logs', undefined))),
successMessage: ''
},
{
name: 'Analyze Files',
description: 'Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release.',
api: this.serverService.analyzeFiles(),
successMessage: 'File analysis has been queued'
},
{
name: 'Check for Updates',
description: 'See if there are any Stable releases ahead of your version',

View File

@ -448,7 +448,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
if (!this.incognitoMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
}
}

View File

@ -194,7 +194,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
return;
}
this.actionService.markChapterAsRead(this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
markChapterAsUnread(chapter: Chapter) {
@ -202,7 +202,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
return;
}
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {

View File

@ -1234,7 +1234,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
if (!this.incognitoMode && !this.bookmarkMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */});
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
}
@ -1382,7 +1382,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
window.history.replaceState({}, '', newRoute);
this.toastr.info('Incognito mode is off. Progress will now start being tracked.');
if (!this.bookmarkMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
}

View File

@ -197,7 +197,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
saveProgress() {
if (this.incognitoMode) return;
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.currentPage).subscribe(() => {});
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.currentPage).subscribe(() => {});
}
closeReader() {

View File

@ -0,0 +1,42 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'bytes'
})
export class BytesPipe implements PipeTransform {
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
*
* @return Formatted string.
*
* Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
*/
transform(bytes: number, si=true, dp=0): string {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + ' B';
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10**dp;
do {
bytes /= thresh;
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
return bytes.toFixed(dp) + ' ' + units[u];
}
}

View File

@ -15,6 +15,7 @@ import { MangaFormatIconPipe } from './manga-format-icon.pipe';
import { LibraryTypePipe } from './library-type.pipe';
import { SafeStylePipe } from './safe-style.pipe';
import { DefaultDatePipe } from './default-date.pipe';
import { BytesPipe } from './bytes.pipe';
@ -35,6 +36,7 @@ import { DefaultDatePipe } from './default-date.pipe';
LibraryTypePipe,
SafeStylePipe,
DefaultDatePipe,
BytesPipe,
],
imports: [
CommonModule,
@ -55,6 +57,7 @@ import { DefaultDatePipe } from './default-date.pipe';
LibraryTypePipe,
SafeStylePipe,
DefaultDatePipe,
BytesPipe
]
})
export class PipeModule { }

View File

@ -639,7 +639,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
return;
}
this.actionService.markChapterAsRead(this.seriesId, chapter, () => {
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, () => {
this.setContinuePoint();
});
}
@ -649,7 +649,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
return;
}
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => {
this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, chapter, () => {
this.setContinuePoint();
});
}

View File

@ -1,4 +1,4 @@
import { HttpClient, HttpErrorResponse, HttpEventType } from '@angular/common/http';
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Series } from 'src/app/_models/series';
import { environment } from 'src/environments/environment';
@ -12,9 +12,12 @@ import { download, Download } from '../_models/download';
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
import { switchMap, takeWhile, throttleTime } from 'rxjs/operators';
import { AccountService } from 'src/app/_services/account.service';
import { BytesPipe } from 'src/app/pipe/bytes.pipe';
export const DEBOUNCE_TIME = 100;
const bytesPipe = new BytesPipe();
export interface DownloadEvent {
/**
* Type of entity being downloaded
@ -235,7 +238,7 @@ export class DownloadService {
}
private async confirmSize(size: number, entityType: DownloadEntityType) {
return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + this.humanFileSize(size) + '. Are you sure you want to continue?'));
return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + bytesPipe.transform(size) + '. Are you sure you want to continue?'));
}
private downloadBookmarks(bookmarks: PageBookmark[]) {
@ -253,38 +256,4 @@ export class DownloadService {
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
*
* @return Formatted string.
*
* Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
*/
private humanFileSize(bytes: number, si=true, dp=0) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + ' B';
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10**dp;
do {
bytes /= thresh;
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
return bytes.toFixed(dp) + ' ' + units[u];
}
}

View File

@ -150,6 +150,7 @@
</div>
</form>
<div class="modal-footer">
<button type="button" class="btn btn-light" (click)="forceScan()" position="above" ngbTooltip="This will force a scan on the library, treating like a fresh scan">Force Scan</button>
<button type="button" class="btn btn-light" (click)="reset()">Reset</button>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>

View File

@ -135,6 +135,10 @@ export class LibrarySettingsModalComponent implements OnInit, OnDestroy {
this.modal.close(returnVal);
}
forceScan() {
this.libraryService.scan(this.library.id, true).subscribe(() => this.toastr.info('A forced scan has been started for ' + this.library.name));
}
async save() {
const model = this.libraryForm.value;
model.folders = this.selectedFolders;

View File

@ -0,0 +1,71 @@
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Format</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-file-breakdown-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-file-breakdown-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
</div>
<ng-template #tooltip>Non Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings.</ng-template>
<ng-container *ngIf="files$ | async as files">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
Extension
</th>
<th scope="col" sortable="format" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="totalSize" (sort)="onSort($event)">
Total Size
</th>
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
Total Files
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of files; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.extension || 'Not Classified'}}
</td>
<td>
{{item.format | mangaFormat}}
</td>
<td>
{{item.totalSize | bytes}}
</td>
<td>
{{item.totalFiles | number:'1.0-0'}}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total File Size:</td>
<td></td>
<td></td>
<td>{{((rawData$ | async)?.totalFileSize || 0) | bytes}}</td>
</tr>
</tfoot>
</table>
</ng-template>
</ng-container>

View File

@ -0,0 +1,4 @@
::ng-deep .advanced-pie-legend {
top: unset !important;
transform: unset !important;
}

View File

@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, Component, OnInit, QueryList, ViewChildren } from '@angular/core';
import { FormControl } from '@angular/forms';
import { LegendPosition } from '@swimlane/ngx-charts';
import { Observable, Subject, BehaviorSubject, combineLatest, map, takeUntil, shareReplay } from 'rxjs';
import { MangaFormatPipe } from 'src/app/pipe/manga-format.pipe';
import { MangaFormat } from 'src/app/_models/manga-format';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { SortableHeader, SortEvent, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { FileExtension, FileExtensionBreakdown } from '../../_models/file-breakdown';
import { PieDataItem } from '../../_models/pie-data-item';
export interface StackedBarChartDataItem {
name: string,
series: Array<PieDataItem>;
}
const mangaFormatPipe = new MangaFormatPipe();
@Component({
selector: 'app-file-breakdown-stats',
templateUrl: './file-breakdown-stats.component.html',
styleUrls: ['./file-breakdown-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FileBreakdownStatsComponent implements OnInit {
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
rawData$!: Observable<FileExtensionBreakdown>;
files$!: Observable<Array<FileExtension>>;
vizData$!: Observable<Array<StackedBarChartDataItem>>;
vizData2$!: Observable<Array<PieDataItem>>;
private readonly onDestroy = new Subject<void>();
currentSort = new BehaviorSubject<SortEvent<FileExtension>>({column: 'extension', direction: 'asc'});
currentSort$: Observable<SortEvent<FileExtension>> = this.currentSort.asObservable();
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);
constructor(private statService: StatisticsService) {
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntil(this.onDestroy), shareReplay());
this.files$ = combineLatest([this.currentSort$, this.rawData$]).pipe(
map(([sortConfig, data]) => {
return {sortConfig, fileBreakdown: data.fileBreakdown};
}),
map(({ sortConfig, fileBreakdown }) => {
return (sortConfig.column) ? fileBreakdown.sort((a: FileExtension, b: FileExtension) => {
if (sortConfig.column === '') return 0;
const res = compare(a[sortConfig.column], b[sortConfig.column]);
return sortConfig.direction === 'asc' ? res : -res;
}) : fileBreakdown;
}),
takeUntil(this.onDestroy)
);
this.vizData2$ = this.files$.pipe(takeUntil(this.onDestroy), map(data => data.map(d => {
return {name: d.extension || 'Not Categorized', value: d.totalFiles, extra: d.totalSize};
})));
}
ngOnInit(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onSort(evt: SortEvent<FileExtension>) {
this.currentSort.next(evt);
// Must clear out headers here
this.headers.forEach((header) => {
if (header.sortable !== evt.column) {
header.direction = '';
}
});
}
}

View File

@ -0,0 +1,58 @@
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Format</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="manga-format-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="manga-format-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
</div>
<ng-template #tooltip></ng-template>
<ng-container *ngIf="formats$ | async as formats">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-pie-chart
[view]="view"
[results]="formats"
[legend]="showLegend"
[legendPosition]="legendPosition"
[labels]="showLabels"
>
</ngx-charts-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of formats; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>

View File

@ -0,0 +1,72 @@
import { ChangeDetectionStrategy, Component, OnInit, QueryList, ViewChildren } from '@angular/core';
import { FormControl } from '@angular/forms';
import { LegendPosition } from '@swimlane/ngx-charts';
import { Observable, Subject, BehaviorSubject, combineLatest, map, takeUntil } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { compare, SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { PieDataItem } from '../../_models/pie-data-item';
@Component({
selector: 'app-manga-format-stats',
templateUrl: './manga-format-stats.component.html',
styleUrls: ['./manga-format-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MangaFormatStatsComponent implements OnInit {
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
formats$!: Observable<Array<PieDataItem>>;
private readonly onDestroy = new Subject<void>();
currentSort = new BehaviorSubject<SortEvent<PieDataItem>>({column: 'value', direction: 'asc'});
currentSort$: Observable<SortEvent<PieDataItem>> = this.currentSort.asObservable();
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);
constructor(private statService: StatisticsService) {
this.formats$ = combineLatest([this.currentSort$, this.statService.getMangaFormat()]).pipe(
map(([sortConfig, data]) => {
return (sortConfig.column) ? data.sort((a: PieDataItem, b: PieDataItem) => {
if (sortConfig.column === '') return 0;
const res = compare(a[sortConfig.column], b[sortConfig.column]);
return sortConfig.direction === 'asc' ? res : -res;
}) : data;
}),
takeUntil(this.onDestroy)
);
}
ngOnInit(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onSort(evt: SortEvent<PieDataItem>) {
this.currentSort.next(evt);
// Must clear out headers here
this.headers.forEach((header) => {
if (header.sortable !== evt.column) {
header.direction = '';
}
});
}
}

View File

@ -0,0 +1,54 @@
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Publication Status</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-status-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-status-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
</div>
<ng-template #tooltip></ng-template>
<ng-container *ngIf="publicationStatues$ | async as statuses">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart
[results]="statuses"
>
</ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-hover table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Year
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of statuses; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>

View File

@ -0,0 +1,3 @@
::ng-deep .pie-label {
color: var(--body-text-color) !important;
}

View File

@ -0,0 +1,71 @@
import { ChangeDetectionStrategy, Component, OnInit, QueryList, ViewChildren } from '@angular/core';
import { FormControl } from '@angular/forms';
import { LegendPosition } from '@swimlane/ngx-charts';
import { Observable, Subject, map, takeUntil, combineLatest, BehaviorSubject } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { compare, SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { PieDataItem } from '../../_models/pie-data-item';
@Component({
selector: 'app-publication-status-stats',
templateUrl: './publication-status-stats.component.html',
styleUrls: ['./publication-status-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PublicationStatusStatsComponent implements OnInit {
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
publicationStatues$!: Observable<Array<PieDataItem>>;
private readonly onDestroy = new Subject<void>();
currentSort = new BehaviorSubject<SortEvent<PieDataItem>>({column: 'value', direction: 'asc'});
currentSort$: Observable<SortEvent<PieDataItem>> = this.currentSort.asObservable();
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);
constructor(private statService: StatisticsService) {
this.publicationStatues$ = combineLatest([this.currentSort$, this.statService.getPublicationStatus()]).pipe(
map(([sortConfig, data]) => {
return (sortConfig.column) ? data.sort((a: PieDataItem, b: PieDataItem) => {
if (sortConfig.column === '') return 0;
const res = compare(a[sortConfig.column], b[sortConfig.column]);
return sortConfig.direction === 'asc' ? res : -res;
}) : data;
}),
takeUntil(this.onDestroy)
);
}
ngOnInit(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onSort(evt: SortEvent<PieDataItem>) {
this.currentSort.next(evt);
// Must clear out headers here
this.headers.forEach((header) => {
if (header.sortable !== evt.column) {
header.direction = '';
}
});
}
}

View File

@ -0,0 +1,106 @@
<div class="container-fluid">
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around" *ngIf="stats$ | async as stats">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Series" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Total Series">
{{stats.seriesCount | compactNumber}} Series
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title label="Total Volumes" [clickable]="false" fontClasses="fas fa-eye" title="Total Volumes">
{{stats.volumeCount | compactNumber}} Volumes
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Chapters" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Chapters">
{{stats.chapterCount | compactNumber}} Chapters
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Files" [clickable]="false" fontClasses="fa-regular fa-file" title="Total Files">
{{stats.totalFiles | compactNumber}} Files
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Size" [clickable]="false" fontClasses="fa-solid fa-weight-scale" title="Total Size">
{{stats.totalSize | bytes}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Genres" [clickable]="false" fontClasses="fa-solid fa-tags" title="Total Genres">
{{stats.totalGenres | compactNumber}} Genres
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Tags" [clickable]="false" fontClasses="fa-solid fa-tags" title="Total Tags">
{{stats.totalTags | compactNumber}} Tags
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total People" [clickable]="false" fontClasses="fa-solid fa-user-tag" title="Total People">
{{stats.totalPeople | compactNumber}} People
</app-icon-and-title>
</div>
</ng-container>
</div>
<div class="grid row g-0 pt-2 pb-2">
<div class="col-auto">
<app-stat-list [data$]="releaseYears$" title="Release Years"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveUsers$" title="Most Active Users" label="events"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveLibrary$" title="Popular Libraries" label="events"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveSeries$" title="Popular Series"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="recentlyRead$" title="Recently Read"></app-stat-list>
</div>
</div>
<div class="row g-0 pt-2 pb-2 ">
<app-top-readers></app-top-readers>
</div>
<div class="row g-0 pt-2 pb-2 " style="height: 242px">
<div class="col-md-6 col-sm-12">
<app-file-breakdown-stats></app-file-breakdown-stats>
</div>
<div class="col-md-6 col-sm-12">
<app-publication-status-stats></app-publication-status-stats>
</div>
</div>
</div>

View File

@ -0,0 +1,10 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, 280px);
grid-gap: 0.5rem;
justify-content: space-evenly;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
align-items: start;
}

View File

@ -0,0 +1,75 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { map, Observable, shareReplay, Subject, takeUntil } from 'rxjs';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { User } from 'src/app/_models/user';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { FileExtensionBreakdown } from '../../_models/file-breakdown';
import { PieDataItem } from '../../_models/pie-data-item';
import { ServerStatistics } from '../../_models/server-statistics';
import { StatCount } from '../../_models/stat-count';
@Component({
selector: 'app-server-stats',
templateUrl: './server-stats.component.html',
styleUrls: ['./server-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ServerStatsComponent implements OnInit, OnDestroy {
releaseYears$!: Observable<Array<PieDataItem>>;
mostActiveUsers$!: Observable<Array<PieDataItem>>;
mostActiveLibrary$!: Observable<Array<PieDataItem>>;
mostActiveSeries$!: Observable<Array<PieDataItem>>;
recentlyRead$!: Observable<Array<PieDataItem>>;
stats$!: Observable<ServerStatistics>;
private readonly onDestroy = new Subject<void>();
constructor(private statService: StatisticsService) {
this.stats$ = this.statService.getServerStatistics().pipe(takeUntil(this.onDestroy), shareReplay());
this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy));
this.mostActiveUsers$ = this.stats$.pipe(
map(d => d.mostActiveUsers),
map(userCounts => userCounts.map(count => {
return {name: count.value.username, value: count.count};
})),
takeUntil(this.onDestroy)
);
this.mostActiveLibrary$ = this.stats$.pipe(
map(d => d.mostActiveLibraries),
map(counts => counts.map(count => {
return {name: count.value.name, value: count.count};
})),
takeUntil(this.onDestroy)
);
this.mostActiveSeries$ = this.stats$.pipe(
map(d => d.mostActiveLibraries),
map(counts => counts.map(count => {
return {name: count.value.name, value: count.count};
})),
takeUntil(this.onDestroy)
);
this.recentlyRead$ = this.stats$.pipe(
map(d => d.recentlyRead),
map(counts => counts.map(count => {
return {name: count.name, value: -1};
})),
takeUntil(this.onDestroy)
);
}
ngOnInit(): void {
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
}

View File

@ -0,0 +1,14 @@
<ng-container *ngIf="data$ | async as data">
<div class="card" style="width: 18rem;">
<div class="card-header text-center">
{{title}}
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0" *ngIf="description && description.length > 0"></i>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item" *ngFor="let item of data">
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value}} {{label}}</span>
</li>
</ul>
</div>
</ng-container>
<ng-template #tooltip></ng-template>

View File

@ -0,0 +1,3 @@
.card {
border: var(--bs-card-border-width) solid var(--bs-card-border-color);
}

View File

@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Observable } from 'rxjs';
import { PieDataItem } from '../../_models/pie-data-item';
@Component({
selector: 'app-stat-list',
templateUrl: './stat-list.component.html',
styleUrls: ['./stat-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StatListComponent {
/**
* Title of list
*/
@Input() title: string = ''
/**
* Optional label to render after value
*/
@Input() label: string = ''
/**
* Optional data to put in tooltip
*/
@Input() description: string = '';
@Input() data$!: Observable<PieDataItem[]>;
}

View File

@ -0,0 +1,35 @@
<div class="row g-0 mb-2 align-items-center">
<div class="col-4">
<h4>Top Readers</h4>
</div>
<div class="col-8">
<form [formGroup]="formGroup" class="d-inline-flex float-end">
<div class="d-flex">
<label for="time-select-top-reads" class="form-check-label"></label>
<select id="time-select-top-reads" class="form-select" formControlName="days"
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
<option *ngFor="let item of timePeriods" [value]="item.value">{{item.title}}</option>
</select>
</div>
</form>
</div>
</div>
<ng-container>
<div class="grid row g-0">
<div class="card" *ngFor="let user of (users$ | async)">
<div class="card-header text-center">
{{user.username}}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">Comics: {{user.comicsTime}} hrs</li>
<li class="list-group-item">Manga: {{user.mangaTime}} hrs</li>
<li class="list-group-item">Books: {{user.booksTime}} hrs</li>
</ul>
</div>
</div>
</ng-container>

View File

@ -0,0 +1,14 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, 280px);
grid-gap: 0.5rem;
justify-content: space-evenly;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
align-items: start;
}
.card {
border: var(--bs-card-border-width) solid var(--bs-card-border-color);
}

View File

@ -0,0 +1,44 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { Observable, Subject, takeUntil, switchMap, shareReplay } from 'rxjs';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { TopUserRead } from '../../_models/top-reads';
@Component({
selector: 'app-top-readers',
templateUrl: './top-readers.component.html',
styleUrls: ['./top-readers.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TopReadersComponent implements OnInit, OnDestroy {
formGroup: FormGroup;
timePeriods: Array<{title: string, value: number}> = [{title: 'Last 7 Days', value: 7}, {title: 'Last 30 Days', value: 30}, {title: 'Last 90 Days', value: 90}, {title: 'Last Year', value: 365}, {title: 'All Time', value: 0}];
users$: Observable<TopUserRead[]>;
private readonly onDestroy = new Subject<void>();
constructor(private statsService: StatisticsService, private readonly cdRef: ChangeDetectorRef) {
this.formGroup = new FormGroup({
'days': new FormControl(this.timePeriods[0].value, []),
});
this.users$ = this.formGroup.valueChanges.pipe(
switchMap(_ => this.statsService.getTopUsers(this.formGroup.get('days')?.value as number)),
takeUntil(this.onDestroy),
shareReplay(),
);
}
ngOnInit(): void {
// Needed so that other pipes work
this.users$.subscribe();
this.formGroup.get('days')?.setValue(this.timePeriods[0].value, {emitEvent: true});
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
}

View File

@ -0,0 +1,36 @@
<div class="row g-0 mt-4 mb-3">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Pages Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Pages Read">
{{totalPagesRead | number}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title label="Time Spent Reading" [clickable]="false" fontClasses="fas fa-eye" title="Time Spent Reading">
{{timeSpentReading}} hours
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Chapters Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Chapters Read">
{{chaptersRead | compactNumber}} Chapters
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Last Active" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Last Active">
{{lastActive | date:'short'}}
</app-icon-and-title>
</div>
</ng-container>
</div>

View File

@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'app-user-stats-info-cards',
templateUrl: './user-stats-info-cards.component.html',
styleUrls: ['./user-stats-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserStatsInfoCardsComponent implements OnInit {
@Input() totalPagesRead: number = 0;
@Input() timeSpentReading: number = 0;
@Input() chaptersRead: number = 0;
@Input() lastActive: string = '';
@Input() avgHoursPerWeekSpentReading: number = 0;
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,20 @@
<div class="container-fluid">
<!-- High level stats (use same design as series metadata info cards)-->
<div class="row g-0">
<ng-container *ngIf="userStats$ | async as userStats">
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [timeSpentReading]="userStats.timeSpentReading"
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive"></app-user-stats-info-cards>
</ng-container>
</div>
<!-- <div class="row g-0">
Books Read (this can be chapters read fully)
Number of bookmarks
Last Active Time
Average days reading on server a week
Total Series in want to read list?
</div> -->
</div>

View File

@ -0,0 +1,66 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
import { map, Observable, of, Subject, takeUntil } from 'rxjs';
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
import { Series } from 'src/app/_models/series';
import { UserReadStatistics } from 'src/app/statistics/_models/user-read-statistics';
import { SeriesService } from 'src/app/_services/series.service';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { ReadHistoryEvent } from '../../_models/read-history-event';
type SeriesWithProgress = Series & {progress: number};
@Component({
selector: 'app-user-stats',
templateUrl: './user-stats.component.html',
styleUrls: ['./user-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserStatsComponent implements OnInit, OnDestroy {
@Input() userId!: number;
@ViewChildren(SortableHeader) headers!: QueryList<SortableHeader<SeriesWithProgress>>;
userStats$!: Observable<UserReadStatistics>;
readSeries$!: Observable<ReadHistoryEvent[]>;
private readonly onDestroy = new Subject<void>();
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService, private seriesService: SeriesService,
private filterService: FilterUtilitiesService) { }
ngOnInit(): void {
const filter = this.filterService.createSeriesFilter();
filter.readStatus = {read: true, notRead: false, inProgress: true};
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy));
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe(
takeUntil(this.onDestroy),
);
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onSort({ column, direction }: SortEvent<SeriesWithProgress>) {
// resetting other headers
this.headers.forEach((header) => {
if (header.sortable !== column) {
header.direction = '';
}
});
// sorting countries
// if (direction === '' || column === '') {
// this.countries = COUNTRIES;
// } else {
// this.countries = [...COUNTRIES].sort((a, b) => {
// const res = compare(a[column], b[column]);
// return direction === 'asc' ? res : -res;
// });
// }
}
}

View File

@ -0,0 +1,13 @@
import { MangaFormat } from "src/app/_models/manga-format";
export interface FileExtension {
extension: string;
format: MangaFormat;
totalSize: number;
totalFiles: number;
}
export interface FileExtensionBreakdown {
totalFileSize: number;
fileBreakdown: Array<FileExtension>;
}

View File

@ -0,0 +1,4 @@
export enum Mode {
Visualization = 0,
Table = 1
}

View File

@ -0,0 +1,5 @@
export interface PieDataItem {
name: string;
value: number;
extra?: any;
}

View File

@ -0,0 +1,10 @@
export interface ReadHistoryEvent {
userId: number;
userName: string;
seriesName: string;
seriesId: number;
libraryId: number;
readDate: string;
chapterId: number;
chapterNumber: string;
}

View File

@ -0,0 +1,19 @@
import { Library } from "src/app/_models/library";
import { Series } from "src/app/_models/series";
import { User } from "src/app/_models/user";
import { StatCount } from "./stat-count";
export interface ServerStatistics {
chapterCount: number;
volumeCount: number;
seriesCount: number;
totalFiles: number;
totalSize: number;
totalGenres: number;
totalTags: number;
totalPeople: number;
mostActiveUsers: Array<StatCount<User>>;
mostActiveLibraries: Array<StatCount<Library>>;
mostActiveSeries: Array<StatCount<Series>>;
recentlyRead: Array<Series>;
}

View File

@ -0,0 +1,4 @@
export interface StatCount<T> {
value: T;
count: number;
}

View File

@ -0,0 +1,7 @@
export interface TopUserRead {
userId: number;
username: string;
mangaTime: number;
comicsTime: number;
booksTime: number;
}

View File

@ -0,0 +1,8 @@
export interface UserReadStatistics {
totalPagesRead: number;
timeSpentReading: number;
favoriteGenres: Array<any>;
chaptersRead: number;
lastActive: string;
avgHoursPerWeekSpentReading: number;
}

View File

@ -0,0 +1,48 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UserStatsComponent } from './_components/user-stats/user-stats.component';
import { TableModule } from '../_single-module/table/table.module';
import { UserStatsInfoCardsComponent } from './_components/user-stats-info-cards/user-stats-info-cards.component';
import { SharedModule } from '../shared/shared.module';
import { ServerStatsComponent } from './_components/server-stats/server-stats.component';
import { NgxChartsModule } from '@swimlane/ngx-charts';
import { StatListComponent } from './_components/stat-list/stat-list.component';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { PublicationStatusStatsComponent } from './_components/publication-status-stats/publication-status-stats.component';
import { ReactiveFormsModule } from '@angular/forms';
import { MangaFormatStatsComponent } from './_components/manga-format-stats/manga-format-stats.component';
import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/file-breakdown-stats.component';
import { PipeModule } from '../pipe/pipe.module';
import { TopReadersComponent } from './_components/top-readers/top-readers.component';
@NgModule({
declarations: [
UserStatsComponent,
UserStatsInfoCardsComponent,
ServerStatsComponent,
StatListComponent,
PublicationStatusStatsComponent,
MangaFormatStatsComponent,
FileBreakdownStatsComponent,
TopReadersComponent
],
imports: [
CommonModule,
TableModule,
SharedModule,
NgbTooltipModule,
ReactiveFormsModule,
PipeModule,
// Server only
NgxChartsModule
],
exports: [
UserStatsComponent,
ServerStatsComponent
]
})
export class StatisticsModule { }

View File

@ -308,6 +308,9 @@
<ng-container *ngIf="tab.fragment === FragmentID.Devices">
<app-manage-devices></app-manage-devices>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
<app-user-stats [userId]="1"></app-user-stats>
</ng-container>
</ng-template>
</li>
</ul>

Some files were not shown because too many files have changed in this diff Show More