diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index f29bcb9b5..468c22681 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Progress; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index a8de09145..3b9f8cdda 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using API.Data.ManualMigrations; +using API.DTOs.Progress; using API.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -29,4 +30,15 @@ public class AdminController : BaseApiController var users = await _userManager.GetUsersInRoleAsync("Admin"); return users.Count > 0; } + + /// + /// Set the progress information for a particular user + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("update-chapter-progress")] + public async Task> UpdateChapterProgress(UpdateUserProgressDto dto) + { + return Ok(await Task.FromResult(false)); + } } diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 26f6871d1..a019255a8 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -3,10 +3,12 @@ using System.Collections.Generic; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.Entities.Metadata; using API.Extensions; using API.Services; +using API.Services.Plus; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -23,14 +25,16 @@ public class CollectionController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly ICollectionTagService _collectionService; private readonly ILocalizationService _localizationService; + private readonly IExternalMetadataService _externalMetadataService; /// public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService, - ILocalizationService localizationService) + ILocalizationService localizationService, IExternalMetadataService externalMetadataService) { _unitOfWork = unitOfWork; _collectionService = collectionService; _localizationService = localizationService; + _externalMetadataService = externalMetadataService; } /// @@ -168,4 +172,15 @@ public class CollectionController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } + + /// + /// For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record, + /// fetch their Mal interest stacks (including restacks) + /// + /// + [HttpGet("mal-stacks")] + public async Task>> GetMalStacksForUser() + { + return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId())); + } } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 95affd2e9..9769fef81 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -13,6 +13,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.OPDS; +using API.DTOs.Progress; using API.DTOs.Search; using API.Entities; using API.Entities.Enums; diff --git a/API/Controllers/PanelsController.cs b/API/Controllers/PanelsController.cs index c53b68f86..d6cdbee2f 100644 --- a/API/Controllers/PanelsController.cs +++ b/API/Controllers/PanelsController.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs; +using API.DTOs.Progress; using API.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index dd2ec1575..6e870c6ea 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -7,8 +7,8 @@ using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; -using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.Progress; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; @@ -880,4 +880,21 @@ public class ReaderController : BaseApiController await _unitOfWork.CommitAsync(); return Ok(); } + + /// + /// Get all progress events for a given chapter + /// + /// + /// + [HttpGet("all-chapter-progress")] + public async Task>> GetProgressForChapter(int chapterId) + { + if (User.IsInRole(PolicyConstants.AdminRole)) + { + return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId)); + } + + return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, User.GetUserId())); + + } } diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs index 9707bbf61..685f3e2a1 100644 --- a/API/Controllers/ScrobblingController.cs +++ b/API/Controllers/ScrobblingController.cs @@ -52,6 +52,23 @@ public class ScrobblingController : BaseApiController return Ok(user.AniListAccessToken); } + /// + /// Get the current user's MAL token & username + /// + /// + [HttpGet("mal-token")] + public async Task> GetMalToken() + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Unauthorized(); + + return Ok(new MalUserInfoDto() + { + Username = user.MalUserName, + AccessToken = user.MalAccessToken + }); + } + /// /// Update the current user's AniList token /// @@ -76,6 +93,26 @@ public class ScrobblingController : BaseApiController return Ok(); } + /// + /// Update the current user's MAL token (Client ID) and Username + /// + /// + /// + [HttpPost("update-mal-token")] + public async Task UpdateMalToken(MalUserInfoDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Unauthorized(); + + user.MalAccessToken = dto.AccessToken; + user.MalUserName = dto.Username; + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + /// /// Checks if the current Scrobbling token for the given Provider has expired for the current user /// diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index e0339309b..08168ddc8 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -457,6 +457,7 @@ public class SettingsController : BaseApiController } } + /// /// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup. /// diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index 9654abef6..a003551a1 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -8,6 +8,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Plus; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -22,14 +23,16 @@ public class StatsController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly UserManager _userManager; private readonly ILocalizationService _localizationService; + private readonly ILicenseService _licenseService; public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, - UserManager userManager, ILocalizationService localizationService) + UserManager userManager, ILocalizationService localizationService, ILicenseService licenseService) { _statService = statService; _unitOfWork = unitOfWork; _userManager = userManager; _localizationService = localizationService; + _licenseService = licenseService; } [HttpGet("user/{userId}/read")] @@ -181,6 +184,18 @@ public class StatsController : BaseApiController return Ok(_statService.GetWordsReadCountByYear(userId)); } - + /// + /// Returns for Kavita+ the number of Series that have been processed, errored, and not processed + /// + /// + [Authorize("RequireAdminRole")] + [HttpGet("kavitaplus-metadata-breakdown")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetKavitaPlusMetadataBreakdown() + { + if (!await _licenseService.HasActiveLicense()) + return BadRequest("This data is not available for non-Kavita+ servers"); + return Ok(await _statService.GetKavitaPlusMetadataBreakdown()); + } } diff --git a/API/DTOs/Collection/MalStackDto.cs b/API/DTOs/Collection/MalStackDto.cs new file mode 100644 index 000000000..3144f6c72 --- /dev/null +++ b/API/DTOs/Collection/MalStackDto.cs @@ -0,0 +1,19 @@ +namespace API.DTOs.Collection; + +/// +/// Represents an Interest Stack from MAL +/// +public class MalStackDto +{ + public required string Title { get; set; } + public required long StackId { get; set; } + public required string Url { get; set; } + public required string? Author { get; set; } + public required int SeriesCount { get; set; } + public required int RestackCount { get; set; } + /// + /// If an existing collection exists within Kavita + /// + /// This is filled out from Kavita and not Kavita+ + public int ExistingId { get; set; } +} diff --git a/API/DTOs/Progress/FullProgressDto.cs b/API/DTOs/Progress/FullProgressDto.cs new file mode 100644 index 000000000..7d0b47f60 --- /dev/null +++ b/API/DTOs/Progress/FullProgressDto.cs @@ -0,0 +1,19 @@ +using System; + +namespace API.DTOs.Progress; + +/// +/// A full progress Record from the DB (not all data, only what's needed for API) +/// +public class FullProgressDto +{ + public int Id { get; set; } + public int ChapterId { get; set; } + public int PagesRead { get; set; } + public DateTime LastModified { get; set; } + public DateTime LastModifiedUtc { get; set; } + public DateTime Created { get; set; } + public DateTime CreatedUtc { get; set; } + public int AppUserId { get; set; } + public string UserName { get; set; } +} diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/Progress/ProgressDto.cs similarity index 95% rename from API/DTOs/ProgressDto.cs rename to API/DTOs/Progress/ProgressDto.cs index 2a05360c4..9fc9010aa 100644 --- a/API/DTOs/ProgressDto.cs +++ b/API/DTOs/Progress/ProgressDto.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; -namespace API.DTOs; +namespace API.DTOs.Progress; #nullable enable public class ProgressDto diff --git a/API/DTOs/Progress/UpdateUserProgressDto.cs b/API/DTOs/Progress/UpdateUserProgressDto.cs new file mode 100644 index 000000000..2aa77b04e --- /dev/null +++ b/API/DTOs/Progress/UpdateUserProgressDto.cs @@ -0,0 +1,11 @@ +using System; + +namespace API.DTOs.Progress; +#nullable enable + +public class UpdateUserProgressDto +{ + public int PageNum { get; set; } + public DateTime LastModifiedUtc { get; set; } + public DateTime CreatedUtc { get; set; } +} diff --git a/API/DTOs/Scrobbling/MalUserInfoDto.cs b/API/DTOs/Scrobbling/MalUserInfoDto.cs new file mode 100644 index 000000000..407639e2a --- /dev/null +++ b/API/DTOs/Scrobbling/MalUserInfoDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Scrobbling; + +/// +/// Information about a User's MAL connection +/// +public class MalUserInfoDto +{ + public required string Username { get; set; } + /// + /// This is actually the Client Id + /// + public required string AccessToken { get; set; } +} diff --git a/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs b/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs new file mode 100644 index 000000000..9ce44b6fa --- /dev/null +++ b/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs @@ -0,0 +1,17 @@ +namespace API.DTOs.Statistics; + +public class KavitaPlusMetadataBreakdownDto +{ + /// + /// Total amount of Series + /// + public int TotalSeries { get; set; } + /// + /// Series on the Blacklist (errored or bad match) + /// + public int ErroredSeries { get; set; } + /// + /// Completed so far + /// + public int SeriesCompleted { get; set; } +} diff --git a/API/Data/Migrations/20240321173812_UserMalToken.Designer.cs b/API/Data/Migrations/20240321173812_UserMalToken.Designer.cs new file mode 100644 index 000000000..ee182676d --- /dev/null +++ b/API/Data/Migrations/20240321173812_UserMalToken.Designer.cs @@ -0,0 +1,2904 @@ +// +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("20240321173812_UserMalToken")] + partial class UserMalToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.3"); + + 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("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + 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("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + 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("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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("ChapterId"); + + 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("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + 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.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .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("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .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("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + 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("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .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("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .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.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + 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("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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + 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("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + 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.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .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", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .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.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + 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("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .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("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + 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/20240321173812_UserMalToken.cs b/API/Data/Migrations/20240321173812_UserMalToken.cs new file mode 100644 index 000000000..f1b1d3caa --- /dev/null +++ b/API/Data/Migrations/20240321173812_UserMalToken.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class UserMalToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MalAccessToken", + table: "AspNetUsers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "MalUserName", + table: "AspNetUsers", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MalAccessToken", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "MalUserName", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 7a251ffbd..f6be4e431 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -97,6 +97,12 @@ namespace API.Data.Migrations b.Property("LockoutEnd") .HasColumnType("TEXT"); + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + b.Property("NormalizedEmail") .HasMaxLength(256) .HasColumnType("TEXT"); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 1e9aec77b..3b065f2e0 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -5,8 +5,10 @@ using System.Text; using System.Threading.Tasks; using API.Data.ManualMigrations; using API.DTOs; +using API.DTOs.Progress; using API.Entities; using API.Entities.Enums; +using API.Extensions.QueryExtensions; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -36,6 +38,7 @@ public interface IAppUserProgressRepository Task GetLatestProgressForSeries(int seriesId, int userId); Task GetFirstProgressForSeries(int seriesId, int userId); Task UpdateAllProgressThatAreMoreThanChapterPages(); + Task> GetUserProgressForChapter(int chapterId, int userId = 0); } #nullable disable public class AppUserProgressRepository : IAppUserProgressRepository @@ -233,6 +236,33 @@ public class AppUserProgressRepository : IAppUserProgressRepository await _context.Database.ExecuteSqlRawAsync(batchSql); } + /// + /// + /// + /// + /// If 0, will pull all records + /// + public async Task> GetUserProgressForChapter(int chapterId, int userId = 0) + { + return await _context.AppUserProgresses + .WhereIf(userId > 0, p => p.AppUserId == userId) + .Where(p => p.ChapterId == chapterId) + .Include(p => p.AppUser) + .Select(p => new FullProgressDto() + { + AppUserId = p.AppUserId, + ChapterId = p.ChapterId, + PagesRead = p.PagesRead, + Id = p.Id, + Created = p.Created, + CreatedUtc = p.CreatedUtc, + LastModified = p.LastModified, + LastModifiedUtc = p.LastModifiedUtc, + UserName = p.AppUser.UserName + }) + .ToListAsync(); + } + #nullable enable public async Task GetUserProgressAsync(int chapterId, int userId) { diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index f87531e8a..e9eb0cbe3 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -63,6 +63,15 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// Requires Kavita+ Subscription public string? AniListAccessToken { get; set; } + /// + /// The Username of the MAL user + /// + public string? MalUserName { get; set; } + /// + /// The Client ID for the user's MAL account. User should create a client on MAL for this. + /// + public string? MalAccessToken { get; set; } + /// /// A list of Series the user doesn't want scrobbling for /// diff --git a/API/Entities/CollectionTag.cs b/API/Entities/CollectionTag.cs index 2594a9772..a357b55d3 100644 --- a/API/Entities/CollectionTag.cs +++ b/API/Entities/CollectionTag.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities.Metadata; +using API.Services.Plus; using Microsoft.EntityFrameworkCore; namespace API.Entities; @@ -41,6 +43,21 @@ public class CollectionTag public ICollection SeriesMetadatas { get; set; } = null!; + /// + /// Is this Collection tag managed by another system, like Kavita+ + /// + //public bool IsManaged { get; set; } = false; + + /// + /// The last time this Collection was Synchronized. Only applicable for Managed Tags. + /// + //public DateTime LastSynchronized { get; set; } + + /// + /// Who created this Collection (Kavita, or external services) + /// + //public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita; + /// /// Not Used due to not using concurrency update /// diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index f439af9ef..f2f679a84 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -10,6 +10,7 @@ using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.MediaErrors; using API.DTOs.Metadata; +using API.DTOs.Progress; using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Recommendation; diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index f3ad603be..f2478f68e 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Collection; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -61,6 +62,8 @@ public interface IExternalMetadataService /// /// Task GetNewSeriesData(int seriesId, LibraryType libraryType); + + Task> GetStacksForUser(int userId); } public class ExternalMetadataService : IExternalMetadataService @@ -70,7 +73,8 @@ public class ExternalMetadataService : IExternalMetadataService private readonly IMapper _mapper; private readonly ILicenseService _licenseService; private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30); - public static readonly ImmutableArray NonEligibleLibraryTypes = ImmutableArray.Create(LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine); + public static readonly ImmutableArray NonEligibleLibraryTypes = ImmutableArray.Create + (LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine); private readonly SeriesDetailPlusDto _defaultReturn = new() { Recommendations = null, @@ -137,12 +141,15 @@ public class ExternalMetadataService : IExternalMetadataService public async Task ForceKavitaPlusRefresh(int seriesId) { if (!await _licenseService.HasActiveLicense()) return; - // Remove from Blacklist if applicable var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId); if (!IsPlusEligible(libraryType)) return; + + // Remove from Blacklist if applicable await _unitOfWork.ExternalSeriesMetadataRepository.RemoveFromBlacklist(seriesId); + var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId); if (metadata == null) return; + metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache); await _unitOfWork.CommitAsync(); } @@ -170,10 +177,50 @@ public class ExternalMetadataService : IExternalMetadataService // Prefetch SeriesDetail data await GetSeriesDetailPlus(seriesId, libraryType); - // TODO: Fetch Series Metadata + // TODO: Fetch Series Metadata (Summary, etc) } + public async Task> GetStacksForUser(int userId) + { + if (!await _licenseService.HasActiveLicense()) return ArraySegment.Empty; + + // See if this user has Mal account on record + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null || string.IsNullOrEmpty(user.MalUserName) || string.IsNullOrEmpty(user.MalAccessToken)) + { + _logger.LogInformation("User is attempting to fetch MAL Stacks, but missing information on their account"); + return ArraySegment.Empty; + } + try + { + _logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName); + + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var result = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={user.MalUserName}") + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-license-key", license) + .WithHeader("x-installId", HashUtil.ServerToken()) + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .GetJsonAsync>(); + + if (result == null) + { + return ArraySegment.Empty; + } + + return result; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Fetching Kavita+ for MAL Stacks for user {UserName} failed", user.MalUserName); + return ArraySegment.Empty; + } + } + /// /// Retrieves Metadata about a Recommended External Series /// diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 801ac2b33..f440548ca 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -94,6 +94,7 @@ public class ScrobblingService : IScrobblingService ScrobbleProvider.AniList }; + private const string UnknownSeriesErrorMessage = "Series cannot be matched for Scrobbling"; private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling"; @@ -332,15 +333,7 @@ public class ScrobblingService : IScrobblingService await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId), Format = LibraryTypeHelper.GetFormat(series.Library.Type), }; - // NOTE: Not sure how to handle scrobbling specials or handling sending loose leaf volumes - if (evt.VolumeNumber is Parser.SpecialVolumeNumber) - { - evt.VolumeNumber = 0; - } - if (evt.VolumeNumber is Parser.DefaultChapterNumber) - { - evt.VolumeNumber = 0; - } + _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); _logger.LogDebug("Added Scrobbling Read update on {SeriesName} with Userid {UserId} ", series.Name, userId); @@ -826,6 +819,20 @@ public class ScrobblingService : IScrobblingService try { var data = await createEvent(evt); + // We need to handle the encoding and changing it to the old one until we can update the API layer to handle these + // which could happen in v0.8.3 + if (data.VolumeNumber is Parser.SpecialVolumeNumber) + { + data.VolumeNumber = 0; + } + if (data.VolumeNumber is Parser.DefaultChapterNumber) + { + data.VolumeNumber = 0; + } + if (data.ChapterNumber is Parser.DefaultChapterNumber) + { + data.ChapterNumber = 0; + } userRateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, license.Value, evt); evt.IsProcessed = true; evt.ProcessDateUtc = DateTime.UtcNow; @@ -870,6 +877,7 @@ public class ScrobblingService : IScrobblingService } } + private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent) { var userProviders = GetUserProviders(readEvent.AppUser); diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index d8c62a245..c25b7e327 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -9,6 +9,7 @@ using API.Comparators; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Progress; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 5788c9e12..ea49f353d 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -9,6 +9,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -33,6 +34,7 @@ public interface IStatisticService IEnumerable> GetWordsReadCountByYear(int userId = 0); Task UpdateServerStatistics(); Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds); + Task GetKavitaPlusMetadataBreakdown(); } /// @@ -531,6 +533,29 @@ public class StatisticService : IStatisticService p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))); } + public async Task GetKavitaPlusMetadataBreakdown() + { + // We need to count number of Series that have an external series record + // Then count how many series are blacklisted + // Then get total count of series that are Kavita+ eligible + var plusLibraries = await _context.Library + .Where(l => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(l.Type)) + .Select(l => l.Id) + .ToListAsync(); + + var countOfBlacklisted = await _context.SeriesBlacklist.CountAsync(); + var totalSeries = await _context.Series.Where(s => plusLibraries.Contains(s.LibraryId)).CountAsync(); + var seriesWithMetadata = await _context.ExternalSeriesMetadata.CountAsync(); + + return new KavitaPlusMetadataBreakdownDto() + { + TotalSeries = totalSeries, + ErroredSeries = countOfBlacklisted, + SeriesCompleted = seriesWithMetadata + }; + + } + public async Task> GetTopUsers(int days) { var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 293b37c96..48f97f219 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -9,6 +9,7 @@ using API.Entities.Enums; using API.Extensions; using API.Services.Tasks.Scanner.Parser; using API.SignalR; +using ExCSS; using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; @@ -448,22 +449,42 @@ public class ParseScannedFiles var infos = scannedSeries[series].Where(info => info.Volumes == volume.Key).ToList(); IList chapters; var specialTreatment = infos.TrueForAll(info => info.IsSpecial); + var hasAnySpMarker = infos.Exists(info => info.SpecialIndex > 0); + var counter = 0f; - if (specialTreatment) + if (specialTreatment && hasAnySpMarker) { chapters = infos .OrderBy(info => info.SpecialIndex) .ToList(); + + foreach (var chapter in chapters) + { + chapter.IssueOrder = counter; + counter++; + } + return; } - else + + + chapters = infos + .OrderByNatural(info => info.Chapters) + .ToList(); + + + // If everything is a special but we don't have any SpecialIndex, then order naturally and use 0, 1, 2 + if (specialTreatment) { - chapters = infos - .OrderByNatural(info => info.Chapters) - .ToList(); + foreach (var chapter in chapters) + { + chapter.IssueOrder = counter; + counter++; + } + return; } - var counter = 0f; + counter = 0f; var prevIssue = string.Empty; foreach (var chapter in chapters) { diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 363d4aaff..8be98330f 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -95,6 +95,11 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag // Patch in other information from ComicInfo UpdateFromComicInfo(ret); + if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter) + { + ret.IsSpecial = true; + } + // v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number if (ret.IsSpecial) { diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs index 8c7c00b83..e4b9a7ea6 100644 --- a/API/Services/Tasks/Scanner/Parser/BookParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -22,6 +22,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series))) { + // NOTE: I'm not sure the comment is true. I've never seen this triggered // This is likely a light novel for which we can set series from parsed title info.Series = Parser.ParseSeries(info.Title); info.Volumes = Parser.ParseVolume(info.Title); @@ -30,6 +31,12 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer { var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo); info.Merge(info2); + if (type == LibraryType.LightNovel && hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series) + .Equals(Parser.LooseLeafVolume)) + { + // Override the Series name so it groups appropriately + info.Series = info2.Series; + } } } diff --git a/UI/Web/src/app/_models/collection/mal-stack.ts b/UI/Web/src/app/_models/collection/mal-stack.ts new file mode 100644 index 000000000..5868a202d --- /dev/null +++ b/UI/Web/src/app/_models/collection/mal-stack.ts @@ -0,0 +1,8 @@ +export interface MalStack { + title: string; + stackId: number; + url: string; + author?: string; + seriesCount: number; + restackCount: number; +} diff --git a/UI/Web/src/app/_models/readers/full-progress.ts b/UI/Web/src/app/_models/readers/full-progress.ts new file mode 100644 index 000000000..2b34be267 --- /dev/null +++ b/UI/Web/src/app/_models/readers/full-progress.ts @@ -0,0 +1,11 @@ +export interface FullProgress { + id: number; + chapterId: number; + pagesRead: number; + lastModified: string; + lastModifiedUtc: string; + created: string; + createdUtc: string; + appUserId: number; + userName: string; +} diff --git a/UI/Web/src/app/_services/collection-tag.service.ts b/UI/Web/src/app/_services/collection-tag.service.ts index 3e4b8b508..7d1991fc5 100644 --- a/UI/Web/src/app/_services/collection-tag.service.ts +++ b/UI/Web/src/app/_services/collection-tag.service.ts @@ -5,6 +5,7 @@ import { environment } from 'src/environments/environment'; import { CollectionTag } from '../_models/collection-tag'; import { TextResonse } from '../_types/text-response'; import { ImageService } from './image.service'; +import {MalStack} from "../_models/collection/mal-stack"; @Injectable({ providedIn: 'root' @@ -45,4 +46,8 @@ export class CollectionTagService { deleteTag(tagId: number) { return this.httpClient.delete(this.baseUrl + 'collection?tagId=' + tagId, TextResonse); } + + getMalStacks() { + return this.httpClient.get>(this.baseUrl + 'collection/mal-stacks'); + } } diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index f4d7dbc34..e6fee578a 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -23,7 +23,9 @@ export class LibraryService { constructor(private httpClient: HttpClient, private readonly messageHub: MessageHubService, private readonly destroyRef: DestroyRef) { this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(e => e.event === EVENTS.LibraryModified), tap((e) => { - this.libraryNames = undefined; + console.log('LibraryModified event came in, clearing library name cache'); + this.libraryNames = undefined; + this.libraryTypes = undefined; })).subscribe(); } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index bd91c78cb..9215b7d2b 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -18,6 +18,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {PersonalToC} from "../_models/readers/personal-toc"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import NoSleep from 'nosleep.js'; +import {FullProgress} from "../_models/readers/full-progress"; export const CHAPTER_ID_DOESNT_EXIST = -1; @@ -155,6 +156,10 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/progress', {libraryId, seriesId, volumeId, chapterId, pageNum: page, bookScrollId}); } + getAllProgressForChapter(chapterId: number) { + return this.httpClient.get>(this.baseUrl + 'reader/all-chapter-progress?chapterId=' + chapterId); + } + markVolumeRead(seriesId: number, volumeId: number) { return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId}); } diff --git a/UI/Web/src/app/_services/scrobbling.service.ts b/UI/Web/src/app/_services/scrobbling.service.ts index 9edca977c..c77a66ba3 100644 --- a/UI/Web/src/app/_services/scrobbling.service.ts +++ b/UI/Web/src/app/_services/scrobbling.service.ts @@ -36,10 +36,18 @@ export class ScrobblingService { return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token}); } + updateMalToken(username: string, accessToken: string) { + return this.httpClient.post(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken}); + } + getAniListToken() { return this.httpClient.get(this.baseUrl + 'scrobbling/anilist-token', TextResonse); } + getMalToken() { + return this.httpClient.get<{username: string, accessToken: string}>(this.baseUrl + 'scrobbling/mal-token'); + } + getScrobbleErrors() { return this.httpClient.get>(this.baseUrl + 'scrobbling/scrobble-errors'); } diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index f30eb26aa..2e2173e6a 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -14,6 +14,7 @@ import { PublicationStatus } from '../_models/metadata/publication-status'; import { MangaFormat } from '../_models/manga-format'; import { TextResonse } from '../_types/text-response'; import {TranslocoService} from "@ngneat/transloco"; +import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown"; export enum DayOfWeek { @@ -115,4 +116,8 @@ export class StatisticsService { getDayBreakdown( userId = 0) { return this.httpClient.get>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId); } + + getKavitaPlusMetadataBreakdown() { + return this.httpClient.get(this.baseUrl + 'stats/kavitaplus-metadata-breakdown'); + } } diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index 7819fd0b8..b9cd26381 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -11,12 +11,13 @@
- + @if(pagination) { + + }
@@ -43,9 +44,12 @@ - - - + @if (events.length === 0) { + + + + } +
{{t('no-data')}}
{{t('no-data')}}
{{item.createdUtc | utcToLocalTime | defaultValue}} @@ -60,25 +64,28 @@ {{item.seriesName}} - - - @if(item.volumeNumber === SpecialVolumeNumber) { - {{t('chapter-num', {num: item.volumeNumber})}} - } @else if (item.chapterNumber === LooseLeafOrDefaultNumber) { + @switch (item.scrobbleEventType) { + @case (ScrobbleEventType.ChapterRead) { + @if(item.volumeNumber === LooseLeafOrDefaultNumber) { + {{t('chapter-num', {num: item.chapterNumber})}} + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber) { {{t('volume-num', {num: item.volumeNumber})}} - } @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) { - - } @else { + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) { + Special + } + @else { {{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}} } - - + } + @case (ScrobbleEventType.ScoreUpdated) { {{t('rating', {r: item.rating})}} - - + } + @default { {{t('not-applicable')}} - - + } + } @if(item.isProcessed) { diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index b057c7647..88fa503e0 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -21,7 +21,8 @@ import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapt @Component({ selector: 'app-user-scrobble-history', standalone: true, - imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip], + imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, + DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip], templateUrl: './user-scrobble-history.component.html', styleUrls: ['./user-scrobble-history.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.ts b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.ts index 52d22d654..a82b66be0 100644 --- a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.ts +++ b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.ts @@ -1,17 +1,13 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {ToastrService} from 'ngx-toastr'; import {take} from 'rxjs'; import {SettingsService} from '../settings.service'; import {ServerSettings} from '../_models/server-settings'; import { - NgbAccordionBody, - NgbAccordionButton, - NgbAccordionCollapse, - NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import {NgForOf, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; +import {NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; import {translate, TranslocoModule} from "@ngneat/transloco"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component"; @@ -23,8 +19,7 @@ import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component"; standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe, - ManageAlertsComponent, NgbAccordionBody, NgbAccordionButton, NgbAccordionCollapse, NgbAccordionDirective, - NgbAccordionHeader, NgbAccordionItem, NgForOf, TitleCasePipe] + ManageAlertsComponent, TitleCasePipe] }) export class ManageEmailSettingsComponent implements OnInit { diff --git a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.html b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.html index 6d0172920..3ea157b65 100644 --- a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.html +++ b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.html @@ -9,15 +9,19 @@ - - - + } + Bulk Actions diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html index 5016bf715..9c4846c1c 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html @@ -32,59 +32,6 @@ - - - -
-
-
{{t('writers-title')}}
- - - - - - - -
-
-
{{t('genres-title')}}
- - - - {{item.title}} - - - -
-
-
-
-
{{t('publishers-title')}}
- - - - - - - -
-
-
{{t('tags-title')}}
- - - - {{item.title}} - - - -
-
- - - {{t('not-defined')}} - -
- @@ -96,6 +43,13 @@ +
  • + {{t(tabs[TabID.Progress].title)}} + + + +
  • +
  • {{t(tabs[TabID.Cover].title)}} @@ -113,8 +67,7 @@ }
    • - - +
      diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts index 592603ca0..63351976e 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -50,18 +50,20 @@ import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component"; import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; +import {EditChapterProgressComponent} from "../edit-chapter-progress/edit-chapter-progress.component"; enum TabID { General = 0, Metadata = 1, Cover = 2, - Files = 3 + Progress = 3, + Files = 4 } @Component({ selector: 'app-card-detail-drawer', standalone: true, - imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective], + imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective, EditChapterProgressComponent], templateUrl: './card-detail-drawer.component.html', styleUrls: ['./card-detail-drawer.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -106,6 +108,7 @@ export class CardDetailDrawerComponent implements OnInit { {title: 'general-tab', disabled: false}, {title: 'metadata-tab', disabled: false}, {title: 'cover-tab', disabled: false}, + {title: 'progress-tab', disabled: false}, {title: 'info-tab', disabled: false} ]; active = this.tabs[0]; diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html new file mode 100644 index 000000000..201044913 --- /dev/null +++ b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html @@ -0,0 +1,48 @@ + + + + + + + + + + + + + @for(rowForm of items.controls; track rowForm; let idx = $index) { + + + + + + + + + + + + + + + + + + + + } + +
      {{t('user-header')}}{{t('page-read-header')}}{{t('date-created-header')}}{{t('date-updated-header')}}
      + {{progressEvents[idx].userName}} + + @if(editMode[idx]) { + + } @else { + {{progressEvents[idx].pagesRead}} + } + + {{progressEvents[idx].createdUtc}} + + {{progressEvents[idx].lastModifiedUtc}} +
      +
      diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.scss b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts new file mode 100644 index 000000000..d07283065 --- /dev/null +++ b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts @@ -0,0 +1,80 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; +import {Chapter} from "../../_models/chapter"; +import {AsyncPipe, NgForOf, TitleCasePipe} from "@angular/common"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {FullProgress} from "../../_models/readers/full-progress"; +import {ReaderService} from "../../_services/reader.service"; +import {TranslocoDirective} from "@ngneat/transloco"; +import {FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; + +@Component({ + selector: 'app-edit-chapter-progress', + standalone: true, + imports: [ + AsyncPipe, + DefaultValuePipe, + NgForOf, + TitleCasePipe, + UtcToLocalTimePipe, + TranslocoDirective, + ReactiveFormsModule + ], + templateUrl: './edit-chapter-progress.component.html', + styleUrl: './edit-chapter-progress.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditChapterProgressComponent implements OnInit { + + private readonly readerService = inject(ReaderService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly fb = inject(FormBuilder); + + @Input({required: true}) chapter!: Chapter; + + progressEvents: Array = []; + editMode: {[key: number]: boolean} = {}; + formGroup = this.fb.group({ + items: this.fb.array([]) + }); + + get items() { + return this.formGroup.get('items') as FormArray; + } + + + ngOnInit() { + this.readerService.getAllProgressForChapter(this.chapter!.id).subscribe(res => { + this.progressEvents = res; + this.progressEvents.forEach((v, i) => { + this.editMode[i] = false; + this.items.push(this.createRowForm(v)); + }); + this.cdRef.markForCheck(); + }); + } + + createRowForm(progress: FullProgress): FormGroup { + return this.fb.group({ + pagesRead: [progress.pagesRead, [Validators.required, Validators.min(0), Validators.max(this.chapter!.pages)]], + created: [progress.createdUtc, [Validators.required]], + lastModified: [progress.lastModifiedUtc, [Validators.required]], + }); + } + + edit(progress: FullProgress, idx: number) { + this.editMode[idx] = !this.editMode[idx]; + this.cdRef.markForCheck(); + } + + save(progress: FullProgress, idx: number) { + // todo + this.editMode[idx] = !this.editMode[idx]; + // this.formGroup[idx + ''].patchValue({ + // pagesRead: progress.pagesRead, + // // Patch other form values as needed + // }); + this.cdRef.markForCheck(); + } + +} diff --git a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html index 7cd9a3165..606e53b1f 100644 --- a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html +++ b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html @@ -1,6 +1,6 @@ -
      +
      @@ -90,11 +90,8 @@ @@ -117,6 +114,15 @@
      + + +
      +
      + + {{chapter.sortOrder}} + +
      +
      diff --git a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts index 23a466450..5e32e8cda 100644 --- a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts +++ b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts @@ -10,7 +10,6 @@ import { UtilityService } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { ChapterMetadata } from 'src/app/_models/metadata/chapter-metadata'; import { HourEstimateRange } from 'src/app/_models/series-detail/hour-estimate-range'; -import { LibraryType } from 'src/app/_models/library/library'; import { MangaFormat } from 'src/app/_models/manga-format'; import { AgeRating } from 'src/app/_models/metadata/age-rating'; import { Volume } from 'src/app/_models/volume'; @@ -29,17 +28,27 @@ import {TranslocoModule} from "@ngneat/transloco"; import {TranslocoLocaleModule} from "@ngneat/transloco-locale"; import {FilterField} from "../../_models/metadata/v2/filter-field"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {ImageComponent} from "../../shared/image/image.component"; @Component({ selector: 'app-entity-info-cards', standalone: true, - imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule, UtcToLocalTimePipe], + imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, + AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule, + UtcToLocalTimePipe, ImageComponent], templateUrl: './entity-info-cards.component.html', styleUrls: ['./entity-info-cards.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class EntityInfoCardsComponent implements OnInit { + protected readonly AgeRating = AgeRating; + protected readonly MangaFormat = MangaFormat; + protected readonly FilterField = FilterField; + + public readonly imageService = inject(ImageService); + + @Input({required: true}) entity!: Volume | Chapter; @Input({required: true}) libraryId!: number; /** @@ -48,7 +57,7 @@ export class EntityInfoCardsComponent implements OnInit { @Input() includeMetadata: boolean = false; /** - * Hide more system based fields, like Id or Date Added + * Hide more system based fields, like id or Date Added */ @Input() showExtendedProperties: boolean = true; @@ -62,22 +71,6 @@ export class EntityInfoCardsComponent implements OnInit { readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1}; size: number = 0; - imageService = inject(ImageService); - - get LibraryType() { - return LibraryType; - } - - get MangaFormat() { - return MangaFormat; - } - - get AgeRating() { - return AgeRating; - } - - get FilterField() { return FilterField; } - get WebLinks() { if (this.chapter.webLinks === '') return []; return this.chapter.webLinks.split(','); diff --git a/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.html b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.html new file mode 100644 index 000000000..9ac631305 --- /dev/null +++ b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.html @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.scss b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts new file mode 100644 index 000000000..77b8b5809 --- /dev/null +++ b/UI/Web/src/app/collections/_components/import-mal-collection-modal/import-mal-collection-modal.component.ts @@ -0,0 +1,40 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; +import {TranslocoDirective} from "@ngneat/transloco"; +import {ReactiveFormsModule} from "@angular/forms"; +import {Select2Module} from "ng-select2-component"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {CollectionTagService} from "../../../_services/collection-tag.service"; +import {MalStack} from "../../../_models/collection/mal-stack"; + +@Component({ + selector: 'app-import-mal-collection-modal', + standalone: true, + imports: [ + TranslocoDirective, + ReactiveFormsModule, + Select2Module + ], + templateUrl: './import-mal-collection-modal.component.html', + styleUrl: './import-mal-collection-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ImportMalCollectionModalComponent { + + protected readonly ngbModal = inject(NgbActiveModal); + protected readonly collectionService = inject(CollectionTagService); + protected readonly cdRef = inject(ChangeDetectorRef); + + stacks: Array = []; + isLoading = true; + + constructor() { + this.collectionService.getMalStacks().subscribe(stacks => { + this.stacks = stacks; + this.isLoading = false; + this.cdRef.markForCheck(); + }) + + } + + +} diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index 550a4049e..aa765b59a 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -7,6 +7,7 @@
      {{t('common.series-count', {num: pagination.totalItems | number})}}
      + = new EventEmitter(); jumpKeys: Array = []; + bulkLoader: boolean = false; tabs: Array<{title: string, fragment: string, icon: string}> = [ {title: 'library-tab', fragment: '', icon: 'fa-landmark'}, {title: 'recommended-tab', fragment: 'recommended', icon: 'fa-award'}, ]; active = this.tabs[0]; - private readonly destroyRef = inject(DestroyRef); - private readonly metadataService = inject(MetadataService); - private readonly cdRef = inject(ChangeDetectorRef); - bulkActionCallback = (action: ActionItem, data: any) => { + bulkActionCallback = async (action: ActionItem, data: any) => { const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series'); const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndices.includes(index + '')); @@ -123,7 +126,14 @@ export class LibraryDetailComponent implements OnInit { }); break; case Action.Delete: - this.actionService.deleteMultipleSeries(selectedSeries, (successful) => { + if (selectedSeries.length > 25) { + this.bulkLoader = true; + this.cdRef.markForCheck(); + } + + await this.actionService.deleteMultipleSeries(selectedSeries, (successful) => { + this.bulkLoader = false; + this.cdRef.markForCheck(); if (!successful) return; this.bulkSelectionService.deselectAll(); this.loadPage(); diff --git a/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.html b/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.html index aa57fbf6e..a119adc9c 100644 --- a/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.html +++ b/UI/Web/src/app/reading-list/_modals/import-cbl-modal/import-cbl-modal.component.html @@ -1,7 +1,7 @@ + diff --git a/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.scss b/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.scss new file mode 100644 index 000000000..b467f2366 --- /dev/null +++ b/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.scss @@ -0,0 +1,18 @@ +.dashboard-card-content { + max-width: 400px; + height: auto; + box-sizing:border-box; +} + +.day-breakdown-chart { + width: 100%; + margin: 0 auto; + max-width: 400px; +} + +.error { + color: red; +} +.completed { + color: var(--color-5); +} diff --git a/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.ts b/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.ts new file mode 100644 index 000000000..cb42975ee --- /dev/null +++ b/UI/Web/src/app/statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component.ts @@ -0,0 +1,40 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; +import {StatisticsService} from "../../../_services/statistics.service"; +import {KavitaPlusMetadataBreakdown} from "../../_models/kavitaplus-metadata-breakdown"; +import {TranslocoDirective} from "@ngneat/transloco"; +import {PercentPipe} from "@angular/common"; + +@Component({ + selector: 'app-kavitaplus-metadata-breakdown-stats', + standalone: true, + imports: [ + TranslocoDirective, + PercentPipe + ], + templateUrl: './kavitaplus-metadata-breakdown-stats.component.html', + styleUrl: './kavitaplus-metadata-breakdown-stats.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class KavitaplusMetadataBreakdownStatsComponent { + private readonly cdRef = inject(ChangeDetectorRef); + private readonly statsService = inject(StatisticsService); + + breakdown: KavitaPlusMetadataBreakdown | undefined; + completedStart!: number; + completedEnd!: number; + errorStart!: number; + errorEnd!: number; + percentDone!: number; + + constructor() { + this.statsService.getKavitaPlusMetadataBreakdown().subscribe(res => { + this.breakdown = res; + this.completedStart = 0; + this.completedEnd = ((res.seriesCompleted - res.erroredSeries) / res.totalSeries); + this.errorStart = this.completedEnd; + this.errorEnd = Math.max(1, ((res.seriesCompleted) / res.totalSeries)); + this.percentDone = res.seriesCompleted / res.totalSeries; + this.cdRef.markForCheck(); + }); + } +} diff --git a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html index 5d82541a2..b457e8ed0 100644 --- a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html +++ b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html @@ -113,9 +113,14 @@
      -
      +
      + @if (accountService.hasValidLicense$ | async) { +
      + +
      + }
      diff --git a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts index 9450a3ee7..0aae8472a 100644 --- a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts +++ b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts @@ -26,6 +26,10 @@ import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common'; import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {FilterField} from "../../../_models/metadata/v2/filter-field"; +import { + KavitaplusMetadataBreakdownStatsComponent +} from "../kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component"; +import {AccountService} from "../../../_services/account.service"; @Component({ selector: 'app-server-stats', @@ -33,12 +37,15 @@ import {FilterField} from "../../../_models/metadata/v2/filter-field"; styleUrls: ['./server-stats.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent, - PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe, - CompactNumberPipe, TimeDurationPipe, BytesPipe, TranslocoDirective] + imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent, + PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe, + CompactNumberPipe, TimeDurationPipe, BytesPipe, TranslocoDirective, KavitaplusMetadataBreakdownStatsComponent] }) export class ServerStatsComponent { + private readonly destroyRef = inject(DestroyRef); + protected readonly accountService = inject(AccountService); + releaseYears$!: Observable>; mostActiveUsers$!: Observable>; mostActiveLibrary$!: Observable>; @@ -54,7 +61,7 @@ export class ServerStatsComponent { breakpointSubject = new ReplaySubject(1); breakpoint$: Observable = this.breakpointSubject.asObservable(); - private readonly destroyRef = inject(DestroyRef); + @HostListener('window:resize', ['$event']) @HostListener('window:orientationchange', ['$event']) diff --git a/UI/Web/src/app/statistics/_models/kavitaplus-metadata-breakdown.ts b/UI/Web/src/app/statistics/_models/kavitaplus-metadata-breakdown.ts new file mode 100644 index 000000000..923965bf4 --- /dev/null +++ b/UI/Web/src/app/statistics/_models/kavitaplus-metadata-breakdown.ts @@ -0,0 +1,5 @@ +export interface KavitaPlusMetadataBreakdown { + totalSeries: number; + erroredSeries: number; + seriesCompleted: number; +} diff --git a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.html b/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.html deleted file mode 100644 index 237ebf1c1..000000000 --- a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.html +++ /dev/null @@ -1,64 +0,0 @@ - -
      -
      -
      -
      -
      -

      {{t('title')}} - @if(!tokenExpired) { - - {{t('token-valid')}} - } @else { - - {{t('token-expired')}} - } -

      - -
      -
      - -
      -
      -
      - - -
      - - - {{t('requires', {product: 'Kavita+'})}} - - - - AniList {{t('token-set')}} - - {{t('token-expired')}} - - - {{t('no-token-set')}} - - -
      -
      - -
      -

      {{t('instructions', {service: 'AniList'})}}

      -
      -
      - - -
      -
      -
      - {{t('generate')}} - -
      -
      - - -
      -
      - - -
      diff --git a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.scss b/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.scss deleted file mode 100644 index bc6ccbdf7..000000000 --- a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.scss +++ /dev/null @@ -1,9 +0,0 @@ -.error { - color: var(--error-color); -} - -.confirm-icon { - color: var(--primary-color); - font-size: 14px; - vertical-align: middle; -} diff --git a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.ts b/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.ts deleted file mode 100644 index 955e7fe15..000000000 --- a/UI/Web/src/app/user-settings/anilist-key/anilist-key.component.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - DestroyRef, - inject, - OnInit -} from '@angular/core'; -import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms"; -import {ToastrService} from "ngx-toastr"; -import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service"; -import {AccountService} from "../../_services/account.service"; -import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; -import { NgIf, NgOptimizedImage } from '@angular/common'; -import {translate, TranslocoDirective} from "@ngneat/transloco"; - -@Component({ - selector: 'app-anilist-key', - templateUrl: './anilist-key.component.html', - styleUrls: ['./anilist-key.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [NgIf, NgOptimizedImage, NgbTooltip, NgbCollapse, ReactiveFormsModule, TranslocoDirective] -}) -export class AnilistKeyComponent implements OnInit { - - hasValidLicense: boolean = false; - - formGroup: FormGroup = new FormGroup({}); - token: string = ''; - isViewMode: boolean = true; - private readonly destroyRef = inject(DestroyRef); - tokenExpired: boolean = false; - - - constructor(public accountService: AccountService, private scrobblingService: ScrobblingService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { - this.accountService.hasValidLicense().subscribe(res => { - this.hasValidLicense = res; - this.cdRef.markForCheck(); - if (this.hasValidLicense) { - this.scrobblingService.getAniListToken().subscribe(token => { - this.token = token; - this.formGroup.get('aniListToken')?.setValue(token); - this.cdRef.markForCheck(); - }); - this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => { - this.tokenExpired = hasExpired; - this.cdRef.markForCheck(); - }); - } - }); - } - - ngOnInit(): void { - this.formGroup.addControl('aniListToken', new FormControl('', [Validators.required])); - } - - - - resetForm() { - this.formGroup.get('aniListToken')?.setValue(''); - this.cdRef.markForCheck(); - } - - saveForm() { - this.scrobblingService.updateAniListToken(this.formGroup.get('aniListToken')!.value).subscribe(() => { - this.toastr.success(translate('toasts.anilist-token-updated')); - this.token = this.formGroup.get('aniListToken')!.value; - this.resetForm(); - this.isViewMode = true; - }); - } - - toggleViewMode() { - this.isViewMode = !this.isViewMode; - this.resetForm(); - } - - -} diff --git a/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.html b/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.html new file mode 100644 index 000000000..5a3a09eda --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.html @@ -0,0 +1,151 @@ + +
      +
      +
      +
      +
      +

      {{t('title')}} + @if(!aniListTokenExpired) { + + {{t('token-valid')}} + } @else { + + {{t('token-expired')}} + } +

      +
      +
      + +
      +
      +
      + + @if(loaded) { + +
      + + @if(!hasValidLicense) { + {{t('requires', {product: 'Kavita+'})}} + } @else { + + AniList + @if(aniListToken && aniListToken.length > 0) { + {{t('token-set')}} + } @else { + {{t('no-token-set')}} + } + @if(aniListTokenExpired) { + + {{t('token-expired')}} + + } + + + + MAL + @if (malToken && malToken.length > 0) { + {{t('token-set')}} + } + @else { + {{t('no-token-set')}} + } + + + @if(malTokenExpired) { + + {{t('token-expired')}} + + } + + @if (!aniListToken && !malToken) { + {{t('no-token-set')}} + } + } + +
      +
      + +
      +

      {{t('generic-instructions')}}

      +
      + +
      +
      +

      + +

      +
      +
      + +

      {{t('instructions', {service: 'AniList'})}}

      +
      + + +
      +
      + {{t('generate')}} + +
      +
      +
      +
      +
      + +
      +

      + +

      +
      +
      + +

      {{t('mal-instructions', {service: 'MAL'})}}

      +
      + + +
      + +
      + + +
      +
      + +
      +
      +
      +
      +
      + +
      +
      +
      + } @else { + + } + + +
      +
      + + +
      diff --git a/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.scss b/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.ts b/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.ts new file mode 100644 index 000000000..ece423c71 --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component.ts @@ -0,0 +1,126 @@ +import {ChangeDetectorRef, Component, ContentChild, DestroyRef, ElementRef, inject, OnInit} from '@angular/core'; +import {NgIf, NgOptimizedImage} from "@angular/common"; +import { + NgbAccordionBody, + NgbAccordionButton, + NgbAccordionCollapse, NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem, + NgbCollapse, + NgbTooltip +} from "@ng-bootstrap/ng-bootstrap"; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; +import {Select2Module} from "ng-select2-component"; +import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {AccountService} from "../../_services/account.service"; +import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service"; +import {ToastrService} from "ngx-toastr"; +import {ManageAlertsComponent} from "../../admin/manage-alerts/manage-alerts.component"; +import {LoadingComponent} from "../../shared/loading/loading.component"; + +@Component({ + selector: 'app-manage-scrobbling-providers', + standalone: true, + imports: [ + NgIf, + NgOptimizedImage, + NgbTooltip, + ReactiveFormsModule, + Select2Module, + TranslocoDirective, + NgbCollapse, + ManageAlertsComponent, + NgbAccordionBody, + NgbAccordionButton, + NgbAccordionCollapse, + NgbAccordionDirective, + NgbAccordionHeader, + NgbAccordionItem, + LoadingComponent, + ], + templateUrl: './manage-scrobbling-providers.component.html', + styleUrl: './manage-scrobbling-providers.component.scss' +}) +export class ManageScrobblingProvidersComponent implements OnInit { + public readonly accountService = inject(AccountService); + private readonly scrobblingService = inject(ScrobblingService); + private readonly toastr = inject(ToastrService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); + + hasValidLicense: boolean = false; + + formGroup: FormGroup = new FormGroup({}); + aniListToken: string = ''; + malToken: string = ''; + malUsername: string = ''; + + aniListTokenExpired: boolean = false; + malTokenExpired: boolean = false; + + isViewMode: boolean = true; + loaded: boolean = false; + + constructor() { + this.accountService.hasValidLicense().subscribe(res => { + this.hasValidLicense = res; + this.cdRef.markForCheck(); + if (this.hasValidLicense) { + this.scrobblingService.getAniListToken().subscribe(token => { + this.aniListToken = token; + this.formGroup.get('aniListToken')?.setValue(token); + this.loaded = true; + this.cdRef.markForCheck(); + }); + this.scrobblingService.getMalToken().subscribe(dto => { + this.malToken = dto.accessToken; + this.malUsername = dto.username; + this.formGroup.get('malToken')?.setValue(this.malToken); + this.formGroup.get('malUsername')?.setValue(this.malUsername); + this.cdRef.markForCheck(); + }); + this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => { + this.aniListTokenExpired = hasExpired; + this.cdRef.markForCheck(); + }); + } + }); + } + + ngOnInit(): void { + this.formGroup.addControl('aniListToken', new FormControl('', [Validators.required])); + this.formGroup.addControl('malClientId', new FormControl('', [Validators.required])); + this.formGroup.addControl('malUsername', new FormControl('', [Validators.required])); + } + + + + resetForm() { + this.formGroup.get('aniListToken')?.setValue(''); + this.formGroup.get('malClientId')?.setValue(''); + this.formGroup.get('malUsername')?.setValue(''); + this.cdRef.markForCheck(); + } + + saveAniListForm() { + this.scrobblingService.updateAniListToken(this.formGroup.get('aniListToken')!.value).subscribe(() => { + this.toastr.success(translate('toasts.anilist-token-updated')); + this.aniListToken = this.formGroup.get('aniListToken')!.value; + this.resetForm(); + this.cdRef.markForCheck(); + }); + } + + saveMalForm() { + this.scrobblingService.updateMalToken(this.formGroup.get('malUsername')!.value, this.formGroup.get('malClientId')!.value).subscribe(() => { + this.toastr.success(translate('toasts.mal-clientId-updated')); + this.malToken = this.formGroup.get('malClientId')!.value; + this.malUsername = this.formGroup.get('malUsername')!.value; + this.resetForm(); + this.cdRef.markForCheck(); + }); + } + + toggleViewMode() { + this.isViewMode = !this.isViewMode; + this.resetForm(); + } +} diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index 33d662a24..6b55ba054 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -13,7 +13,7 @@ - + } @defer (when tab.fragment === FragmentID.Preferences; prefetch on idle) { diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index a57fef24c..b37c1ffe6 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -9,7 +9,7 @@ import { } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import {take, tap} from 'rxjs/operators'; +import {take} from 'rxjs/operators'; import { Title } from '@angular/platform-browser'; import { readingDirections, @@ -39,7 +39,6 @@ import { ManageDevicesComponent } from '../manage-devices/manage-devices.compone import { ThemeManagerComponent } from '../theme-manager/theme-manager.component'; import { ApiKeyComponent } from '../api-key/api-key.component'; import { ColorPickerModule } from 'ngx-color-picker'; -import { AnilistKeyComponent } from '../anilist-key/anilist-key.component'; import { ChangeAgeRestrictionComponent } from '../change-age-restriction/change-age-restriction.component'; import { ChangePasswordComponent } from '../change-password/change-password.component'; import { ChangeEmailComponent } from '../change-email/change-email.component'; @@ -50,6 +49,7 @@ import {LocalizationService} from "../../_services/localization.service"; import {Language} from "../../_models/metadata/language"; import {translate, TranslocoDirective} from "@ngneat/transloco"; import {LoadingComponent} from "../../shared/loading/loading.component"; +import {ManageScrobblingProvidersComponent} from "../manage-scrobbling-providers/manage-scrobbling-providers.component"; enum AccordionPanelID { ImageReader = 'image-reader', @@ -74,10 +74,10 @@ enum FragmentID { changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent, - ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, + ChangePasswordComponent, ChangeAgeRestrictionComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent, ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe, - TranslocoDirective, LoadingComponent], + TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent], }) export class UserPreferencesComponent implements OnInit, OnDestroy { @@ -166,7 +166,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.route.fragment.subscribe(frag => { const tab = this.tabs.filter(item => item.fragment === frag); - console.log('tab: ', tab); if (tab.length > 0) { this.active = tab[0]; } else { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 97a9eb504..454d894f2 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -294,11 +294,17 @@ "no-token-set": "No Token Set", "token-set": "Token Set", "generate": "Generate", + "generic-instructions": "Fill out information about different External Services you have to allow Kavita+ to interact with them.", "instructions": "First time users should click on \"{{scrobbling-providers.generate}}\" below to allow Kavita+ to talk with {{service}}. Once you authorize the program, copy and paste the token in the input below. You can regenerate your token at any time.", + "mal-instructions": "Kavita uses a MAL Client Id for authentication. Create a new Client for Kavita and once approved, supply the client Id and your username.", + "scrobbling-applicable-label": "Scrobbling Applicable", "token-input-label": "{{service}} Token Goes Here", + "mal-token-input-label": "MAL Client Id", + "mal-username-input-label": "MAL Username", "edit": "{{common.edit}}", "cancel": "{{common.cancel}}", - "save": "{{common.save}}" + "save": "{{common.save}}", + "loading": "{{common.loading}}" }, "typeahead": { @@ -937,6 +943,7 @@ "metadata-tab": "Metadata", "cover-tab": "Cover", "info-tab": "Info", + "progress-tab": "Progress", "no-summary": "No Summary available.", "writers-title": "{{series-metadata-detail.writers-title}}", "genres-title": "{{series-metadata-detail.genres-title}}", @@ -1025,6 +1032,7 @@ "id-title": "ID", "links-title": "{{series-metadata-detail.links-title}}", "isbn-title": "ISBN", + "sort-order-title": "Sort Order", "last-read-title": "Last Read", "less-than-hour": "<1 Hour", "range-hours": "{{value}} {{hourWord}}", @@ -1551,6 +1559,23 @@ "promote-tooltip": "Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them." }, + "import-mal-collection-modal": { + "close": "{{common.close}}", + "title": "MAL Interest Stack Import", + "description": "Import your MAL Interest Stacks and create Collections within Kavita", + "series-count": "{{common.series-count}}", + "restack-count": "{{num}} Restacks" + }, + + "edit-chapter-progress": { + "user-header": "User", + "page-read-header": "Pages Read", + "date-created-header": "Created (UTC)", + "date-updated-header": "Last Updated (UTC)", + "action-header": "{{common.edit}}", + "edit-alt": "{{common.edit}}" + }, + "import-cbl-modal": { "close": "{{common.close}}", "title": "CBL Import", @@ -1759,6 +1784,15 @@ "y-axis-label": "Reading Events" }, + "kavitaplus-metadata-breakdown-stats": { + "title": "Kavita+ Metadata Breakdown", + "no-data": "No data", + "errored-series-label": "Errored Series", + "completed-series-label": "Completed Series", + "total-series-progress-label": "Series Processed: {{percent}}", + "complete": "All Series have metadata" + }, + "file-breakdown-stats": { "format-title": "Format", "format-tooltip": "Not Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings modal.", @@ -2125,6 +2159,7 @@ "view-series": "View Series", "clear": "{{common.clear}}", "import-cbl": "Import CBL", + "import-mal-stack": "Import MAL Stack", "read": "Read", "add-rule-group-and": "Add Rule Group (AND)", "add-rule-group-or": "Add Rule Group (OR)", diff --git a/UI/Web/src/theme/components/_progress.scss b/UI/Web/src/theme/components/_progress.scss index 33c52d33e..6516f67fc 100644 --- a/UI/Web/src/theme/components/_progress.scss +++ b/UI/Web/src/theme/components/_progress.scss @@ -1,10 +1,9 @@ .progress { background-color: var(--progress-bg-color); - } .progress-bar { - background-color: var(--progress-bar-color); + background-color: var(--progress-bar-color) !important; } .progress-bar-striped { diff --git a/openapi.json b/openapi.json index 4ee08b420..f573af266 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.7.14.6" + "version": "0.7.14.8" }, "servers": [ { @@ -886,6 +886,55 @@ } } }, + "/api/Admin/update-chapter-progress": { + "post": { + "tags": [ + "Admin" + ], + "summary": "Set the progress information for a particular user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserProgressDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserProgressDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserProgressDto" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "boolean" + } + }, + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, "/api/Book/{chapterId}/book-info": { "get": { "tags": [ @@ -1507,6 +1556,45 @@ } } }, + "/api/Collection/mal-stacks": { + "get": { + "tags": [ + "Collection" + ], + "summary": "For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record,\r\nfetch their Mal interest stacks (including restacks)", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MalStackDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MalStackDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MalStackDto" + } + } + } + } + } + } + } + }, "/api/Device/create": { "post": { "tags": [ @@ -6126,6 +6214,56 @@ } } }, + "/api/Reader/all-chapter-progress": { + "get": { + "tags": [ + "Reader" + ], + "summary": "Get all progress events for a given chapter", + "parameters": [ + { + "name": "chapterId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FullProgressDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FullProgressDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FullProgressDto" + } + } + } + } + } + } + } + }, "/api/ReadingList": { "get": { "tags": [ @@ -7350,6 +7488,35 @@ } } }, + "/api/Scrobbling/mal-token": { + "get": { + "tags": [ + "Scrobbling" + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MalUserInfoDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MalUserInfoDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MalUserInfoDto" + } + } + } + } + } + } + }, "/api/Scrobbling/update-anilist-token": { "post": { "tags": [ @@ -7383,6 +7550,39 @@ } } }, + "/api/Scrobbling/update-mal-token": { + "post": { + "tags": [ + "Scrobbling" + ], + "summary": "Update the current user's MAL token (Client ID) and Username", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MalUserInfoDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MalUserInfoDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/MalUserInfoDto" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/Scrobbling/token-expired": { "get": { "tags": [ @@ -10832,6 +11032,45 @@ } } }, + "/api/Stats/kavitaplus-metadata-breakdown": { + "get": { + "tags": [ + "Stats" + ], + "summary": "Returns for Kavita+ the number of Series that have been processed, errored, and not processed", + "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/Stream/dashboard": { "get": { "tags": [ @@ -12754,6 +12993,16 @@ "description": "The JWT for the user's AniList account. Expires after a year.", "nullable": true }, + "malUserName": { + "type": "string", + "description": "The Username of the MAL user", + "nullable": true + }, + "malAccessToken": { + "type": "string", + "description": "The Client ID for the user's MAL account. User should create a client on MAL for this.", + "nullable": true + }, "scrobbleHolds": { "type": "array", "items": { @@ -15766,6 +16015,49 @@ }, "additionalProperties": false }, + "FullProgressDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "chapterId": { + "type": "integer", + "format": "int32" + }, + "pagesRead": { + "type": "integer", + "format": "int32" + }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "appUserId": { + "type": "integer", + "format": "int32" + }, + "userName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "description": "A full progress Record from the DB (not all data, only what's needed for API)" + }, "Genre": { "type": "object", "properties": { @@ -16277,6 +16569,58 @@ }, "additionalProperties": false }, + "MalStackDto": { + "type": "object", + "properties": { + "title": { + "type": "string", + "nullable": true + }, + "stackId": { + "type": "integer", + "format": "int64" + }, + "url": { + "type": "string", + "nullable": true + }, + "author": { + "type": "string", + "nullable": true + }, + "seriesCount": { + "type": "integer", + "format": "int32" + }, + "restackCount": { + "type": "integer", + "format": "int32" + }, + "existingId": { + "type": "integer", + "description": "If an existing collection exists within Kavita", + "format": "int32" + } + }, + "additionalProperties": false, + "description": "Represents an Interest Stack from MAL" + }, + "MalUserInfoDto": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + }, + "accessToken": { + "type": "string", + "description": "This is actually the Client Id", + "nullable": true + } + }, + "additionalProperties": false, + "description": "Information about a User's MAL connection" + }, "MangaFile": { "type": "object", "properties": { @@ -20133,6 +20477,24 @@ }, "additionalProperties": false }, + "UpdateUserProgressDto": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer", + "format": "int32" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, "UpdateUserReviewDto": { "type": "object", "properties": {