mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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:
parent
4724dc5a76
commit
c361e66b35
17
API/Constants/ResponseCacheProfiles.cs
Normal file
17
API/Constants/ResponseCacheProfiles.cs
Normal 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";
|
||||
}
|
@ -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");
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
112
API/Controllers/StatsController.cs
Normal file
112
API/Controllers/StatsController.cs
Normal 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));
|
||||
}
|
||||
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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".
|
||||
|
7
API/DTOs/Statistics/Count.cs
Normal file
7
API/DTOs/Statistics/Count.cs
Normal 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; }
|
||||
}
|
22
API/DTOs/Statistics/FileExtensionBreakdownDto.cs
Normal file
22
API/DTOs/Statistics/FileExtensionBreakdownDto.cs
Normal 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; }
|
||||
|
||||
}
|
7
API/DTOs/Statistics/ICount.cs
Normal file
7
API/DTOs/Statistics/ICount.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace API.DTOs.Statistics;
|
||||
|
||||
public interface ICount<T>
|
||||
{
|
||||
public T Value { get; set; }
|
||||
public int Count { get; set; }
|
||||
}
|
18
API/DTOs/Statistics/ReadHistoryEvent.cs
Normal file
18
API/DTOs/Statistics/ReadHistoryEvent.cs
Normal 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; }
|
||||
}
|
29
API/DTOs/Statistics/ServerStatistics.cs
Normal file
29
API/DTOs/Statistics/ServerStatistics.cs
Normal 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; }
|
||||
|
||||
|
||||
}
|
19
API/DTOs/Statistics/TopReadsDto.cs
Normal file
19
API/DTOs/Statistics/TopReadsDto.cs
Normal 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; }
|
||||
}
|
||||
|
24
API/DTOs/Statistics/UserReadStatistics.cs
Normal file
24
API/DTOs/Statistics/UserReadStatistics.cs
Normal 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; }
|
||||
}
|
@ -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");
|
||||
|
@ -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);
|
||||
|
43
API/Data/MigrateUserProgressLibraryId.cs
Normal file
43
API/Data/MigrateUserProgressLibraryId.cs
Normal 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");
|
||||
}
|
||||
}
|
1699
API/Data/Migrations/20221126133824_FileLengthAndExtension.Designer.cs
generated
Normal file
1699
API/Data/Migrations/20221126133824_FileLengthAndExtension.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
API/Data/Migrations/20221126133824_FileLengthAndExtension.cs
Normal file
36
API/Data/Migrations/20221126133824_FileLengthAndExtension.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
1702
API/Data/Migrations/20221128230726_UserProgressLibraryId.Designer.cs
generated
Normal file
1702
API/Data/Migrations/20221128230726_UserProgressLibraryId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
API/Data/Migrations/20221128230726_UserProgressLibraryId.cs
Normal file
26
API/Data/Migrations/20221128230726_UserProgressLibraryId.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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)))
|
||||
|
@ -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();
|
||||
|
@ -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; }
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
417
API/Services/StatisticService.cs
Normal file
417
API/Services/StatisticService.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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(),
|
||||
|
@ -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();
|
||||
|
BIN
UI/Web/libs/iharbeck-ngx-virtual-scroller-14.0.5.tgz
Normal file
BIN
UI/Web/libs/iharbeck-ngx-virtual-scroller-14.0.5.tgz
Normal file
Binary file not shown.
413
UI/Web/package-lock.json
generated
413
UI/Web/package-lock.json
generated
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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', {});
|
||||
}
|
||||
|
84
UI/Web/src/app/_services/statistics.service.ts
Normal file
84
UI/Web/src/app/_services/statistics.service.ts
Normal 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');
|
||||
}
|
||||
}
|
@ -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 });
|
||||
}
|
||||
}
|
18
UI/Web/src/app/_single-module/table/table.module.ts
Normal file
18
UI/Web/src/app/_single-module/table/table.module.ts
Normal 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 { }
|
@ -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: []
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -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 */});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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 */});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
|
42
UI/Web/src/app/pipe/bytes.pipe.ts
Normal file
42
UI/Web/src/app/pipe/bytes.pipe.ts
Normal 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];
|
||||
}
|
||||
|
||||
}
|
@ -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 { }
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
||||
|
||||
|
@ -0,0 +1,4 @@
|
||||
::ng-deep .advanced-pie-legend {
|
||||
top: unset !important;
|
||||
transform: unset !important;
|
||||
}
|
@ -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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
|
||||
|
||||
|
@ -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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
|
||||
|
||||
|
@ -0,0 +1,3 @@
|
||||
::ng-deep .pie-label {
|
||||
color: var(--body-text-color) !important;
|
||||
}
|
@ -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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
@ -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>
|
@ -0,0 +1,3 @@
|
||||
.card {
|
||||
border: var(--bs-card-border-width) solid var(--bs-card-border-color);
|
||||
}
|
@ -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[]>;
|
||||
|
||||
}
|
@ -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>
|
||||
|
||||
|
||||
|
@ -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);
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
@ -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 {
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
@ -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;
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
13
UI/Web/src/app/statistics/_models/file-breakdown.ts
Normal file
13
UI/Web/src/app/statistics/_models/file-breakdown.ts
Normal 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>;
|
||||
}
|
4
UI/Web/src/app/statistics/_models/mode.ts
Normal file
4
UI/Web/src/app/statistics/_models/mode.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum Mode {
|
||||
Visualization = 0,
|
||||
Table = 1
|
||||
}
|
5
UI/Web/src/app/statistics/_models/pie-data-item.ts
Normal file
5
UI/Web/src/app/statistics/_models/pie-data-item.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface PieDataItem {
|
||||
name: string;
|
||||
value: number;
|
||||
extra?: any;
|
||||
}
|
10
UI/Web/src/app/statistics/_models/read-history-event.ts
Normal file
10
UI/Web/src/app/statistics/_models/read-history-event.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface ReadHistoryEvent {
|
||||
userId: number;
|
||||
userName: string;
|
||||
seriesName: string;
|
||||
seriesId: number;
|
||||
libraryId: number;
|
||||
readDate: string;
|
||||
chapterId: number;
|
||||
chapterNumber: string;
|
||||
}
|
19
UI/Web/src/app/statistics/_models/server-statistics.ts
Normal file
19
UI/Web/src/app/statistics/_models/server-statistics.ts
Normal 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>;
|
||||
}
|
4
UI/Web/src/app/statistics/_models/stat-count.ts
Normal file
4
UI/Web/src/app/statistics/_models/stat-count.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface StatCount<T> {
|
||||
value: T;
|
||||
count: number;
|
||||
}
|
7
UI/Web/src/app/statistics/_models/top-reads.ts
Normal file
7
UI/Web/src/app/statistics/_models/top-reads.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface TopUserRead {
|
||||
userId: number;
|
||||
username: string;
|
||||
mangaTime: number;
|
||||
comicsTime: number;
|
||||
booksTime: number;
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
export interface UserReadStatistics {
|
||||
totalPagesRead: number;
|
||||
timeSpentReading: number;
|
||||
favoriteGenres: Array<any>;
|
||||
chaptersRead: number;
|
||||
lastActive: string;
|
||||
avgHoursPerWeekSpentReading: number;
|
||||
}
|
48
UI/Web/src/app/statistics/statistics.module.ts
Normal file
48
UI/Web/src/app/statistics/statistics.module.ts
Normal 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 { }
|
@ -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
Loading…
x
Reference in New Issue
Block a user