diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index d16293d0e..f315b6f3f 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -41,7 +41,7 @@ public class StatsController : BaseApiController [Authorize("RequireAdminRole")] [HttpGet("server/stats")] [ResponseCache(CacheProfileName = "Statistics")] - public async Task> GetHighLevelStats() + public async Task> GetHighLevelStats() { return Ok(await _statService.GetServerStatistics()); } @@ -141,4 +141,34 @@ public class StatsController : BaseApiController return Ok(await _statService.GetReadingHistory(userId)); } + /// + /// Returns a count of pages read per year for a given userId. + /// + /// If userId is 0 and user is not an admin, API will default to userId + /// + [HttpGet("pages-per-year")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetPagesReadPerYear(int userId = 0) + { + var isAdmin = User.IsInRole(PolicyConstants.AdminRole); + if (!isAdmin) userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(_statService.GetPagesReadCountByYear(userId)); + } + + /// + /// Returns a count of words read per year for a given userId. + /// + /// If userId is 0 and user is not an admin, API will default to userId + /// + [HttpGet("words-per-year")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetWordsReadPerYear(int userId = 0) + { + var isAdmin = User.IsInRole(PolicyConstants.AdminRole); + if (!isAdmin) userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(_statService.GetWordsReadCountByYear(userId)); + } + + + } diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index 30b8cb05d..12bbeaa85 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -42,7 +42,7 @@ public class WantToReadController : BaseApiController } [HttpGet] - public async Task> GetWantToRead([FromQuery] int seriesId) + public async Task> IsSeriesInWantToRead([FromQuery] int seriesId) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.IsSeriesInWantToRead(user.Id, seriesId)); diff --git a/API/DTOs/Statistics/Count.cs b/API/DTOs/Statistics/Count.cs index b9f797574..dc9803a6f 100644 --- a/API/DTOs/Statistics/Count.cs +++ b/API/DTOs/Statistics/Count.cs @@ -3,5 +3,5 @@ public class StatCount : ICount { public T Value { get; set; } - public int Count { get; set; } + public long Count { get; set; } } diff --git a/API/DTOs/Statistics/ICount.cs b/API/DTOs/Statistics/ICount.cs index c38f8895e..7f8b5b2ed 100644 --- a/API/DTOs/Statistics/ICount.cs +++ b/API/DTOs/Statistics/ICount.cs @@ -3,5 +3,5 @@ public interface ICount { public T Value { get; set; } - public int Count { get; set; } + public long Count { get; set; } } diff --git a/API/DTOs/Statistics/PagesReadOnADayCount.cs b/API/DTOs/Statistics/PagesReadOnADayCount.cs index f2bfab74b..c38a775c7 100644 --- a/API/DTOs/Statistics/PagesReadOnADayCount.cs +++ b/API/DTOs/Statistics/PagesReadOnADayCount.cs @@ -12,10 +12,9 @@ public class PagesReadOnADayCount : ICount /// /// Number of pages read /// - public int Count { get; set; } + public long Count { get; set; } /// /// Format of those files /// public MangaFormat Format { get; set; } - } diff --git a/API/DTOs/Statistics/ServerStatistics.cs b/API/DTOs/Statistics/ServerStatisticsDto.cs similarity index 96% rename from API/DTOs/Statistics/ServerStatistics.cs rename to API/DTOs/Statistics/ServerStatisticsDto.cs index 76dbd94e0..3bdeef9f3 100644 --- a/API/DTOs/Statistics/ServerStatistics.cs +++ b/API/DTOs/Statistics/ServerStatisticsDto.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; namespace API.DTOs.Statistics; -public class ServerStatistics +public class ServerStatisticsDto { public long ChapterCount { get; set; } public long VolumeCount { get; set; } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index a5cb6b191..e39b09a45 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -45,6 +45,7 @@ public sealed class DataContext : IdentityDbContext SeriesRelation { get; set; } public DbSet FolderPath { get; set; } public DbSet Device { get; set; } + public DbSet ServerStatistics { get; set; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/Migrations/20230111014852_YearlyStats.Designer.cs b/API/Data/Migrations/20230111014852_YearlyStats.Designer.cs new file mode 100644 index 000000000..2a34ad07b --- /dev/null +++ b/API/Data/Migrations/20230111014852_YearlyStats.Designer.cs @@ -0,0 +1,1743 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20230111014852_YearlyStats")] + partial class YearlyStats + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.10"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230111014852_YearlyStats.cs b/API/Data/Migrations/20230111014852_YearlyStats.cs new file mode 100644 index 000000000..c2ec76e3b --- /dev/null +++ b/API/Data/Migrations/20230111014852_YearlyStats.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class YearlyStats : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ServerStatistics", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Year = table.Column(type: "INTEGER", nullable: false), + SeriesCount = table.Column(type: "INTEGER", nullable: false), + VolumeCount = table.Column(type: "INTEGER", nullable: false), + ChapterCount = table.Column(type: "INTEGER", nullable: false), + FileCount = table.Column(type: "INTEGER", nullable: false), + UserCount = table.Column(type: "INTEGER", nullable: false), + GenreCount = table.Column(type: "INTEGER", nullable: false), + PersonCount = table.Column(type: "INTEGER", nullable: false), + TagCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ServerStatistics", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ServerStatistics"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c474690ce..3ebad1105 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -949,6 +949,44 @@ namespace API.Data.Migrations b.ToTable("ServerSetting"); }); + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + modelBuilder.Entity("API.Entities.SiteTheme", b => { b.Property("Id") diff --git a/API/Entities/ServerStatistics.cs b/API/Entities/ServerStatistics.cs new file mode 100644 index 000000000..159b7ef4c --- /dev/null +++ b/API/Entities/ServerStatistics.cs @@ -0,0 +1,15 @@ +namespace API.Entities; + +public class ServerStatistics +{ + public int Id { get; set; } + public int Year { get; set; } + public long SeriesCount { get; set; } + public long VolumeCount { get; set; } + public long ChapterCount { get; set; } + public long FileCount { get; set; } + public long UserCount { get; set; } + public long GenreCount { get; set; } + public long PersonCount { get; set; } + public long TagCount { get; set; } +} diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index f301718e3..c770c4dce 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs; using API.DTOs.Statistics; +using API.Entities; using API.Entities.Enums; using API.Extensions; using AutoMapper; @@ -17,7 +18,7 @@ namespace API.Services; public interface IStatisticService { - Task GetServerStatistics(); + Task GetServerStatistics(); Task GetUserReadStatistics(int userId, IList libraryIds); Task>> GetYearCount(); Task>> GetTopYears(); @@ -28,6 +29,9 @@ public interface IStatisticService Task> GetReadingHistory(int userId); Task>> ReadCountByDay(int userId = 0, int days = 0); IEnumerable> GetDayBreakdown(); + IEnumerable> GetPagesReadCountByYear(int userId = 0); + IEnumerable> GetWordsReadCountByYear(int userId = 0); + Task UpdateServerStatistics(); } /// @@ -71,9 +75,12 @@ public class StatisticService : IStatisticService .Where(c => chapterIds.Contains(c.Id)) .SumAsync(c => c.AvgHoursToRead); - var totalWordsRead = await _context.Chapter - .Where(c => chapterIds.Contains(c.Id)) - .SumAsync(c => c.WordCount); + var totalWordsRead = (long) Math.Round(await _context.AppUserProgresses + .Where(p => p.AppUserId == userId) + .Where(p => libraryIds.Contains(p.LibraryId)) + .Join(_context.Chapter, p => p.ChapterId, c => c.Id, (progress, chapter) => new {chapter, progress}) + .Where(p => p.chapter.WordCount > 0) + .SumAsync(p => p.chapter.WordCount * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))); var chaptersRead = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) @@ -83,10 +90,10 @@ public class StatisticService : IStatisticService var lastActive = await _context.AppUserProgresses .OrderByDescending(p => p.LastModified) + .Where(p => p.AppUserId == userId) .Select(p => p.LastModified) .FirstOrDefaultAsync(); - // Reading Progress by Library Name // First get the total pages per library var totalPageCountByLibrary = _context.Chapter @@ -190,7 +197,7 @@ public class StatisticService : IStatisticService } - public async Task GetServerStatistics() + public async Task GetServerStatistics() { var mostActiveUsers = _context.AppUserProgresses .AsSplitQuery() @@ -268,7 +275,7 @@ public class StatisticService : IStatisticService .Distinct() .Count(); - return new ServerStatistics() + return new ServerStatisticsDto() { ChapterCount = await _context.Chapter.CountAsync(), SeriesCount = await _context.Series.CountAsync(), @@ -397,6 +404,85 @@ public class StatisticService : IStatisticService .AsEnumerable(); } + /// + /// Return a list of years for the given userId + /// + /// + /// + public IEnumerable> GetPagesReadCountByYear(int userId = 0) + { + var query = _context.AppUserProgresses + .AsSplitQuery() + .AsNoTracking(); + + if (userId > 0) + { + query = query.Where(p => p.AppUserId == userId); + } + + return query.GroupBy(p => p.LastModified.Year) + .OrderBy(g => g.Key) + .Select(g => new StatCount {Value = g.Key, Count = g.Sum(x => x.PagesRead)}) + .AsEnumerable(); + } + + public IEnumerable> GetWordsReadCountByYear(int userId = 0) + { + var query = _context.AppUserProgresses + .AsSplitQuery() + .AsNoTracking(); + + if (userId > 0) + { + query = query.Where(p => p.AppUserId == userId); + } + + return query + .Join(_context.Chapter, p => p.ChapterId, c => c.Id, (progress, chapter) => new {chapter, progress}) + .Where(p => p.chapter.WordCount > 0) + .GroupBy(p => p.progress.LastModified.Year) + .Select(g => new StatCount{ + Value = g.Key, + Count = (long) Math.Round(g.Sum(p => p.chapter.WordCount * ((1.0f * p.progress.PagesRead) / p.chapter.Pages))) + }) + .AsEnumerable(); + } + + /// + /// Updates the ServerStatistics table for the current year + /// + /// This commits + /// + public async Task UpdateServerStatistics() + { + var year = DateTime.Today.Year; + + var existingRecord = await _context.ServerStatistics.SingleOrDefaultAsync(s => s.Year == year) ?? new ServerStatistics(); + + existingRecord.Year = year; + existingRecord.ChapterCount = await _context.Chapter.CountAsync(); + existingRecord.VolumeCount = await _context.Volume.CountAsync(); + existingRecord.FileCount = await _context.MangaFile.CountAsync(); + existingRecord.SeriesCount = await _context.Series.CountAsync(); + existingRecord.UserCount = await _context.Users.CountAsync(); + existingRecord.GenreCount = await _context.Genre.CountAsync(); + existingRecord.TagCount = await _context.Tag.CountAsync(); + existingRecord.PersonCount = _context.Person + .AsSplitQuery() + .AsEnumerable() + .GroupBy(sm => sm.NormalizedName) + .Select(sm => sm.Key) + .Distinct() + .Count(); + + _context.ServerStatistics.Attach(existingRecord); + if (existingRecord.Id > 0) + { + _context.Entry(existingRecord).State = EntityState.Modified; + } + await _unitOfWork.CommitAsync(); + } + public async Task> GetTopUsers(int days) { var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); @@ -434,9 +520,8 @@ public class StatisticService : IStatisticService .ToList(); var chapterLibLookup = new Dictionary(); - foreach (var cl in chapterIdWithLibraryId) + foreach (var cl in chapterIdWithLibraryId.Where(cl => !chapterLibLookup.ContainsKey(cl.ChapterId))) { - if (chapterLibLookup.ContainsKey(cl.ChapterId)) continue; chapterLibLookup.Add(cl.ChapterId, cl.LibraryId); } @@ -457,19 +542,14 @@ public class StatisticService : IStatisticService user[userChapter.User.Id] = libraryTimes; } - var ret = new List(); - foreach (var userId in user.Keys) - { - ret.Add(new TopReadDto() + return user.Keys.Select(userId => 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; + }) + .ToList(); } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 072f9bfbf..bdd7069bc 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -46,11 +46,13 @@ public class TaskScheduler : ITaskScheduler private readonly IVersionUpdaterService _versionUpdaterService; private readonly IThemeService _themeService; private readonly IWordCountAnalyzerService _wordCountAnalyzerService; + private readonly IStatisticService _statisticService; public static BackgroundJobServer Client => new BackgroundJobServer(); public const string ScanQueue = "scan"; public const string DefaultQueue = "default"; public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read"; + public const string UpdateYearlyStatsTaskId = "update-yearly-stats"; public const string CleanupDbTaskId = "cleanup-db"; public const string CleanupTaskId = "cleanup"; public const string BackupTaskId = "backup"; @@ -65,7 +67,7 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, - IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService) + IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService) { _cacheService = cacheService; _logger = logger; @@ -78,6 +80,7 @@ public class TaskScheduler : ITaskScheduler _versionUpdaterService = versionUpdaterService; _themeService = themeService; _wordCountAnalyzerService = wordCountAnalyzerService; + _statisticService = statisticService; } public async Task ScheduleTasks() @@ -111,6 +114,7 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, TimeZoneInfo.Local); + RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, TimeZoneInfo.Local); } #region StatsTasks diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 7f3e07fa5..299925020 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -13,6 +13,7 @@ import { UserUpdateEvent } from '../_models/events/user-update-event'; import { UpdateEmailResponse } from '../_models/auth/update-email-response'; import { AgeRating } from '../_models/metadata/age-rating'; import { AgeRestriction } from '../_models/metadata/age-restriction'; +import { TextResonse } from '../_types/text-response'; export enum Role { Admin = 'Admin', @@ -151,7 +152,7 @@ export class AccountService implements OnDestroy { } migrateUser(model: {email: string, username: string, password: string, sendEmail: boolean}) { - return this.httpClient.post(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'account/migrate-email', model, TextResonse); } confirmMigrationEmail(model: {email: string, token: string}) { @@ -159,7 +160,7 @@ export class AccountService implements OnDestroy { } resendConfirmationEmail(userId: number) { - return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, TextResonse); } inviteUser(model: {email: string, roles: Array, libraries: Array, ageRestriction: AgeRestriction}) { @@ -180,7 +181,7 @@ export class AccountService implements OnDestroy { * @returns */ getInviteUrl(userId: number, withBaseUrl: boolean = true) { - return this.httpClient.get(this.baseUrl + 'account/invite-url?userId=' + userId + '&withBaseUrl=' + withBaseUrl, {responseType: 'text' as 'json'}); + return this.httpClient.get(this.baseUrl + 'account/invite-url?userId=' + userId + '&withBaseUrl=' + withBaseUrl, TextResonse); } getDecodedToken(token: string) { @@ -188,15 +189,15 @@ export class AccountService implements OnDestroy { } requestResetPasswordEmail(email: string) { - return this.httpClient.post(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, TextResonse); } confirmResetPasswordEmail(model: {email: string, token: string, password: string}) { - return this.httpClient.post(this.baseUrl + 'account/confirm-password-reset', model, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'account/confirm-password-reset', model, TextResonse); } resetPassword(username: string, password: string, oldPassword: string) { - return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, {responseType: 'json' as 'text'}); + return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, TextResonse); } update(model: {email: string, roles: Array, libraries: Array, userId: number, ageRestriction: AgeRestriction}) { @@ -247,7 +248,7 @@ export class AccountService implements OnDestroy { } resetApiKey() { - return this.httpClient.post(this.baseUrl + 'account/reset-api-key', {}, {responseType: 'text' as 'json'}).pipe(map(key => { + return this.httpClient.post(this.baseUrl + 'account/reset-api-key', {}, TextResonse).pipe(map(key => { const user = this.getUserFromLocalStorage(); if (user) { user.apiKey = key; @@ -264,7 +265,8 @@ export class AccountService implements OnDestroy { private refreshToken() { if (this.currentUser === null || this.currentUser === undefined) return of(); - return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { + return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', + {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { if (this.currentUser) { this.currentUser.token = user.token; this.currentUser.refreshToken = user.refreshToken; diff --git a/UI/Web/src/app/_services/collection-tag.service.ts b/UI/Web/src/app/_services/collection-tag.service.ts index b53f4d5b7..2f19352ab 100644 --- a/UI/Web/src/app/_services/collection-tag.service.ts +++ b/UI/Web/src/app/_services/collection-tag.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { CollectionTag } from '../_models/collection-tag'; +import { TextResonse } from '../_types/text-response'; import { ImageService } from './image.service'; @Injectable({ @@ -26,15 +27,15 @@ export class CollectionTagService { } updateTag(tag: CollectionTag) { - return this.httpClient.post(this.baseUrl + 'collection/update', tag, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'collection/update', tag, TextResonse); } updateSeriesForTag(tag: CollectionTag, seriesIdsToRemove: Array) { - return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, TextResonse); } addByMultiple(tagId: number, seriesIds: Array, tagTitle: string = '') { - return this.httpClient.post(this.baseUrl + 'collection/update-for-series', {collectionTagId: tagId, collectionTagTitle: tagTitle, seriesIds}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'collection/update-for-series', {collectionTagId: tagId, collectionTagTitle: tagTitle, seriesIds}, TextResonse); } tagNameExists(name: string) { diff --git a/UI/Web/src/app/_services/device.service.ts b/UI/Web/src/app/_services/device.service.ts index 52e1c5aad..c7b062cc6 100644 --- a/UI/Web/src/app/_services/device.service.ts +++ b/UI/Web/src/app/_services/device.service.ts @@ -4,6 +4,7 @@ import { ReplaySubject, shareReplay, tap } from 'rxjs'; import { environment } from 'src/environments/environment'; import { Device } from '../_models/device/device'; import { DevicePlatform } from '../_models/device/device-platform'; +import { TextResonse } from '../_types/text-response'; import { AccountService } from './account.service'; @Injectable({ @@ -32,11 +33,11 @@ export class DeviceService { } createDevice(name: string, platform: DevicePlatform, emailAddress: string) { - return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, TextResonse); } updateDevice(id: number, name: string, platform: DevicePlatform, emailAddress: string) { - return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, TextResonse); } deleteDevice(id: number) { @@ -50,7 +51,7 @@ export class DeviceService { } sendTo(chapterIds: Array, deviceId: number) { - return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, TextResonse); } diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 7d0e115b8..80b46ff09 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -11,6 +11,7 @@ import { Language } from '../_models/metadata/language'; import { PublicationStatusDto } from '../_models/metadata/publication-status-dto'; import { Person } from '../_models/metadata/person'; import { Tag } from '../_models/tag'; +import { TextResonse } from '../_types/text-response'; @Injectable({ providedIn: 'root' @@ -28,7 +29,7 @@ export class MetadataService { if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) { return of(this.ageRatingTypes[ageRating]); } - return this.httpClient.get(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, {responseType: 'text' as 'json'}).pipe(map(ratingString => { + return this.httpClient.get(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, TextResonse).pipe(map(ratingString => { if (this.ageRatingTypes === undefined) { this.ageRatingTypes = {}; } @@ -97,6 +98,6 @@ export class MetadataService { } getChapterSummary(chapterId: number) { - return this.httpClient.get(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, {responseType: 'text' as 'json'}); + return this.httpClient.get(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse); } } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index dcca5e522..51fe7ed90 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -15,6 +15,7 @@ import { UtilityService } from '../shared/_services/utility.service'; import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; import { FileDimension } from '../manga-reader/_models/file-dimension'; import screenfull from 'screenfull'; +import { TextResonse } from '../_types/text-response'; export const CHAPTER_ID_DOESNT_EXIST = -1; export const CHAPTER_ID_NOT_FETCHED = -2; @@ -78,10 +79,10 @@ export class ReaderService { } clearBookmarks(seriesId: number) { - return this.httpClient.post(this.baseUrl + 'reader/remove-bookmarks', {seriesId}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'reader/remove-bookmarks', {seriesId}, TextResonse); } clearMultipleBookmarks(seriesIds: Array) { - return this.httpClient.post(this.baseUrl + 'reader/bulk-remove-bookmarks', {seriesIds}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'reader/bulk-remove-bookmarks', {seriesIds}, TextResonse); } /** diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 486c51f0f..b626d1a72 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -5,6 +5,7 @@ import { environment } from 'src/environments/environment'; import { UtilityService } from '../shared/_services/utility.service'; import { PaginatedResult } from '../_models/pagination'; import { ReadingList, ReadingListItem } from '../_models/reading-list'; +import { TextResonse } from '../_types/text-response'; import { ActionItem } from './action-factory.service'; @Injectable({ @@ -44,43 +45,43 @@ export class ReadingListService { } update(model: {readingListId: number, title?: string, summary?: string, promoted: boolean}) { - return this.httpClient.post(this.baseUrl + 'readinglist/update', model, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update', model, TextResonse); } updateByMultiple(readingListId: number, seriesId: number, volumeIds: Array, chapterIds?: Array) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple', {readingListId, seriesId, volumeIds, chapterIds}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple', {readingListId, seriesId, volumeIds, chapterIds}, TextResonse); } updateByMultipleSeries(readingListId: number, seriesIds: Array) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds}, TextResonse); } updateBySeries(readingListId: number, seriesId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-series', {readingListId, seriesId}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-series', {readingListId, seriesId}, TextResonse); } updateByVolume(readingListId: number, seriesId: number, volumeId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-volume', {readingListId, seriesId, volumeId}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-volume', {readingListId, seriesId, volumeId}, TextResonse); } updateByChapter(readingListId: number, seriesId: number, chapterId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-by-chapter', {readingListId, seriesId, chapterId}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-chapter', {readingListId, seriesId, chapterId}, TextResonse); } delete(readingListId: number) { - return this.httpClient.delete(this.baseUrl + 'readinglist?readingListId=' + readingListId, { responseType: 'text' as 'json' }); + return this.httpClient.delete(this.baseUrl + 'readinglist?readingListId=' + readingListId, TextResonse); } updatePosition(readingListId: number, readingListItemId: number, fromPosition: number, toPosition: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/update-position', {readingListId, readingListItemId, fromPosition, toPosition}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/update-position', {readingListId, readingListItemId, fromPosition, toPosition}, TextResonse); } deleteItem(readingListId: number, readingListItemId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/delete-item', {readingListId, readingListItemId}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/delete-item', {readingListId, readingListItemId}, TextResonse); } removeRead(readingListId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse); } actionListFilter(action: ActionItem, readingList: ReadingList, isAdmin: boolean) { diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 94227379a..680688cff 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -17,6 +17,7 @@ import { SeriesGroup } from '../_models/series-group'; import { SeriesMetadata } from '../_models/metadata/series-metadata'; import { Volume } from '../_models/volume'; import { ImageService } from './image.service'; +import { TextResonse } from '../_types/text-response'; @Injectable({ providedIn: 'root' @@ -131,7 +132,7 @@ export class SeriesService { } isWantToRead(seriesId: number) { - return this.httpClient.get(this.baseUrl + 'want-to-read?seriesId=' + seriesId, {responseType: 'text' as 'json'}) + return this.httpClient.get(this.baseUrl + 'want-to-read?seriesId=' + seriesId, TextResonse) .pipe(map(val => { return val === 'true'; })); @@ -174,7 +175,7 @@ export class SeriesService { seriesMetadata, collectionTags, }; - return this.httpClient.post(this.baseUrl + 'series/metadata', data, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'series/metadata', data, TextResonse); } getSeriesForTag(collectionTagId: number, pageNum?: number, itemsPerPage?: number) { diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 9b14c9d30..e8d1e2b7f 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -12,6 +12,7 @@ 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'; +import { TextResonse } from '../_types/text-response'; export enum DayOfWeek { @@ -58,7 +59,21 @@ export class StatisticsService { getTopYears() { return this.httpClient.get[]>(this.baseUrl + 'stats/server/top/years').pipe( map(spreads => spreads.map(spread => { - return {name: spread.value + '', value: spread.count}; + return {name: spread.value + '', value: spread.count}; + }))); + } + + getPagesPerYear(userId = 0) { + return this.httpClient.get[]>(this.baseUrl + 'stats/pages-per-year?userId=' + userId).pipe( + map(spreads => spreads.map(spread => { + return {name: spread.value + '', value: spread.count}; + }))); + } + + getWordsPerYear(userId = 0) { + return this.httpClient.get[]>(this.baseUrl + 'stats/words-per-year?userId=' + userId).pipe( + map(spreads => spreads.map(spread => { + return {name: spread.value + '', value: spread.count}; }))); } @@ -85,7 +100,7 @@ export class StatisticsService { } getTotalSize() { - return this.httpClient.get(this.baseUrl + 'stats/server/file-size', { responseType: 'text' as 'json'}); + return this.httpClient.get(this.baseUrl + 'stats/server/file-size', TextResonse); } getFileBreakdown() { diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 8414150cc..a877a0df8 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -3,12 +3,12 @@ import { HttpClient } from '@angular/common/http'; import { Inject, Injectable, OnDestroy, Renderer2, RendererFactory2, SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import { ToastrService } from 'ngx-toastr'; -import { map, ReplaySubject, Subject, takeUntil, take, distinctUntilChanged, Observable } from 'rxjs'; +import { map, ReplaySubject, Subject, takeUntil, take } from 'rxjs'; import { environment } from 'src/environments/environment'; import { ConfirmService } from '../shared/confirm.service'; import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme'; -import { AccountService } from './account.service'; +import { TextResonse } from '../_types/text-response'; import { EVENTS, MessageHubService } from './message-hub.service'; @@ -65,6 +65,14 @@ export class ThemeService implements OnDestroy { return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim(); } + /** + * --theme-color from theme. Updates the meta tag + * @returns + */ + getThemeColor() { + return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim(); + } + getCssVariable(variable: string) { return getComputedStyle(this.document.body).getPropertyValue(variable).trim(); } @@ -137,11 +145,23 @@ export class ThemeService implements OnDestroy { this.setTheme('dark'); return; } - const styleElem = document.createElement('style'); + const styleElem = this.document.createElement('style'); styleElem.id = 'theme-' + theme.name; styleElem.appendChild(this.document.createTextNode(content)); this.renderer.appendChild(this.document.head, styleElem); + + // Check if the theme has --theme-color and apply it to meta tag + const themeColor = this.getThemeColor(); + if (themeColor) { + this.document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor); + } + + const colorScheme = this.getColorScheme(); + if (themeColor) { + this.document.querySelector('body')?.setAttribute('theme', colorScheme); + } + this.currentThemeSource.next(theme); }); } else { @@ -161,8 +181,7 @@ export class ThemeService implements OnDestroy { } private fetchThemeContent(themeId: number) { - // TODO: Refactor {responseType: 'text' as 'json'} into a type so i don't have to retype it - return this.httpClient.get(this.baseUrl + 'theme/download-content?themeId=' + themeId, {responseType: 'text' as 'json'}).pipe(map(encodedCss => { + return this.httpClient.get(this.baseUrl + 'theme/download-content?themeId=' + themeId, TextResonse).pipe(map(encodedCss => { return this.domSantizer.sanitize(SecurityContext.STYLE, encodedCss); })); } diff --git a/UI/Web/src/app/_services/upload.service.ts b/UI/Web/src/app/_services/upload.service.ts index 77fde32aa..8c3d6295a 100644 --- a/UI/Web/src/app/_services/upload.service.ts +++ b/UI/Web/src/app/_services/upload.service.ts @@ -1,6 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; +import { TextResonse } from '../_types/text-response'; @Injectable({ providedIn: 'root' @@ -13,7 +14,7 @@ export class UploadService { uploadByUrl(url: string) { - return this.httpClient.post(this.baseUrl + 'upload/upload-by-url', {url}, {responseType: 'text' as 'json'}); + return this.httpClient.post(this.baseUrl + 'upload/upload-by-url', {url}, TextResonse); } /** diff --git a/UI/Web/src/app/_types/text-response.ts b/UI/Web/src/app/_types/text-response.ts new file mode 100644 index 000000000..19e2117d3 --- /dev/null +++ b/UI/Web/src/app/_types/text-response.ts @@ -0,0 +1,4 @@ +/** + * Use when httpClient is expected to return just a string/variable and not json + */ +export const TextResonse = {responseType: 'text' as 'json'}; \ No newline at end of file diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index ab29e4f86..b6cf59cfe 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -1,6 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; +import { TextResonse } from '../_types/text-response'; import { ServerSettings } from './_models/server-settings'; /** @@ -53,6 +54,6 @@ export class SettingsService { } getOpdsEnabled() { - return this.http.get(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'}); + return this.http.get(this.baseUrl + 'settings/opds-enabled', TextResonse); } } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index 57c90a119..d894c5bd8 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -91,7 +91,8 @@ [innerHtml]="page" *ngIf="page !== undefined" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)"> -
+
diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index 2738346e7..10012af05 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -47,7 +47,7 @@ $primary-color: #0062cc; $action-bar-height: 38px; -// Drawer +// Drawer .control-container { padding-bottom: 5px; } @@ -86,10 +86,6 @@ $action-bar-height: 38px; opacity: 0; } -::ng-deep .bg-warning { - background-color: yellow; -} - .action-bar { background-color: var(--br-actionbar-bg-color); @@ -196,7 +192,8 @@ $action-bar-height: 38px; } &.immersive { - height: calc((var(--vh, 1vh) * 100) - $action-bar-height); + // Note: I removed this for bug: https://github.com/Kareadita/Kavita/issues/1726 + //height: calc((var(--vh, 1vh) * 100) - $action-bar-height); } a, :link { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 37dca4c42..ac2086c7f 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -262,9 +262,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('reader', {static: true}) reader!: ElementRef; - - - get BookPageLayoutMode() { return BookPageLayoutMode; } @@ -722,16 +719,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * from 'kavita-part', which will cause the reader to scroll to the marker. */ addLinkClickHandlers() { - var links = this.readingSectionElemRef.nativeElement.querySelectorAll('a'); + const links = this.readingSectionElemRef.nativeElement.querySelectorAll('a'); links.forEach((link: any) => { link.addEventListener('click', (e: any) => { + console.log('Link clicked: ', e); if (!e.target.attributes.hasOwnProperty('kavita-page')) { return; } - var page = parseInt(e.target.attributes['kavita-page'].value, 10); + const page = parseInt(e.target.attributes['kavita-page'].value, 10); if (this.adhocPageHistory.peek()?.page !== this.pageNum) { this.adhocPageHistory.push({page: this.pageNum, scrollPart: this.lastSeenScrollPartPath}); } - var partValue = e.target.attributes.hasOwnProperty('kavita-part') ? e.target.attributes['kavita-part'].value : undefined; + const partValue = e.target.attributes.hasOwnProperty('kavita-part') ? e.target.attributes['kavita-part'].value : undefined; if (partValue && page === this.pageNum) { this.scrollTo(e.target.attributes['kavita-part'].value); return; diff --git a/UI/Web/src/app/book-reader/_services/book.service.ts b/UI/Web/src/app/book-reader/_services/book.service.ts index 3dd579662..4ca377636 100644 --- a/UI/Web/src/app/book-reader/_services/book.service.ts +++ b/UI/Web/src/app/book-reader/_services/book.service.ts @@ -1,5 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { TextResonse } from 'src/app/_types/text-response'; import { environment } from 'src/environments/environment'; import { BookChapterItem } from '../_models/book-chapter-item'; import { BookInfo } from '../_models/book-info'; @@ -41,7 +42,7 @@ export class BookService { } getBookPage(chapterId: number, page: number) { - return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, {responseType: 'text' as 'json'}); + return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-page?page=' + page, TextResonse); } getBookInfo(chapterId: number) { diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts index 66953d028..5b0e614d7 100644 --- a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts @@ -252,6 +252,13 @@ export class DoubleNoCoverRendererComponent implements OnInit { this.debugLog('Moving forward 2 pages'); return 2; case PAGING_DIRECTION.BACKWARDS: + + if (this.mangaReaderService.isCoverImage(this.pageNum - 1)) { + // TODO: If we are moving back and prev page is cover and we are not showing on right side, then move back twice as if we did once, we would show pageNum twice + this.debugLog('Moving back 1 page as on cover image'); + return 2; + } + if (this.mangaReaderService.isCoverImage(this.pageNum)) { this.debugLog('Moving back 1 page as on cover image'); return 2; diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 14eca4e73..0da5f8472 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -670,7 +670,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe return; } - this.seriesService.updateRating(this.series?.id, this.series?.userRating, this.series?.userReview).subscribe(() => { + this.seriesService.updateRating(this.series?.id, rating, this.series?.userReview).subscribe(() => { + this.series.userRating = rating; this.createHTML(); }); } diff --git a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html index ddbc9eda2..8b1cdf1ac 100644 --- a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html +++ b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html @@ -15,7 +15,7 @@
  • {{item}} - diff --git a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html index 0002d677a..13f79ccbf 100644 --- a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html @@ -1,7 +1,7 @@
    - + {{totalPagesRead | compactNumber}}
    @@ -10,7 +10,7 @@
    - + {{totalWordsRead | compactNumber}}
    diff --git a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts index 9119c3796..c450cdf00 100644 --- a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts +++ b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts @@ -1,4 +1,9 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { CompactNumberPipe } from 'src/app/pipe/compact-number.pipe'; +import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service'; +import { StatisticsService } from 'src/app/_services/statistics.service'; +import { GenericListModalComponent } from '../_modals/generic-list-modal/generic-list-modal.component'; @Component({ selector: 'app-user-stats-info-cards', @@ -15,9 +20,27 @@ export class UserStatsInfoCardsComponent implements OnInit { @Input() lastActive: string = ''; @Input() avgHoursPerWeekSpentReading: number = 0; - constructor() { } + constructor(private statsService: StatisticsService, private modalService: NgbModal) { } ngOnInit(): void { } + openPageByYearList() { + const numberPipe = new CompactNumberPipe(); + this.statsService.getPagesPerYear().subscribe(yearCounts => { + const ref = this.modalService.open(GenericListModalComponent, { scrollable: true }); + ref.componentInstance.items = yearCounts.map(t => `${t.name}: ${numberPipe.transform(t.value)} pages`); + ref.componentInstance.title = 'Pages Read By Year'; + }); + } + + openWordByYearList() { + const numberPipe = new CompactNumberPipe(); + this.statsService.getWordsPerYear().subscribe(yearCounts => { + const ref = this.modalService.open(GenericListModalComponent, { scrollable: true }); + ref.componentInstance.items = yearCounts.map(t => `${t.name}: ${numberPipe.transform(t.value)} pages`); + ref.componentInstance.title = 'Words Read By Year'; + }); + } + } diff --git a/UI/Web/src/index.html b/UI/Web/src/index.html index befd1b415..dfed2c574 100644 --- a/UI/Web/src/index.html +++ b/UI/Web/src/index.html @@ -19,7 +19,7 @@ - + diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index b1d3f2334..94a6c89bd 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -2,7 +2,6 @@ // Import themes which define the css variables we use to customize the app -@import './theme/themes/light'; @import './theme/themes/dark'; // Import colors for overrides of bootstrap theme diff --git a/UI/Web/src/theme/_variables.scss b/UI/Web/src/theme/_variables.scss index 26f9136e4..10628e73e 100644 --- a/UI/Web/src/theme/_variables.scss +++ b/UI/Web/src/theme/_variables.scss @@ -21,6 +21,6 @@ $grid-breakpoints-xl: 1200px; $grid-breakpoints: (xs: $grid-breakpoints-xs, sm: $grid-breakpoints-sm, md: $grid-breakpoints-md, lg: $grid-breakpoints-lg, xl: $grid-breakpoints-xl); // Override any bootstrap styles we don't want -:root { - --hr-color: transparent; -} +// :root { +// --hr-color: transparent; +// } diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index 0d5786d26..413735eea 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -1,4 +1,6 @@ -:root, :root .bg-dark { +// +:root, :root .default { + --theme-color: #000000; --color-scheme: dark; --primary-color: #4ac694; --primary-color-dark-shade: #3B9E76; @@ -240,4 +242,7 @@ /* List Card Item */ --card-list-item-bg-color: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%); + + /* Bootstrap overrides */ + --hr-color: transparent; } diff --git a/openapi.json b/openapi.json index e08a99e25..51847c981 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.6.1.24" + "version": "0.6.1.26" }, "servers": [ { @@ -7712,17 +7712,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ServerStatistics" + "$ref": "#/components/schemas/ServerStatisticsDto" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ServerStatistics" + "$ref": "#/components/schemas/ServerStatisticsDto" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ServerStatistics" + "$ref": "#/components/schemas/ServerStatisticsDto" } } } @@ -8119,6 +8119,108 @@ } } }, + "/api/Stats/pages-per-year": { + "get": { + "tags": [ + "Stats" + ], + "summary": "Returns a count of pages read per year for a given userId.", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "If userId is 0 and user is not an admin, API will default to userId", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + } + } + } + } + } + }, + "/api/Stats/words-per-year": { + "get": { + "tags": [ + "Stats" + ], + "summary": "Returns a count of words read per year for a given userId.", + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "If userId is 0 and user is not an admin, API will default to userId", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Int32StatCount" + } + } + } + } + } + } + } + }, "/api/Tachiyomi/latest-chapter": { "get": { "tags": [ @@ -10333,7 +10435,7 @@ "count": { "type": "integer", "description": "Number of pages read", - "format": "int32" + "format": "int64" }, "format": { "$ref": "#/components/schemas/MangaFormat" @@ -10362,7 +10464,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -10901,7 +11003,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -11145,7 +11247,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -11277,7 +11379,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -11563,7 +11665,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -12590,7 +12692,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -13186,7 +13288,7 @@ }, "additionalProperties": false }, - "ServerStatistics": { + "ServerStatisticsDto": { "type": "object", "properties": { "chapterCount": { @@ -13270,7 +13372,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false @@ -14113,7 +14215,7 @@ }, "count": { "type": "integer", - "format": "int32" + "format": "int64" } }, "additionalProperties": false