Basic Stats (#1673)

* Refactored ResponseCache profiles into consts

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

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

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

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

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

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

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

* Working on visualization of file breakdown

* Fixed the file breakdown visual

* Fixed up 2 visualizations

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

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

* Preparing to remove top reads and refactor into Top users

* Added LibraryId to AppUserProgress to help with complex lookups.

* Added the new libraryId hook into some stats methods

* Updated api methods to use libraryId for progress

* More places where LibraryId is needed

* Added some high level server stats

* Got a ton done on server stats

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

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

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

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

* Fixed recently read query

* Cleanup the css a bit, Robbie we need you

* More css love

* Cleaned up DTOs that aren't needed anymore

* Fixed top readers query

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

* Hooked up the date into GetTopUsers

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

* Fixed up query

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

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

* Ensure OPDS-PS uses new libraryId for progress reporting

* Fixed a code smell

* Adding some styling

* adding more styles

* Removed some debug stuff from user stats

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

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

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

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

* Tweaked some code for bad data cases

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

* API push

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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