diff --git a/Kavita.API/Database/IUnitOfWork.cs b/Kavita.API/Database/IUnitOfWork.cs index b3ec1b152..b6a560a58 100644 --- a/Kavita.API/Database/IUnitOfWork.cs +++ b/Kavita.API/Database/IUnitOfWork.cs @@ -36,6 +36,7 @@ public interface IUnitOfWork IReadingSessionRepository ReadingSessionRepository { get; } IClientDeviceRepository ClientDeviceRepository { get; } IReadingListRemapRuleRepository RemapRuleRepository { get; } + IKavitaPlusAuditRepository KavitaPlusAuditRepository { get; } /// /// Commits pending changes to the database inside an IMMEDIATE transaction so writer diff --git a/Kavita.API/Repositories/IKavitaPlusAuditRepository.cs b/Kavita.API/Repositories/IKavitaPlusAuditRepository.cs new file mode 100644 index 000000000..28a3a7c95 --- /dev/null +++ b/Kavita.API/Repositories/IKavitaPlusAuditRepository.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Kavita.Common.Helpers; +using Kavita.Models.DTOs.KavitaPlus; +using Kavita.Models.Entities.History; + +namespace Kavita.API.Repositories; + +public interface IKavitaPlusAuditRepository +{ + void Add(KavitaPlusAuditLog entry); + Task DeleteOlderThanAsync(DateTime cutoff, CancellationToken ct = default); + + Task> GetPagedAsync( + KavitaPlusAuditFilterDto filter, UserParams userParams, CancellationToken ct = default); + + Task> GetMyActivityAsync( + int userId, KavitaPlusAuditFilterDto filter, UserParams userParams, CancellationToken ct = default); + + Task GetStatsAsync(CancellationToken ct = default); + + Task GetSeriesInfoAsync( + int seriesId, int callingUserId, bool isAdmin, CancellationToken ct = default); + + Task MarkAsRetriedAsync(long id, CancellationToken ct = default); +} diff --git a/Kavita.API/Services/Plus/IKavitaPlusAuditService.cs b/Kavita.API/Services/Plus/IKavitaPlusAuditService.cs new file mode 100644 index 000000000..10765c550 --- /dev/null +++ b/Kavita.API/Services/Plus/IKavitaPlusAuditService.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Kavita.Models.DTOs.KavitaPlus; +using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.Audit; + +namespace Kavita.API.Services.Plus; + +public interface IKavitaPlusAuditService +{ + Task LogAsync( + KavitaPlusAuditCategory category, + KavitaPlusEventType eventType, + AuditStatus status, + AuditSubjectType subjectType = AuditSubjectType.Global, + int? seriesId = null, + int? subjectId = null, + object? payload = null, + string? error = null, + int? userId = null, + CancellationToken ct = default); + + Task LogMatchAsync(KavitaPlusEventType type, int seriesId, object payload, + AuditStatus status = AuditStatus.Success, string? error = null, CancellationToken ct = default); + + Task LogMetadataAsync(int seriesId, IList changes, CancellationToken ct = default); + + Task LogChapterMetadataAsync(int chapterId, int seriesId, IList changes, + CancellationToken ct = default); + + Task LogPersonAsync(KavitaPlusEventType type, int personId, object payload, + AuditStatus status = AuditStatus.Success, CancellationToken ct = default); + + Task LogCollectionAsync(KavitaPlusEventType type, int collectionId, object payload, + AuditStatus status = AuditStatus.Success, int? userId = null, CancellationToken ct = default); + + Task LogScrobbleAsync(KavitaPlusEventType type, int seriesId, AuditLogScrobbleParamsDto details, + AuditStatus status, string? error = null, int? userId = null, CancellationToken ct = default); + + Task PurgeOldLogsAsync(CancellationToken ct = default); +} diff --git a/Kavita.API/Services/Plus/IScrobblingService.cs b/Kavita.API/Services/Plus/IScrobblingService.cs index 12e9839d3..b69ded6e0 100644 --- a/Kavita.API/Services/Plus/IScrobblingService.cs +++ b/Kavita.API/Services/Plus/IScrobblingService.cs @@ -1,11 +1,9 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Threading; using System.Threading.Tasks; using Hangfire; -using Kavita.API.Services.Helpers; using Kavita.Common.Helpers; +using Kavita.Models.DTOs.KavitaPlus; using Kavita.Models.DTOs.Scrobbling; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; @@ -96,8 +94,10 @@ public interface IScrobblingService Task CreateEventsFromExistingHistory(int userId = 0, CancellationToken ct = default); Task CreateEventsFromExistingHistoryForSeries(int seriesId, CancellationToken ct = default); Task ClearEventsForSeries(int userId, int seriesId, CancellationToken ct = default); + Task RetryScrobbleAsync(int authUserId, KavitaPlusAuditEntryDto auditEntry, CancellationToken ct = default); } +// TODO: Figure out a place to put this that doesn't cause dependency hell public static class ScrobblingHelper { public const string AniListWeblinkWebsite = "https://anilist.co/manga/"; @@ -148,5 +148,4 @@ public static class ScrobblingHelper { return id is null or 0 ? string.Empty : $"{url}{id}/"; } - } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 831df6e75..b26413d21 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -28,4 +28,4 @@ - \ No newline at end of file + diff --git a/Kavita.Database/DataContext.cs b/Kavita.Database/DataContext.cs index c2f4856b1..feec39edd 100644 --- a/Kavita.Database/DataContext.cs +++ b/Kavita.Database/DataContext.cs @@ -104,6 +104,8 @@ public sealed class DataContext : IdentityDbContext DataProtectionKeys { get; set; } = null!; + public DbSet KavitaPlusAuditLogs { get; set; } = null!; + protected override void OnModelCreating(ModelBuilder builder) { @@ -549,6 +551,24 @@ public sealed class DataContext : IdentityDbContext new { a.StartTimeUtc, a.LibraryId }) .HasDatabaseName("IX_ActivityData_StartTimeUtc_LibraryId"); + builder.Entity(entity => + { + entity.HasIndex(e => new { e.Category, e.CreatedUtc }) + .HasDatabaseName("IX_KavitaPlusAuditLog_Category_CreatedUtc"); + entity.HasIndex(e => new { e.SeriesId, e.CreatedUtc }) + .HasDatabaseName("IX_KavitaPlusAuditLog_SeriesId_CreatedUtc"); + entity.HasIndex(e => new { e.SubjectType, e.SubjectId }) + .HasDatabaseName("IX_KavitaPlusAuditLog_SubjectType_SubjectId"); + entity.HasIndex(e => e.CreatedUtc) + .HasDatabaseName("IX_KavitaPlusAuditLog_CreatedUtc"); + entity.HasIndex(e => e.UserId) + .HasDatabaseName("IX_KavitaPlusAuditLog_UserId"); + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.SetNull); + }); + #endregion } diff --git a/Kavita.Database/Extensions/QueryableExtensions.cs b/Kavita.Database/Extensions/QueryableExtensions.cs index 338eb4fb3..f9b61baef 100644 --- a/Kavita.Database/Extensions/QueryableExtensions.cs +++ b/Kavita.Database/Extensions/QueryableExtensions.cs @@ -363,7 +363,7 @@ public static class QueryableExtensions MatchStateOption.All => query, MatchStateOption.Matched => query .Include(s => s.ExternalSeriesMetadata) - .Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted), + .WhereMatchedExternalMetadata(), MatchStateOption.NotMatched => query. Include(s => s.ExternalSeriesMetadata) .Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted && !s.DontMatch), @@ -373,6 +373,28 @@ public static class QueryableExtensions }; } + /// + /// Filters to series that have been successfully matched in Kavita+: + /// has ExternalSeriesMetadata with a populated ValidUntilUtc and is not blacklisted. + /// + public static IQueryable WhereMatchedExternalMetadata(this IQueryable query) + { + return query + .Where(s => !s.IsBlacklisted) + .Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue); + } + + /// + /// Filters to series that are matched but whose cached metadata has expired and needs a refresh. + /// A stale series is still considered matched, the data is just out of date. + /// + public static IQueryable WhereStaleExternalMetadata(this IQueryable query) + { + return query + .Where(s => !s.IsBlacklisted) + .Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata!.ValidUntilUtc < DateTime.UtcNow); + } + /// /// Filters a sequence to elements where the specified key falls within an inclusive range. /// diff --git a/Kavita.Database/Migrations/20260520121345_KavitaPlusAuditLog.Designer.cs b/Kavita.Database/Migrations/20260520121345_KavitaPlusAuditLog.Designer.cs new file mode 100644 index 000000000..732853795 --- /dev/null +++ b/Kavita.Database/Migrations/20260520121345_KavitaPlusAuditLog.Designer.cs @@ -0,0 +1,4796 @@ +// +using System; +using System.Collections.Generic; +using Kavita.Database; +using Kavita.Models.Entities.MetadataMatching; +using Kavita.Models.Entities.Progress; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Kavita.Database.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260520121345_KavitaPlusAuditLog")] + partial class KavitaPlusAuditLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.6"); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + 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("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("Kavita.Models.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("ComicVineId") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("HardcoverId") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("MangaBakaId") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MetronId") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TitleName") + .HasDatabaseName("IX_Chapter_TitleName"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.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("Kavita.Models.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.EpubFont", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("EpubFont"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.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("Kavita.Models.Entities.History.KavitaPlusAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("HasRetried") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubjectId") + .HasColumnType("INTEGER"); + + b.Property("SubjectType") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedUtc") + .HasDatabaseName("IX_KavitaPlusAuditLog_CreatedUtc"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_KavitaPlusAuditLog_UserId"); + + b.HasIndex("Category", "CreatedUtc") + .HasDatabaseName("IX_KavitaPlusAuditLog_Category_CreatedUtc"); + + b.HasIndex("SeriesId", "CreatedUtc") + .HasDatabaseName("IX_KavitaPlusAuditLog_SeriesId_CreatedUtc"); + + b.HasIndex("SubjectType", "SubjectId") + .HasDatabaseName("IX_KavitaPlusAuditLog_SubjectType_SubjectId"); + + b.ToTable("KavitaPlusAuditLogs"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.History.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("Kavita.Models.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("DefaultLanguage") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("InheritWebLinksFromFirstChapter") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.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("Kavita.Models.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("KoreaderHash") + .HasColumnType("TEXT"); + + 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.HasIndex("FilePath") + .HasDatabaseName("IX_MangaFile_FilePath"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .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.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + 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.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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("Kavita.Models.Entities.Metadata.GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.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("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + 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("AgeRating") + .HasDatabaseName("IX_SeriesMetadata_AgeRating"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.HasIndex("SeriesId", "AgeRating") + .HasDatabaseName("IX_SeriesMetadata_SeriesId_AgeRating"); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.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("TotalReads") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ClientInfoUsed") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Data") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"Activities\":[],\"SeriesIds\":null,\"ChapterIds\":null}"); + + b.Property("DateUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId", "DateUtc") + .HasDatabaseName("IX_AppUserReadingHistory_AppUserId_DateUtc"); + + b.ToTable("AppUserReadingHistory"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EndTimeUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsGenerated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("StartTimeUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("AppUserId", "IsActive") + .HasDatabaseName("IX_AppUserReadingSession_AppUserId_IsActive"); + + b.HasIndex("IsActive", "LastModifiedUtc") + .HasDatabaseName("IX_AppUserReadingSession_IsActive_LastModifiedUtc"); + + b.ToTable("AppUserReadingSession"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSessionActivityData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserReadingSessionId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("DeviceIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndBookScrollId") + .HasColumnType("TEXT"); + + b.Property("EndPage") + .HasColumnType("INTEGER"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EndTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("StartBookScrollId") + .HasColumnType("TEXT"); + + b.Property("StartPage") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("StartTimeUtc") + .HasColumnType("TEXT"); + + b.Property("TotalPages") + .HasColumnType("INTEGER"); + + b.Property("TotalWords") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordsRead") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "ClientInfo", "Kavita.Models.Entities.Progress.AppUserReadingSessionActivityData.ClientInfo#ClientInfoData", b1 => + { + b1.Property("AppVersion"); + + b1.Property("AuthType"); + + b1.Property("Browser"); + + b1.Property("BrowserVersion"); + + b1.Property("CapturedAt"); + + b1.Property("ClientType"); + + b1.Property("DeviceType"); + + b1.Property("IpAddress") + .IsRequired(); + + b1.Property("Orientation"); + + b1.Property("Platform"); + + b1.Property("ScreenHeight"); + + b1.Property("ScreenWidth"); + + b1.Property("UserAgent") + .IsRequired(); + + b1 + .ToJson("ClientInfo") + .HasColumnType("TEXT"); + }); + + b.HasKey("Id"); + + b.HasIndex("AppUserReadingSessionId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("StartTimeUtc", "LibraryId") + .HasDatabaseName("IX_ActivityData_StartTimeUtc_LibraryId"); + + b.ToTable("AppUserReadingSessionActivityData"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.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("DownloadUrl") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncCheckUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TotalItemsAtImport") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.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("Kavita.Models.Entities.ReadingLists.ReadingListRemapRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CblNumber") + .HasColumnType("TEXT"); + + b.Property("CblSeriesName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CblVolume") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("IsGlobal") + .HasColumnType("INTEGER"); + + b.Property("NormalizedCblSeriesName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SeriesNameAtMapping") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("NormalizedCblSeriesName", "IsGlobal", "AppUserId") + .HasDatabaseName("IX_ReadingListRemapRule_NormalizedCblSeriesName_IsGlobal_AppUserId"); + + b.ToTable("ReadingListRemapRule"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListTag", 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("ReadingListTag"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.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("Kavita.Models.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("Kavita.Models.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("ComicVineId") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("HardcoverId") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("MalId") + .HasColumnType("INTEGER"); + + b.Property("MangaBakaId") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MetronId") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId") + .HasDatabaseName("IX_Series_LibraryId"); + + b.HasIndex("NormalizedName") + .HasDatabaseName("IX_Series_NormalizedName"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.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("Kavita.Models.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.Entities.User.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("Kavita.Models.Entities.User.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("CoverImage") + .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("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + 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("OidcId") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("Kavita.Models.Entities.User.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Likes") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedSlotIndex") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserAnnotation"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserAuthKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastAccessedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("AppUserAuthKey"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + + 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.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("AppUserId", "SeriesId") + .HasDatabaseName("IX_AppUserBookmark_AppUserId_SeriesId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserChapterRating", 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("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.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("Kavita.Models.Entities.User.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("Kavita.Models.Entities.User.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("Kavita.Models.Entities.User.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("BookReaderHighlightSlots") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + 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("ColorScapeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CustomKeyBinds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{}"); + + b.Property("DataSaver") + .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("OpdsPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"EmbedProgressIndicator\":true,\"IncludeContinueFrom\":true}"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("PromptForRereadsAfter") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + 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("SocialPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true,\"ShareProfile\":false}"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderDisableBookmarkIcon") + .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.PrimitiveCollection("DeviceIds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("LibraryIds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("SeriesIds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.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("Kavita.Models.Entities.User.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("Kavita.Models.Entities.User.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("EntityType") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.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("ChapterTitle") + .HasColumnType("TEXT"); + + 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("SelectedText") + .HasColumnType("TEXT"); + + 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("Kavita.Models.Entities.User.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("Kavita.Models.Entities.User.ClientDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CurrentClientInfo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"UserAgent\":\"\",\"IpAddress\":\"\",\"AuthType\":0,\"ClientType\":0,\"AppVersion\":null,\"Browser\":null,\"BrowserVersion\":null,\"Platform\":0,\"DeviceType\":null,\"ScreenWidth\":null,\"ScreenHeight\":null,\"Orientation\":null,\"CapturedAt\":\"0001-01-01T00:00:00\"}"); + + b.Property("DeviceFingerprint") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("UiFingerprint") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ClientDevice"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.ClientDeviceHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapturedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ClientInfo") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"UserAgent\":\"\",\"IpAddress\":\"\",\"AuthType\":0,\"ClientType\":0,\"AppVersion\":null,\"Browser\":null,\"BrowserVersion\":null,\"Platform\":0,\"DeviceType\":null,\"ScreenWidth\":null,\"ScreenHeight\":null,\"Orientation\":null,\"CapturedAt\":\"0001-01-01T00:00:00\"}"); + + b.Property("DeviceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.ToTable("ClientDeviceHistory"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("ComicVineId") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("MangaBakaId") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MetronId") + .HasColumnType("INTEGER"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasColumnType("TEXT"); + + b.Property("Xml") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + 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("ReadingListReadingListTag", b => + { + b.Property("ReadingListsId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ReadingListsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ReadingListReadingListTag"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Chapter", b => + { + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Device", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.EmailHistory", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.FolderPath", b => + { + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.History.KavitaPlusAuditLog", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.LibraryExcludePattern", b => + { + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MangaFile", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalRating", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalReview", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.GenreSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("Kavita.Models.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesMetadataTag", b => + { + b.HasOne("Kavita.Models.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MetadataFieldMapping", b => + { + b.HasOne("Kavita.Models.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.ChapterPeople", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.PersonAlias", b => + { + b.HasOne("Kavita.Models.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("Kavita.Models.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserProgress", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingHistory", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ReadingHistory") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSession", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ReadingSessions") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSessionActivityData", b => + { + b.HasOne("Kavita.Models.Entities.Progress.AppUserReadingSession", "ReadingSession") + .WithMany("ActivityData") + .HasForeignKey("AppUserReadingSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Library"); + + b.Navigation("ReadingSession"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingList", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListItem", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.ReadingLists.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListRemapRule", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("Kavita.Models.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Series", b => + { + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserAnnotation", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserAuthKey", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("AuthKeys") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserBookmark", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserChapterRating", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserCollection", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserDashboardStream", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.User.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserExternalSource", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserOnDeckRemoval", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserPreferences", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("Kavita.Models.Entities.User.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserRating", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserReadingProfile", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserRole", b => + { + b.HasOne("Kavita.Models.Entities.User.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.User.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserSideNavStream", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.User.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserSmartFilter", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserTableOfContent", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserWantToRead", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.ClientDevice", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ClientDevices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.ClientDeviceHistory", b => + { + b.HasOne("Kavita.Models.Entities.User.ClientDevice", "Device") + .WithMany("History") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Volume", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Kavita.Models.Entities.User.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ReadingListReadingListTag", b => + { + b.HasOne("Kavita.Models.Entities.ReadingLists.ReadingList", null) + .WithMany() + .HasForeignKey("ReadingListsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.ReadingLists.ReadingListTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSession", b => + { + b.Navigation("ActivityData"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Kavita.Models.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("Kavita.Models.Entities.User.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUser", b => + { + b.Navigation("Annotations"); + + b.Navigation("AuthKeys"); + + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("ClientDevices"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingHistory"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ReadingSessions"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences") + .IsRequired(); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.ClientDevice", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Kavita.Database/Migrations/20260520121345_KavitaPlusAuditLog.cs b/Kavita.Database/Migrations/20260520121345_KavitaPlusAuditLog.cs new file mode 100644 index 000000000..5b816cf4b --- /dev/null +++ b/Kavita.Database/Migrations/20260520121345_KavitaPlusAuditLog.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kavita.Database.Migrations +{ + /// + public partial class KavitaPlusAuditLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Created", + table: "ExternalSeriesMetadata", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "ExternalSeriesMetadata", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModified", + table: "ExternalSeriesMetadata", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "ExternalSeriesMetadata", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.CreateTable( + name: "KavitaPlusAuditLogs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + Category = table.Column(type: "INTEGER", nullable: false), + EventType = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + SeriesId = table.Column(type: "INTEGER", nullable: true), + SubjectType = table.Column(type: "INTEGER", nullable: false), + SubjectId = table.Column(type: "INTEGER", nullable: true), + Payload = table.Column(type: "TEXT", nullable: true), + ErrorMessage = table.Column(type: "TEXT", nullable: true), + HasRetried = table.Column(type: "INTEGER", nullable: false), + UserId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_KavitaPlusAuditLogs", x => x.Id); + table.ForeignKey( + name: "FK_KavitaPlusAuditLogs_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_KavitaPlusAuditLog_Category_CreatedUtc", + table: "KavitaPlusAuditLogs", + columns: new[] { "Category", "CreatedUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_KavitaPlusAuditLog_CreatedUtc", + table: "KavitaPlusAuditLogs", + column: "CreatedUtc"); + + migrationBuilder.CreateIndex( + name: "IX_KavitaPlusAuditLog_SeriesId_CreatedUtc", + table: "KavitaPlusAuditLogs", + columns: new[] { "SeriesId", "CreatedUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_KavitaPlusAuditLog_SubjectType_SubjectId", + table: "KavitaPlusAuditLogs", + columns: new[] { "SubjectType", "SubjectId" }); + + migrationBuilder.CreateIndex( + name: "IX_KavitaPlusAuditLog_UserId", + table: "KavitaPlusAuditLogs", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "KavitaPlusAuditLogs"); + + migrationBuilder.DropColumn( + name: "Created", + table: "ExternalSeriesMetadata"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "ExternalSeriesMetadata"); + + migrationBuilder.DropColumn( + name: "LastModified", + table: "ExternalSeriesMetadata"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "ExternalSeriesMetadata"); + } + } +} diff --git a/Kavita.Database/Migrations/DataContextModelSnapshot.cs b/Kavita.Database/Migrations/DataContextModelSnapshot.cs index 0c0ba71ea..16cb1dacb 100644 --- a/Kavita.Database/Migrations/DataContextModelSnapshot.cs +++ b/Kavita.Database/Migrations/DataContextModelSnapshot.cs @@ -572,6 +572,65 @@ namespace Kavita.Database.Migrations b.ToTable("Genre"); }); + modelBuilder.Entity("Kavita.Models.Entities.History.KavitaPlusAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("HasRetried") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubjectId") + .HasColumnType("INTEGER"); + + b.Property("SubjectType") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedUtc") + .HasDatabaseName("IX_KavitaPlusAuditLog_CreatedUtc"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_KavitaPlusAuditLog_UserId"); + + b.HasIndex("Category", "CreatedUtc") + .HasDatabaseName("IX_KavitaPlusAuditLog_Category_CreatedUtc"); + + b.HasIndex("SeriesId", "CreatedUtc") + .HasDatabaseName("IX_KavitaPlusAuditLog_SeriesId_CreatedUtc"); + + b.HasIndex("SubjectType", "SubjectId") + .HasDatabaseName("IX_KavitaPlusAuditLog_SubjectType_SubjectId"); + + b.ToTable("KavitaPlusAuditLogs"); + }); + modelBuilder.Entity("Kavita.Models.Entities.History.ManualMigrationHistory", b => { b.Property("Id") @@ -946,9 +1005,21 @@ namespace Kavita.Database.Migrations b.Property("CbrId") .HasColumnType("INTEGER"); + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("GoogleBooksId") .HasColumnType("TEXT"); + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("MalId") .HasColumnType("INTEGER"); @@ -3752,6 +3823,16 @@ namespace Kavita.Database.Migrations b.Navigation("Library"); }); + modelBuilder.Entity("Kavita.Models.Entities.History.KavitaPlusAuditLog", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + modelBuilder.Entity("Kavita.Models.Entities.LibraryExcludePattern", b => { b.HasOne("Kavita.Models.Entities.Library", "Library") diff --git a/Kavita.Database/Repositories/KavitaPlusAuditRepository.cs b/Kavita.Database/Repositories/KavitaPlusAuditRepository.cs new file mode 100644 index 000000000..d5e44dc60 --- /dev/null +++ b/Kavita.Database/Repositories/KavitaPlusAuditRepository.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Kavita.API.Repositories; +using Kavita.API.Services.Plus; +using Kavita.Common.Helpers; +using Kavita.Database.Extensions; +using Kavita.Models.DTOs.KavitaPlus; +using Kavita.Models.DTOs.KavitaPlus.Audit; +using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.Audit; +using Kavita.Models.Entities.History; +using Microsoft.EntityFrameworkCore; + +namespace Kavita.Database.Repositories; + +public class KavitaPlusAuditRepository(DataContext context) : IKavitaPlusAuditRepository +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public void Add(KavitaPlusAuditLog entry) => context.KavitaPlusAuditLogs.Add(entry); + + public async Task DeleteOlderThanAsync(DateTime cutoff, CancellationToken ct = default) + { + await context.KavitaPlusAuditLogs + .Where(e => e.CreatedUtc < cutoff) + .ExecuteDeleteAsync(ct); + } + + public async Task> GetPagedAsync( + KavitaPlusAuditFilterDto filter, UserParams userParams, CancellationToken ct = default) + { + var query = BuildBaseQuery(filter); + return await ProjectAndPage(query, userParams, ct); + } + + public async Task> GetMyActivityAsync( + int userId, KavitaPlusAuditFilterDto filter, UserParams userParams, CancellationToken ct = default) + { + var query = BuildBaseQuery(filter) + .Where(e => e.UserId == userId); + + return await ProjectAndPage(query, userParams, ct); + } + + public async Task GetStatsAsync(CancellationToken ct = default) + { + var cutoff24H = DateTime.UtcNow.AddHours(-24); + + var events24H = await context.KavitaPlusAuditLogs + .CountAsync(e => e.CreatedUtc >= cutoff24H, ct); + + var failures24H = await context.KavitaPlusAuditLogs + .CountAsync(e => e.CreatedUtc >= cutoff24H && e.Status == AuditStatus.Failure, ct); + + var unresolvedMatchFailures = await context.KavitaPlusAuditLogs + .CountAsync(e => e.EventType == KavitaPlusEventType.SeriesMatchFailed + && e.Status == AuditStatus.Failure, ct); + + var baseEligible = context.Series + .Where(s => !IExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) + .Where(s => s.Library.AllowMetadataMatching) + .Where(s => !s.DontMatch); + + var matchedSeriesCount = await baseEligible.WhereMatchedExternalMetadata().CountAsync(ct); + + var totalEligibleSeriesCount = await baseEligible.CountAsync(ct); + + var staleMatchesCount = await baseEligible.WhereStaleExternalMetadata().CountAsync(ct); + + var blacklistedSeriesCount = await baseEligible + .Where(s => s.IsBlacklisted) + .CountAsync(ct); + + var scrobbleQueueCount = await context.ScrobbleEvent + .CountAsync(e => !e.IsProcessed, ct); + + return new KavitaPlusAuditStatsDto + { + Events24H = events24H, + Failures24H = failures24H, + UnresolvedMatchFailures = unresolvedMatchFailures, + MatchedSeriesCount = matchedSeriesCount, + TotalEligibleSeriesCount = totalEligibleSeriesCount, + StaleMatchesCount = staleMatchesCount, + BlacklistedSeriesCount = blacklistedSeriesCount, + ScrobbleQueueCount = scrobbleQueueCount, + }; + } + + public async Task GetSeriesInfoAsync( + int seriesId, int callingUserId, bool isAdmin, CancellationToken ct = default) + { + var series = await context.Series + .Include(s => s.ExternalSeriesMetadata) + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == seriesId, ct); + + if (series == null) + { + return new KavitaPlusAuditSeriesInfoDto { SeriesId = seriesId }; + } + + var recentQuery = context.KavitaPlusAuditLogs + .AsNoTracking() + .Where(e => e.SeriesId == seriesId) + .Where(e => e.Category != KavitaPlusAuditCategory.Scrobble + || isAdmin + || e.UserId == callingUserId) + .OrderByDescending(e => e.CreatedUtc) + .Take(20); + + var recentRaw = await recentQuery + .Select(e => new RawEntry( + e.Id, e.CreatedUtc, e.Category, e.EventType, e.Status, + e.SeriesId, series.LibraryId, series.Name, + e.SubjectType, e.SubjectId, + e.UserId, e.User != null ? e.User.UserName : null, + e.Payload, e.ErrorMessage, e.HasRetried)) + .ToListAsync(ct); + + // Due to Json deserialization, I can't use automapper here and need to do in-mem + var recentEvents = recentRaw.Select(MapToDto).ToList(); + + return new KavitaPlusAuditSeriesInfoDto + { + SeriesId = series.Id, + LibraryId = series.LibraryId, + SeriesName = series.Name, + IsMatched = !series.IsBlacklisted + && series.ExternalSeriesMetadata != null + && series.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue, + MangaBakaId = series.MangaBakaId != 0 ? series.MangaBakaId : null, + AniListId = series.AniListId != 0 ? series.AniListId : null, + HardcoverId = series.HardcoverId != 0 ? series.HardcoverId : null, + CbrId = series.CbrId != 0 ? series.CbrId : null, + ComicVineId = series.ComicVineId != string.Empty ? series.ComicVineId : null, + NextRefreshUtc = series.ExternalSeriesMetadata?.ValidUntilUtc, + LastRefreshedUtc = series.ExternalSeriesMetadata?.LastModifiedUtc, + RecentEvents = recentEvents, + }; + } + + private IQueryable BuildBaseQuery(KavitaPlusAuditFilterDto filter) + { + return context.KavitaPlusAuditLogs + .AsNoTracking() + .WhereIf(filter.Category.HasValue, e => e.Category == filter.Category!.Value) + .WhereIf(filter.Status.HasValue, e => e.Status == filter.Status!.Value) + .WhereIf(filter.SubjectType.HasValue, e => e.SubjectType == filter.SubjectType!.Value) + .WhereIf(filter.UserId.HasValue, e => e.UserId == filter.UserId!.Value) + .WhereIf(filter.SeriesId.HasValue, e => e.SeriesId == filter.SeriesId!.Value) + .WhereIf(filter.FromUtc.HasValue, e => e.CreatedUtc >= filter.FromUtc!.Value) + .WhereIf(filter.ToUtc.HasValue, e => e.CreatedUtc <= filter.ToUtc!.Value) + .WhereIf(!string.IsNullOrEmpty(filter.Search), e => + context.Series.Any(s => s.Id == e.SeriesId && s.Name.Contains(filter.Search!)) || + (e.User != null && e.User.UserName!.Contains(filter.Search!)) || + (e.ErrorMessage != null && e.ErrorMessage.Contains(filter.Search!))) + .OrderByDescending(e => e.CreatedUtc); + } + + private async Task> ProjectAndPage( + IQueryable query, UserParams userParams, CancellationToken ct) + { + var count = await query.CountAsync(ct); + var raw = await query + .Skip((userParams.PageNumber - 1) * userParams.PageSize) + .Take(userParams.PageSize) + .Select(e => new RawEntry( + e.Id, e.CreatedUtc, e.Category, e.EventType, e.Status, + e.SeriesId, + context.Series.Where(s => s.Id == e.SeriesId).Select(s => (int?)s.LibraryId).FirstOrDefault(), + context.Series.Where(s => s.Id == e.SeriesId).Select(s => s.Name).FirstOrDefault(), + e.SubjectType, e.SubjectId, + e.UserId, e.User != null ? e.User.UserName : null, + e.Payload, e.ErrorMessage, e.HasRetried)) + .ToListAsync(ct); + + var items = raw.Select(MapToDto).ToList(); + return PagedList.Create(items, count, userParams); + } + + private static KavitaPlusAuditEntryDto MapToDto(RawEntry e) + { + IList? diff = null; + if (e is {Category: KavitaPlusAuditCategory.Metadata, Payload: not null}) + { + try + { + var wrapper = JsonSerializer.Deserialize(e.Payload, JsonOptions); + diff = wrapper?.Changes; + } + catch + { + // malformed payload + } + } + + KavitaPlusScrobbleDetailsDto? scrobbleDetails = null; + if (e is {Category: KavitaPlusAuditCategory.Scrobble, Payload: not null}) + { + try + { + var p = JsonSerializer.Deserialize(e.Payload, JsonOptions); + if (p != null) + { + scrobbleDetails = new KavitaPlusScrobbleDetailsDto + { + ScrobbleEventType = p.ScrobbleEventType, + ChapterNumber = p.ChapterNumber, + VolumeNumber = p.VolumeNumber, + Rating = p.Rating, + Provider = ScrobbleProvider.AniList, // TODO: This needs to allow provider to be passed from ScrobbleService (Amelia) + LibraryType = p.LibraryType, + }; + } + } + catch + { + // malformed payload + } + } + + KavitaPlusAuditMatchDetailsDto? matchDetails = null; + if (e is { Category: KavitaPlusAuditCategory.Match, Payload: not null }) + { + try + { + matchDetails = e.EventType switch + { + KavitaPlusEventType.SeriesMatched => + KavitaPlusAuditMatchDetailsDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + KavitaPlusEventType.SeriesMatchFixed => + KavitaPlusAuditMatchDetailsDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + KavitaPlusEventType.SeriesMatchFailed or KavitaPlusEventType.SeriesBlacklisted => + KavitaPlusAuditMatchDetailsDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + KavitaPlusEventType.SeriesDontMatchSet => + KavitaPlusAuditMatchDetailsDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + _ => null + }; + } + catch + { + // malformed payload + } + } + + KavitaPlusAuditSyncDetailsDto? syncDetails = null; + if (e is { Category: KavitaPlusAuditCategory.Sync, Payload: not null }) + { + try + { + syncDetails = e.EventType switch + { + KavitaPlusEventType.CollectionSynced => + KavitaPlusAuditSyncDetailsDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + KavitaPlusEventType.CollectionItemAdded => + KavitaPlusAuditSyncDetailsDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + KavitaPlusEventType.SyncCompleted => + KavitaPlusAuditSyncDetailsDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + _ => null + }; + } + catch + { + // malformed payload + } + } + + KavitaPlusAuditMetadataExtrasDto? metadataExtras = null; + if (e is { Category: KavitaPlusAuditCategory.Metadata, Payload: not null }) + { + try + { + metadataExtras = e.EventType switch + { + KavitaPlusEventType.CoverUpdated => + KavitaPlusAuditMetadataExtrasDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + KavitaPlusEventType.ChapterCoverUpdated => + KavitaPlusAuditMetadataExtrasDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + KavitaPlusEventType.PersonAliasAdded => + KavitaPlusAuditMetadataExtrasDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + KavitaPlusEventType.PersonCoverUpdated => + KavitaPlusAuditMetadataExtrasDto.From(JsonSerializer.Deserialize(e.Payload, JsonOptions)), + _ => null + }; + } + catch + { + // malformed payload + } + } + + return new KavitaPlusAuditEntryDto + { + Id = e.Id, + CreatedUtc = e.CreatedUtc, + Category = e.Category, + EventType = e.EventType, + Status = e.Status, + SeriesId = e.SeriesId, + LibraryId = e.LibraryId, + SeriesName = e.SeriesName, + SubjectType = e.SubjectType, + SubjectId = e.SubjectId, + UserId = e.UserId, + Username = e.Username, + Diff = diff, + ErrorMessage = e.ErrorMessage, + ScrobbleDetails = scrobbleDetails, + MatchDetails = matchDetails, + SyncDetails = syncDetails, + MetadataExtras = metadataExtras, + CanRetry = e.Status == AuditStatus.Failure + && e.Category == KavitaPlusAuditCategory.Scrobble + && !e.HasRetried, + }; + } + + public async Task MarkAsRetriedAsync(long id, CancellationToken ct = default) + { + await context.KavitaPlusAuditLogs + .Where(e => e.Id == id) + .ExecuteUpdateAsync(s => s.SetProperty(e => e.HasRetried, true), ct); + } + + private sealed record RawEntry( + long Id, DateTime CreatedUtc, KavitaPlusAuditCategory Category, + KavitaPlusEventType EventType, AuditStatus Status, + int? SeriesId, int? LibraryId, string? SeriesName, + AuditSubjectType SubjectType, int? SubjectId, + int? UserId, string? Username, + string? Payload, string? ErrorMessage, bool HasRetried); + + private sealed class ChangesWrapper + { + public List? Changes { get; set; } + } +} diff --git a/Kavita.Database/UnitOfWork.cs b/Kavita.Database/UnitOfWork.cs index 2c7715604..829196517 100644 --- a/Kavita.Database/UnitOfWork.cs +++ b/Kavita.Database/UnitOfWork.cs @@ -54,6 +54,7 @@ public class UnitOfWork : IUnitOfWork ReadingSessionRepository = new ReadingSessionRepository(_context, _mapper); ClientDeviceRepository = new ClientDeviceRepository(_context, _mapper); RemapRuleRepository = new ReadingListRemapRuleRepository(_context, _mapper); + KavitaPlusAuditRepository = new KavitaPlusAuditRepository(_context); } /// @@ -89,6 +90,7 @@ public class UnitOfWork : IUnitOfWork public IReadingSessionRepository ReadingSessionRepository { get; } public IClientDeviceRepository ClientDeviceRepository { get; } public IReadingListRemapRuleRepository RemapRuleRepository { get; } + public IKavitaPlusAuditRepository KavitaPlusAuditRepository { get; } /// /// Commits pending changes inside an IMMEDIATE SQLite transaction so writer contention diff --git a/Kavita.Models/Constants/TaskSchedulerConstants.cs b/Kavita.Models/Constants/TaskSchedulerConstants.cs index 6f2027f13..b3577f40e 100644 --- a/Kavita.Models/Constants/TaskSchedulerConstants.cs +++ b/Kavita.Models/Constants/TaskSchedulerConstants.cs @@ -25,4 +25,5 @@ public static class TaskSchedulerConstants public const string AuthKeyExpirationId = "auth-key-expiration"; public const string EnsureSideNavId = "ensure-sidenav"; public const string FlushUserActiveTaskId = "flush-user-active"; + public const string PurgeKavitaPlusAuditLogsId = "purge-kavita-plus-audit-logs"; } diff --git a/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogMatchParamsDtos.cs b/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogMatchParamsDtos.cs new file mode 100644 index 000000000..4b66deb90 --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogMatchParamsDtos.cs @@ -0,0 +1,49 @@ +using Kavita.Models.Entities.Enums; + +namespace Kavita.Models.DTOs.KavitaPlus.Audit; +#nullable enable + +public sealed record AuditLogMatchClearedParamsDto +{ + public string SeriesName { get; init; } = string.Empty; + public string? MatchedName { get; init; } +} + +public sealed record AuditLogMatchDontMatchParamsDto +{ + public string SeriesName { get; init; } = string.Empty; + public bool DontMatch { get; init; } +} + +public sealed record AuditLogMatchFailureParamsDto +{ + public string SeriesName { get; init; } = string.Empty; + public string Reason { get; init; } = string.Empty; +} + +public sealed record AuditLogMatchExternalIdsParamsDto +{ + public int AniListId { get; init; } + public long MalId { get; init; } + public long MangaBakaId { get; init; } + public int CbrId { get; init; } +} + +public sealed record AuditLogMatchedParamsDto +{ + public string SeriesName { get; init; } = string.Empty; + public AuditLogMatchExternalIdsParamsDto Before { get; init; } = new(); + public AuditLogMatchExternalIdsParamsDto After { get; init; } = new(); + public string? MatchedName { get; init; } +} + +public sealed record AuditLogMetadataFetchParamsDto +{ + public int SeriesId { get; init; } + public int? LibraryId { get; init; } + public MangaFormat Format { get; init; } + public long MangaBakaId { get; init; } + public int CbrId { get; init; } + public int AniListId { get; init; } + public int HardcoverId { get; init; } +} diff --git a/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogMetadataParamsDtos.cs b/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogMetadataParamsDtos.cs new file mode 100644 index 000000000..e58a2b22c --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogMetadataParamsDtos.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace Kavita.Models.DTOs.KavitaPlus.Audit; +#nullable enable + +public sealed record AuditLogMetadataChangesParamsDto +{ + public IList Changes { get; init; } = []; +} + +public sealed record AuditLogChapterCoverParamsDto +{ + public string IssueNumber { get; init; } = string.Empty; + public string CoverUrl { get; init; } = string.Empty; +} + +public sealed record AuditLogSeriesCoverParamsDto +{ + public string SeriesName { get; init; } = string.Empty; + public string CoverUrl { get; init; } = string.Empty; +} diff --git a/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogPersonParamsDtos.cs b/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogPersonParamsDtos.cs new file mode 100644 index 000000000..c63122b86 --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogPersonParamsDtos.cs @@ -0,0 +1,15 @@ +namespace Kavita.Models.DTOs.KavitaPlus.Audit; +#nullable enable + +public sealed record AuditLogPersonAliasParamsDto +{ + public string PersonName { get; init; } = string.Empty; + public string AliasAdded { get; init; } = string.Empty; +} + +public sealed record AuditLogPersonCoverParamsDto +{ + public string PersonName { get; init; } = string.Empty; + public int AniListId { get; init; } + public string ImageUrl { get; init; } = string.Empty; +} diff --git a/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogSyncParamsDtos.cs b/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogSyncParamsDtos.cs new file mode 100644 index 000000000..dacfc5cde --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/Audit/AuditLogSyncParamsDtos.cs @@ -0,0 +1,44 @@ +namespace Kavita.Models.DTOs.KavitaPlus.Audit; +#nullable enable + +public sealed record AuditLogCollectionItemParamsDto +{ + public string CollectionName { get; init; } = string.Empty; + public string SeriesName { get; init; } = string.Empty; + public int SeriesId { get; init; } +} + +public sealed record AuditLogCollectionSyncedParamsDto +{ + public string CollectionName { get; init; } = string.Empty; + public string? StackId { get; init; } + public int ItemCount { get; init; } + public int MissingCount { get; init; } +} + +public sealed record AuditLogCollectionFailedParamsDto +{ + public string CollectionName { get; init; } = string.Empty; +} + +public sealed record AuditLogCollectionStartedParamsDto +{ + public string CollectionName { get; init; } = string.Empty; + public string? StackId { get; init; } + public int TotalItems { get; init; } +} + +public sealed record AuditLogWantToReadSyncParamsDto +{ + public string UserName { get; init; } = string.Empty; + public bool HasMal { get; init; } + public bool HasAniList { get; init; } +} + +public sealed record AuditLogWantToReadSyncCompletedParamsDto +{ + public string UserName { get; init; } = string.Empty; + public int SeriesMatched { get; init; } + public bool HasMal { get; init; } + public bool HasAniList { get; init; } +} diff --git a/Kavita.Models/DTOs/KavitaPlus/AuditLogScrobbleParamsDto.cs b/Kavita.Models/DTOs/KavitaPlus/AuditLogScrobbleParamsDto.cs new file mode 100644 index 000000000..02f3d22d3 --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/AuditLogScrobbleParamsDto.cs @@ -0,0 +1,18 @@ +using Kavita.Models.DTOs.Scrobbling; +using Kavita.Models.Entities.Enums; + +namespace Kavita.Models.DTOs.KavitaPlus; +#nullable enable + +/// +/// Internal typed payload written into the Payload column for scrobble audit entries. +/// Not returned directly by the API — projected to on read. +/// +public sealed record AuditLogScrobbleParamsDto +{ + public ScrobbleEventType? ScrobbleEventType { get; init; } + public int? ChapterNumber { get; init; } + public float? VolumeNumber { get; init; } + public float? Rating { get; init; } + public LibraryType LibraryType { get; init; } = LibraryType.Manga; +} diff --git a/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditEntryDto.cs b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditEntryDto.cs new file mode 100644 index 000000000..beac8cba7 --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditEntryDto.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using Kavita.Models.Entities.Enums.Audit; + +namespace Kavita.Models.DTOs.KavitaPlus; +#nullable enable + +public sealed record KavitaPlusAuditEntryDto +{ + public long Id { get; init; } + public DateTime CreatedUtc { get; init; } + public KavitaPlusAuditCategory Category { get; init; } + public KavitaPlusEventType EventType { get; init; } + public AuditStatus Status { get; init; } + public int? SeriesId { get; init; } + public int? LibraryId { get; init; } + public string? SeriesName { get; init; } + public AuditSubjectType SubjectType { get; init; } + public int? SubjectId { get; init; } + public int? UserId { get; init; } + public string? Username { get; init; } + public IList? Diff { get; init; } + public string? ErrorMessage { get; init; } + public KavitaPlusScrobbleDetailsDto? ScrobbleDetails { get; init; } + public KavitaPlusAuditMatchDetailsDto? MatchDetails { get; init; } + public KavitaPlusAuditSyncDetailsDto? SyncDetails { get; init; } + public KavitaPlusAuditMetadataExtrasDto? MetadataExtras { get; init; } + public bool CanRetry { get; init; } +} diff --git a/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditFilterDto.cs b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditFilterDto.cs new file mode 100644 index 000000000..95b2b8440 --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditFilterDto.cs @@ -0,0 +1,18 @@ +using System; +using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.Audit; + +namespace Kavita.Models.DTOs.KavitaPlus; +#nullable enable + +public sealed record KavitaPlusAuditFilterDto +{ + public KavitaPlusAuditCategory? Category { get; init; } + public AuditStatus? Status { get; init; } + public AuditSubjectType? SubjectType { get; init; } + public int? UserId { get; init; } + public int? SeriesId { get; init; } + public DateTime? FromUtc { get; init; } + public DateTime? ToUtc { get; init; } + public string? Search { get; init; } +} diff --git a/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditMatchDetailsDto.cs b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditMatchDetailsDto.cs new file mode 100644 index 000000000..43f94c2dd --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditMatchDetailsDto.cs @@ -0,0 +1,37 @@ +using Kavita.Models.DTOs.KavitaPlus.Audit; + +namespace Kavita.Models.DTOs.KavitaPlus; +#nullable enable + +/// +/// Match-specific context surfaced on a Kavita+ audit entry. +/// Projected from AuditLogMatch*ParamsDtos based on EventType. +/// Not returned directly by the API - each From() overload maps one source type. +/// +public sealed record KavitaPlusAuditMatchDetailsDto +{ + // SeriesMatched, SeriesMatchCleared + public string? MatchedName { get; init; } + + // SeriesMatched - external ID snapshots before and after the match + public AuditLogMatchExternalIdsParamsDto? Before { get; init; } + public AuditLogMatchExternalIdsParamsDto? After { get; init; } + + // SeriesMatchFailed, SeriesBlacklisted + public string? Reason { get; init; } + + // SeriesDontMatchSet + public bool? DontMatch { get; init; } + + public static KavitaPlusAuditMatchDetailsDto? From(AuditLogMatchedParamsDto? p) => + p is null ? null : new KavitaPlusAuditMatchDetailsDto { MatchedName = p.MatchedName, Before = p.Before, After = p.After }; + + public static KavitaPlusAuditMatchDetailsDto? From(AuditLogMatchClearedParamsDto? p) => + p is null ? null : new KavitaPlusAuditMatchDetailsDto { MatchedName = p.MatchedName }; + + public static KavitaPlusAuditMatchDetailsDto? From(AuditLogMatchFailureParamsDto? p) => + p is null ? null : new KavitaPlusAuditMatchDetailsDto { Reason = p.Reason }; + + public static KavitaPlusAuditMatchDetailsDto? From(AuditLogMatchDontMatchParamsDto? p) => + p is null ? null : new KavitaPlusAuditMatchDetailsDto { DontMatch = p.DontMatch }; +} diff --git a/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditMetadataExtrasDto.cs b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditMetadataExtrasDto.cs new file mode 100644 index 000000000..c3d2e6356 --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditMetadataExtrasDto.cs @@ -0,0 +1,36 @@ +using Kavita.Models.DTOs.KavitaPlus.Audit; + +namespace Kavita.Models.DTOs.KavitaPlus; +#nullable enable + +/// +/// Extra context for non-diff Metadata events (cover updates, person operations). +/// Projected from AuditLogSeriesCoverParamsDto, AuditLogChapterCoverParamsDto, +/// AuditLogPersonAliasParamsDto, AuditLogPersonCoverParamsDto. +/// +public sealed record KavitaPlusAuditMetadataExtrasDto +{ + // CoverUpdated, ChapterCoverUpdated, PersonCoverUpdated + public string? CoverUrl { get; init; } + + // ChapterCoverUpdated + public string? IssueNumber { get; init; } + + // PersonAliasAdded, PersonCoverUpdated + public string? PersonName { get; init; } + + // PersonAliasAdded + public string? AliasAdded { get; init; } + + public static KavitaPlusAuditMetadataExtrasDto? From(AuditLogSeriesCoverParamsDto? p) => + p is null ? null : new KavitaPlusAuditMetadataExtrasDto { CoverUrl = p.CoverUrl }; + + public static KavitaPlusAuditMetadataExtrasDto? From(AuditLogChapterCoverParamsDto? p) => + p is null ? null : new KavitaPlusAuditMetadataExtrasDto { CoverUrl = p.CoverUrl, IssueNumber = p.IssueNumber }; + + public static KavitaPlusAuditMetadataExtrasDto? From(AuditLogPersonAliasParamsDto? p) => + p is null ? null : new KavitaPlusAuditMetadataExtrasDto { PersonName = p.PersonName, AliasAdded = p.AliasAdded }; + + public static KavitaPlusAuditMetadataExtrasDto? From(AuditLogPersonCoverParamsDto? p) => + p is null ? null : new KavitaPlusAuditMetadataExtrasDto { PersonName = p.PersonName, CoverUrl = p.ImageUrl }; +} diff --git a/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditSeriesInfoDto.cs b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditSeriesInfoDto.cs new file mode 100644 index 000000000..c33aba9e3 --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditSeriesInfoDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using Kavita.Models.DTOs.Common; + +namespace Kavita.Models.DTOs.KavitaPlus; +#nullable enable + +public sealed record KavitaPlusAuditSeriesInfoDto : IUpdateExternalMetadataIds +{ + public int SeriesId { get; init; } + public int LibraryId { get; init; } + public string SeriesName { get; init; } = string.Empty; + public bool IsMatched { get; init; } + public int? AniListId { get; set; } + public long? MalId { get; set; } + public int? HardcoverId { get; set; } + public long? MetronId { get; set; } + public string? ComicVineId { get; set; } + public long? MangaBakaId { get; set; } + public int? CbrId { get; set; } + public DateTime? NextRefreshUtc { get; init; } + public DateTime? LastRefreshedUtc { get; init; } + public IList RecentEvents { get; init; } = []; +} diff --git a/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditStatsDto.cs b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditStatsDto.cs new file mode 100644 index 000000000..53282521d --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditStatsDto.cs @@ -0,0 +1,21 @@ +namespace Kavita.Models.DTOs.KavitaPlus; + +public sealed record KavitaPlusAuditStatsDto +{ + public int Events24H { get; init; } + public int Failures24H { get; init; } + public int UnresolvedMatchFailures { get; init; } + public int MatchedSeriesCount { get; init; } + public int TotalEligibleSeriesCount { get; init; } + /// + /// Series that are matched but whose cached metadata has expired and needs a refresh. + /// The series is still considered matched — the data is just stale. + /// + public int StaleMatchesCount { get; init; } + /// + /// Series that Kavita+ returned "Unknown Series" for; they were attempted but could not be matched. + /// These are not counted as matched and require manual intervention (fix match or set DontMatch). + /// + public int BlacklistedSeriesCount { get; init; } + public int ScrobbleQueueCount { get; init; } +} diff --git a/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditSyncDetailsDto.cs b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditSyncDetailsDto.cs new file mode 100644 index 000000000..e633aa5c1 --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusAuditSyncDetailsDto.cs @@ -0,0 +1,36 @@ +using Kavita.Models.DTOs.KavitaPlus.Audit; + +namespace Kavita.Models.DTOs.KavitaPlus; +#nullable enable + +/// +/// Sync-specific context surfaced on a Kavita+ audit entry. +/// Projected from AuditLogSync*ParamsDtos based on EventType. +/// +public sealed record KavitaPlusAuditSyncDetailsDto +{ + // CollectionSynced + public string? CollectionName { get; init; } + public string? StackId { get; init; } + public int? ItemCount { get; init; } + public int? MissingCount { get; init; } + + // CollectionItemAdded + public string? SeriesName { get; init; } + public int? SeriesId { get; init; } + + // SyncCompleted (WantToRead) + public string? UserName { get; init; } + public bool? HasMal { get; init; } + public bool? HasAniList { get; init; } + public int? SeriesMatched { get; init; } + + public static KavitaPlusAuditSyncDetailsDto? From(AuditLogCollectionSyncedParamsDto? p) => + p is null ? null : new KavitaPlusAuditSyncDetailsDto { CollectionName = p.CollectionName, StackId = p.StackId, ItemCount = p.ItemCount, MissingCount = p.MissingCount }; + + public static KavitaPlusAuditSyncDetailsDto? From(AuditLogCollectionItemParamsDto? p) => + p is null ? null : new KavitaPlusAuditSyncDetailsDto { CollectionName = p.CollectionName, SeriesName = p.SeriesName, SeriesId = p.SeriesId }; + + public static KavitaPlusAuditSyncDetailsDto? From(AuditLogWantToReadSyncCompletedParamsDto? p) => + p is null ? null : new KavitaPlusAuditSyncDetailsDto { UserName = p.UserName, HasMal = p.HasMal, HasAniList = p.HasAniList, SeriesMatched = p.SeriesMatched }; +} diff --git a/Kavita.Models/DTOs/KavitaPlus/KavitaPlusScrobbleDetailsDto.cs b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusScrobbleDetailsDto.cs new file mode 100644 index 000000000..8d4f89223 --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/KavitaPlusScrobbleDetailsDto.cs @@ -0,0 +1,18 @@ +using Kavita.Models.DTOs.Scrobbling; +using Kavita.Models.Entities.Enums; + +namespace Kavita.Models.DTOs.KavitaPlus; +#nullable enable + +/// +/// Scrobble-specific context surfaced on a Kavita+ audit entry. Projected from . +/// +public sealed record KavitaPlusScrobbleDetailsDto +{ + public ScrobbleEventType? ScrobbleEventType { get; init; } + public int? ChapterNumber { get; init; } + public float? VolumeNumber { get; init; } + public float? Rating { get; init; } + public ScrobbleProvider Provider { get; init; } = ScrobbleProvider.AniList; + public LibraryType LibraryType { get; init; } = LibraryType.Manga; +} diff --git a/Kavita.Models/DTOs/KavitaPlus/MetadataFieldChangeDto.cs b/Kavita.Models/DTOs/KavitaPlus/MetadataFieldChangeDto.cs new file mode 100644 index 000000000..0e2ea08af --- /dev/null +++ b/Kavita.Models/DTOs/KavitaPlus/MetadataFieldChangeDto.cs @@ -0,0 +1,28 @@ +namespace Kavita.Models.DTOs.KavitaPlus; +#nullable enable + +/// +/// Records a single field's before/after state during a metadata write +/// +public sealed record MetadataFieldChangeDto(MetadataFieldChangeKind Field, object? From, object? To); + +/// +/// Represents individual fields for any entity type. Will be localized in the UI layer. +/// +public enum MetadataFieldChangeKind +{ + Relationships = 1, + Characters = 2, + Artists = 3, + Writers = 4, + Tags = 5, + Genres = 6, + PublicationStatus = 7, + AgeRating = 8, + ExternalIds = 9, + Summary = 10, + Title = 11, + ReleaseDate = 12, + ReleaseYear = 13, + LocalizedName = 14 +} diff --git a/Kavita.Models/Entities/Enums/Audit/AuditStatus.cs b/Kavita.Models/Entities/Enums/Audit/AuditStatus.cs new file mode 100644 index 000000000..638ef660f --- /dev/null +++ b/Kavita.Models/Entities/Enums/Audit/AuditStatus.cs @@ -0,0 +1,8 @@ +namespace Kavita.Models.Entities.Enums.Audit; + +public enum AuditStatus +{ + Success = 0, + Failure = 1, + Info = 2, +} diff --git a/Kavita.Models/Entities/Enums/Audit/AuditSubjectType.cs b/Kavita.Models/Entities/Enums/Audit/AuditSubjectType.cs new file mode 100644 index 000000000..3a3117a50 --- /dev/null +++ b/Kavita.Models/Entities/Enums/Audit/AuditSubjectType.cs @@ -0,0 +1,10 @@ +namespace Kavita.Models.Entities.Enums.Audit; + +public enum AuditSubjectType +{ + Series = 0, + Person = 1, + Collection = 2, + Chapter = 3, + Global = 4, +} diff --git a/Kavita.Models/Entities/Enums/Audit/KavitaPlusAuditCategory.cs b/Kavita.Models/Entities/Enums/Audit/KavitaPlusAuditCategory.cs new file mode 100644 index 000000000..2cf893efc --- /dev/null +++ b/Kavita.Models/Entities/Enums/Audit/KavitaPlusAuditCategory.cs @@ -0,0 +1,9 @@ +namespace Kavita.Models.Entities.Enums.Audit; + +public enum KavitaPlusAuditCategory +{ + Match = 0, + Metadata = 1, + Scrobble = 2, + Sync = 3, +} diff --git a/Kavita.Models/Entities/Enums/Audit/KavitaPlusEventType.cs b/Kavita.Models/Entities/Enums/Audit/KavitaPlusEventType.cs new file mode 100644 index 000000000..74f1fb994 --- /dev/null +++ b/Kavita.Models/Entities/Enums/Audit/KavitaPlusEventType.cs @@ -0,0 +1,47 @@ +namespace Kavita.Models.Entities.Enums.Audit; + +public enum KavitaPlusEventType +{ + // Match + SeriesMatched = 0, + SeriesMatchFailed = 1, + SeriesBlacklisted = 2, + /// + /// This is the after affect of FixMatch + /// + SeriesMatchFixed = 3, + SeriesDontMatchSet = 4, + + // Metadata - Series + MetadataFetched = 10, + MetadataUpdated = 11, + CoverUpdated = 13, + + // Metadata - Chapter/Issue + ChapterMetadataUpdated = 20, + ChapterCoverUpdated = 21, + + // Metadata - People + PersonCoverUpdated = 30, + PersonAliasAdded = 31, + + // Metadata - Collections + CollectionSynced = 40, + CollectionItemAdded = 41, + + // Scrobble + ScrobbleEventCreated = 50, + ScrobbleEventUpdated = 51, + ScrobbleEventSent = 52, + ScrobbleEventFailed = 53, + ScrobbleRateLimitHit = 54, + ScrobbleEventSkipped = 55, + ScrobbleHoldRemoved = 56, + ScrobbleHoldAdded = 57, + + + // Sync (global background jobs) + SyncStarted = 60, + SyncCompleted = 61, + SyncFailed = 62, +} diff --git a/Kavita.Models/Entities/Enums/ScrobbleProvider.cs b/Kavita.Models/Entities/Enums/ScrobbleProvider.cs index a1f12dd7c..58ab9d60f 100644 --- a/Kavita.Models/Entities/Enums/ScrobbleProvider.cs +++ b/Kavita.Models/Entities/Enums/ScrobbleProvider.cs @@ -15,4 +15,5 @@ public enum ScrobbleProvider Mal = 2, Cbr = 4, Hardcover = 5, + MangaBaka = 6 } diff --git a/Kavita.Models/Entities/History/KavitaPlusAuditLog.cs b/Kavita.Models/Entities/History/KavitaPlusAuditLog.cs new file mode 100644 index 000000000..d6a4ed899 --- /dev/null +++ b/Kavita.Models/Entities/History/KavitaPlusAuditLog.cs @@ -0,0 +1,52 @@ +using System; +using Kavita.Models.Entities.Enums.Audit; +using Kavita.Models.Entities.User; + +namespace Kavita.Models.Entities.History; +#nullable enable + +/// +/// Records a durable, queryable log of every significant Kavita+ event: +/// matching, metadata writes, scrobble sends, collection syncs, and people updates. +/// +public class KavitaPlusAuditLog +{ + public long Id { get; set; } + public DateTime CreatedUtc { get; set; } + + public KavitaPlusAuditCategory Category { get; set; } + public KavitaPlusEventType EventType { get; set; } + public AuditStatus Status { get; set; } + + /// + /// Series FK - set for Series, Chapter, and series-contextual events. No cascade delete: logs outlive entities + /// + public int? SeriesId { get; set; } + + /// + /// Discriminator describing what SubjectId refers to + /// + public AuditSubjectType SubjectType { get; set; } + + /// PersonId, CollectionId, or ChapterId depending on SubjectType. Null for Series/Global events + public int? SubjectId { get; set; } + + /// + /// JSON-serialized event-specific payload. + /// + public string? Payload { get; set; } + + public string? ErrorMessage { get; set; } + + /// + /// Scrobble events that failed allow retrying + /// + public bool HasRetried { get; set; } + + /// + /// The user who triggered this event. Null for system-initiated events. + /// No cascade delete: logs outlive users. + /// + public int? UserId { get; set; } + public AppUser? User { get; set; } +} diff --git a/Kavita.Models/Entities/History/KavitaPlusHistory.cs b/Kavita.Models/Entities/History/KavitaPlusHistory.cs deleted file mode 100644 index faa8dbdee..000000000 --- a/Kavita.Models/Entities/History/KavitaPlusHistory.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Kavita.Models.Entities.History; - -/// -/// Records history of actions Kavita+ takes -/// -// public class KavitaPlusHistory -// { -// -// } diff --git a/Kavita.Models/Entities/Metadata/ExternalSeriesMetadata.cs b/Kavita.Models/Entities/Metadata/ExternalSeriesMetadata.cs index 2e31b3924..caaf30731 100644 --- a/Kavita.Models/Entities/Metadata/ExternalSeriesMetadata.cs +++ b/Kavita.Models/Entities/Metadata/ExternalSeriesMetadata.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; +using Kavita.Models.Entities.Interfaces; namespace Kavita.Models.Entities.Metadata; /// /// External Metadata from Kavita+ for a Series /// -public class ExternalSeriesMetadata +public class ExternalSeriesMetadata : IEntityDate { public int Id { get; set; } /// @@ -37,4 +38,8 @@ public class ExternalSeriesMetadata public Series Series { get; set; } = null!; public int SeriesId { get; set; } + public DateTime Created { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModified { get; set; } + public DateTime LastModifiedUtc { get; set; } } diff --git a/Kavita.Models/Extensions/PlusMediaFormatExtensions.cs b/Kavita.Models/Extensions/PlusMediaFormatExtensions.cs index e6534da6e..6cbb2ce3e 100644 --- a/Kavita.Models/Extensions/PlusMediaFormatExtensions.cs +++ b/Kavita.Models/Extensions/PlusMediaFormatExtensions.cs @@ -9,13 +9,13 @@ public static class PlusMediaFormatExtensions { public static PlusMediaFormat ConvertToPlusMediaFormat(this LibraryType libraryType, MangaFormat? seriesFormat = null) { - + // TODO: Amelia, let's rework this with v3/scrobbling return libraryType switch { LibraryType.Manga => seriesFormat is MangaFormat.Epub ? PlusMediaFormat.LightNovel : PlusMediaFormat.Manga, LibraryType.Comic => PlusMediaFormat.Comic, LibraryType.LightNovel => PlusMediaFormat.LightNovel, - LibraryType.Book => PlusMediaFormat.LightNovel, + LibraryType.Book => PlusMediaFormat.Book, LibraryType.Image => PlusMediaFormat.Manga, LibraryType.ComicVine => PlusMediaFormat.Comic, _ => throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null) diff --git a/Kavita.Server/Controllers/CBLController.cs b/Kavita.Server/Controllers/CBLController.cs index fc92a3cc6..a1483e020 100644 --- a/Kavita.Server/Controllers/CBLController.cs +++ b/Kavita.Server/Controllers/CBLController.cs @@ -123,6 +123,11 @@ public class CblController(IReadingListService readingListService, IDirectorySer private async Task<(bool IsInvalid, ActionResult? ActionResult)> HasInvalidExtensionAsync(string filename, string fullPath) { + if (!ValidateFilename(Path.Join(fullPath, filename))) + { + return (true, BadRequest(await localizationService.TranslateAsync("cbl-import-validation-types"))); + } + var ext = Path.GetExtension(filename); if (!ext.Equals(".cbl", StringComparison.OrdinalIgnoreCase) && !ext.Equals(".json", StringComparison.OrdinalIgnoreCase)) { diff --git a/Kavita.Server/Controllers/ImageController.cs b/Kavita.Server/Controllers/ImageController.cs index 1ce8613ce..3cf68ce8e 100644 --- a/Kavita.Server/Controllers/ImageController.cs +++ b/Kavita.Server/Controllers/ImageController.cs @@ -267,6 +267,7 @@ public class ImageController(IUnitOfWork unitOfWork, IDirectoryService directory return PhysicalFile(path); } + [HttpGet("external/series")] [Authorize(PolicyGroups.AdminPolicy)] [SeriesAccess] diff --git a/Kavita.Server/Controllers/KavitaPlusAuditController.cs b/Kavita.Server/Controllers/KavitaPlusAuditController.cs new file mode 100644 index 000000000..2b7c07abf --- /dev/null +++ b/Kavita.Server/Controllers/KavitaPlusAuditController.cs @@ -0,0 +1,72 @@ +using System.Threading.Tasks; +using Kavita.API.Database; +using Kavita.Common.Helpers; +using Kavita.Models.Constants; +using Kavita.Models.DTOs.KavitaPlus; +using Kavita.Server.Attributes; +using Kavita.Server.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Kavita.Server.Controllers; + +[KPlus] +[Route("api/kavita-plus-audit")] +public class KavitaPlusAuditController(IUnitOfWork unitOfWork) : BaseApiController +{ + /// + /// Returns a paged, filtered list of all Kavita+ audit events. Admin only. + /// + [HttpPost("entries")] + [Authorize(Policy = PolicyGroups.AdminPolicy)] + public async Task>> GetEntries( + KavitaPlusAuditFilterDto filter, [FromQuery] UserParams? userParams) + { + userParams ??= UserParams.Default; + + var res = await unitOfWork.KavitaPlusAuditRepository.GetPagedAsync(filter, userParams); + Response.AddPaginationHeader(res); + + return Ok(res); + } + + /// + /// Returns Kavita+ audit info scoped to a single series, for the popover. + /// Scrobble events are filtered to the calling user unless they are an admin. + /// + [HttpGet("entries/series/{seriesId:int}")] + [SeriesAccess] + public async Task> GetSeriesInfo(int seriesId) + { + var isAdmin = User.IsInRole(PolicyConstants.AdminRole); + var result = await unitOfWork.KavitaPlusAuditRepository + .GetSeriesInfoAsync(seriesId, UserId, isAdmin); + return Ok(result); + } + + /// + /// Returns aggregate stats for the admin audit feed header strip. + /// + [HttpGet("stats")] + [Authorize(Policy = PolicyGroups.AdminPolicy)] + public async Task> GetStats() + { + return Ok(await unitOfWork.KavitaPlusAuditRepository.GetStatsAsync()); + } + + /// + /// Returns the calling user's own Kavita+ activity, paged and filtered. + /// + [HttpPost("my-activity")] + public async Task>> GetMyActivity( + KavitaPlusAuditFilterDto filter, [FromQuery] UserParams? userParams) + { + userParams ??= UserParams.Default; + + var res = await unitOfWork.KavitaPlusAuditRepository + .GetMyActivityAsync(UserId, filter, userParams); + Response.AddPaginationHeader(res); + + return Ok(res); + } +} diff --git a/Kavita.Server/Controllers/ReaderController.cs b/Kavita.Server/Controllers/ReaderController.cs index b1fd08c0c..31157f425 100644 --- a/Kavita.Server/Controllers/ReaderController.cs +++ b/Kavita.Server/Controllers/ReaderController.cs @@ -318,6 +318,8 @@ public class ReaderController(ICacheService cacheService, => s.GenerateReadingSessionForChapters(UserId, dto.SeriesId, progressDictionary, CancellationToken.None)); } + BackgroundJob.Enqueue(s => s.ScrobbleReadingUpdate(user.Id, dto.SeriesId)); + return Ok(); } @@ -474,8 +476,8 @@ public class ReaderController(ICacheService cacheService, if (!await unitOfWork.CommitAsync()) return BadRequest(await localizationService.TranslateAsync(UserId, "generic-read-progress")); - BackgroundJob.Enqueue(() => scrobblingService.ScrobbleReadingUpdate(user.Id, dto.SeriesId)); - BackgroundJob.Enqueue(() => unitOfWork.SeriesRepository.ClearOnDeckRemovalAsync(dto.SeriesId, user.Id)); + BackgroundJob.Enqueue(s => s.ScrobbleReadingUpdate(user.Id, dto.SeriesId)); + BackgroundJob.Enqueue(s => s.SeriesRepository.ClearOnDeckRemovalAsync(dto.SeriesId, user.Id)); if (dto.GenerateReadingSession) { @@ -508,7 +510,7 @@ public class ReaderController(ICacheService cacheService, if (await unitOfWork.CommitAsync()) { - BackgroundJob.Enqueue(() => scrobblingService.ScrobbleReadingUpdate(user.Id, dto.SeriesId)); + BackgroundJob.Enqueue((s) => s.ScrobbleReadingUpdate(user.Id, dto.SeriesId)); return Ok(); } diff --git a/Kavita.Server/Controllers/ScrobblingController.cs b/Kavita.Server/Controllers/ScrobblingController.cs index fe947f122..a1d224067 100644 --- a/Kavita.Server/Controllers/ScrobblingController.cs +++ b/Kavita.Server/Controllers/ScrobblingController.cs @@ -10,9 +10,11 @@ using Kavita.API.Services.Plus; using Kavita.Common.Helpers; using Kavita.Models.Builders; using Kavita.Models.Constants; +using Kavita.Models.DTOs.KavitaPlus; using Kavita.Models.DTOs.KavitaPlus.Account; using Kavita.Models.DTOs.Scrobbling; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.Audit; using Kavita.Models.Entities.Scrobble; using Kavita.Server.Attributes; using Kavita.Server.Extensions; @@ -28,7 +30,8 @@ public class ScrobblingController( IUnitOfWork unitOfWork, IScrobblingService scrobblingService, ILogger logger, - ILocalizationService localizationService) + ILocalizationService localizationService, + IKavitaPlusAuditService kavitaPlusAuditService) : BaseApiController { /// @@ -224,6 +227,8 @@ public class ScrobblingController( // When a hold is placed on a series, clear any pre-existing Scrobble Events await scrobblingService.ClearEventsForSeries(user.Id, seriesId); + await kavitaPlusAuditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleHoldAdded, seriesId, + new AuditLogScrobbleParamsDto(), AuditStatus.Success, null, UserId, HttpContext.RequestAborted); return Ok(); } catch (DbUpdateConcurrencyException ex) @@ -237,6 +242,8 @@ public class ScrobblingController( // Retry the update unitOfWork.UserRepository.Update(user); await unitOfWork.CommitAsync(); + await kavitaPlusAuditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleHoldAdded, seriesId, + new AuditLogScrobbleParamsDto(), AuditStatus.Success, null, UserId, HttpContext.RequestAborted); return Ok(); } catch (Exception ex) @@ -257,18 +264,22 @@ public class ScrobblingController( [DisallowRole(PolicyConstants.ReadOnlyRole)] public async Task RemoveHold(int seriesId) { - var user = await unitOfWork.UserRepository.GetUserByIdAsync(UserId, AppUserIncludes.ScrobbleHolds); + var user = await unitOfWork.UserRepository.GetUserByIdAsync(UserId, AppUserIncludes.ScrobbleHolds, HttpContext.RequestAborted); if (user == null) return Unauthorized(); user.ScrobbleHolds = user.ScrobbleHolds.Where(h => h.SeriesId != seriesId).ToList(); unitOfWork.UserRepository.Update(user); - await unitOfWork.CommitAsync(); + await unitOfWork.CommitAsync(HttpContext.RequestAborted); + + await kavitaPlusAuditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleHoldRemoved, seriesId, + new AuditLogScrobbleParamsDto(), AuditStatus.Success, null, UserId, HttpContext.RequestAborted); + return Ok(); } /// - /// Has the logged in user ran scrobble generation + /// Has the logged-in user ran scrobble generation /// /// [HttpGet("has-ran-scrobble-gen")] @@ -292,4 +303,21 @@ public class ScrobblingController( await unitOfWork.CommitAsync(); return Ok(); } + + + /// + /// Attempts to retry Scrobble Events for the current authenticated user (or admin-allowed). + /// + /// + /// true if successful, false in all other cases (validation) + [HttpPost("retry-scrobble")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + public async Task> RetryScrobble(KavitaPlusAuditEntryDto dto) + { + if (!dto.UserId.HasValue) return Ok(false); + if (dto.UserId != UserId && !User.IsInRole(PolicyConstants.AdminRole)) return Ok(false); + + // Locate the Scrobble event or replay the event + return Ok(await scrobblingService.RetryScrobbleAsync(UserId, dto, HttpContext.RequestAborted)); + } } diff --git a/Kavita.Server/Program.cs b/Kavita.Server/Program.cs index 2eee7d542..18277d17c 100644 --- a/Kavita.Server/Program.cs +++ b/Kavita.Server/Program.cs @@ -3,6 +3,7 @@ using System.IO; using System.IO.Abstractions; using System.Linq; using System.Security.Cryptography; +using System.Threading; using System.Threading.Tasks; using Kavita.API.Database; using Kavita.API.Services; @@ -122,7 +123,32 @@ public class Program - await context.Database.MigrateAsync(); + var appLifetime = services.GetRequiredService(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + appLifetime.ApplicationStopping, + timeoutCts.Token + ); + + try + { + await context.Database.MigrateAsync(linkedCts.Token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + logger.LogCritical("Failed to run critical Migrations, restore from a backup"); + Environment.Exit(1); + } + catch (OperationCanceledException) + { + logger.LogCritical("Database migration cancelled due to shutdown signal"); + throw; + } + catch (Exception ex) + { + logger.LogCritical(ex, "Failed to run critical Migrations, restore from a backup"); + Environment.Exit(1); + } await Seed.SeedRoles(services.GetRequiredService>()); diff --git a/Kavita.Services.Tests/ExternalMetadataServiceTests.cs b/Kavita.Services.Tests/ExternalMetadataServiceTests.cs index 5dcaa3f0c..204633b86 100644 --- a/Kavita.Services.Tests/ExternalMetadataServiceTests.cs +++ b/Kavita.Services.Tests/ExternalMetadataServiceTests.cs @@ -99,7 +99,8 @@ public class ExternalMetadataServiceTests: AbstractDbTest Substitute.For>(), mapper, Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For(), Substitute.For()); + Substitute.For(), Substitute.For(), + Substitute.For()); // Clear tracker so test body starts with a clean slate context.ChangeTracker.Clear(); diff --git a/Kavita.Services.Tests/ScrobblingServiceTests.cs b/Kavita.Services.Tests/ScrobblingServiceTests.cs index a42e168e3..b76ed0c02 100644 --- a/Kavita.Services.Tests/ScrobblingServiceTests.cs +++ b/Kavita.Services.Tests/ScrobblingServiceTests.cs @@ -55,7 +55,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT var kavitaPlusApiService = Substitute.For(); var service = new ScrobblingService(unitOfWork, Substitute.For(), logger, licenseService, - localizationService, emailService, kavitaPlusApiService); + localizationService, emailService, kavitaPlusApiService, Substitute.For()); var readerService = new ReaderService(unitOfWork, Substitute.For>(), diff --git a/Kavita.Services/Extensions/ApplicationServiceExtensions.cs b/Kavita.Services/Extensions/ApplicationServiceExtensions.cs index 2bbd30da7..420b2d09f 100644 --- a/Kavita.Services/Extensions/ApplicationServiceExtensions.cs +++ b/Kavita.Services/Extensions/ApplicationServiceExtensions.cs @@ -90,6 +90,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/Kavita.Services/Plus/ExternalMetadataService.cs b/Kavita.Services/Plus/ExternalMetadataService.cs index 4d21815ec..79deaf745 100644 --- a/Kavita.Services/Plus/ExternalMetadataService.cs +++ b/Kavita.Services/Plus/ExternalMetadataService.cs @@ -13,6 +13,7 @@ using Kavita.API.Services.Metadata; using Kavita.API.Services; using Kavita.API.Services.Plus; using Kavita.API.Services.SignalR; +using Kavita.Models.DTOs.KavitaPlus; using Kavita.Common; using Kavita.Common.Extensions; using Kavita.Common.Helpers; @@ -20,6 +21,7 @@ using Kavita.Models.Builders; using Kavita.Models.DTOs; using Kavita.Models.DTOs.Collection; using Kavita.Models.DTOs.KavitaPlus; +using Kavita.Models.DTOs.KavitaPlus.Audit; using Kavita.Models.DTOs.KavitaPlus.ExternalMetadata; using Kavita.Models.DTOs.KavitaPlus.ExternalMetadata.Covers; using Kavita.Models.DTOs.KavitaPlus.Metadata; @@ -31,6 +33,7 @@ using Kavita.Models.DTOs.SeriesDetail; using Kavita.Models.DTOs.SignalR; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.Audit; using Kavita.Models.Entities.Interfaces; using Kavita.Models.Entities.Metadata; using Kavita.Models.Entities.MetadataMatching; @@ -54,6 +57,7 @@ public class ExternalMetadataService : IExternalMetadataService private readonly ICoverDbService _coverDbService; private readonly IKavitaPlusApiService _kavitaPlusApiService; private readonly IFileCacheService _fileCacheService; + private readonly IKavitaPlusAuditService _auditService; private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30); public static readonly HashSet NonEligibleLibraryTypes = [LibraryType.Comic, LibraryType.Book, LibraryType.Image]; @@ -70,7 +74,7 @@ public class ExternalMetadataService : IExternalMetadataService public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService, - IKavitaPlusApiService kavitaPlusApiService, IFileCacheService fileCacheService) + IKavitaPlusApiService kavitaPlusApiService, IFileCacheService fileCacheService, IKavitaPlusAuditService auditService) { _unitOfWork = unitOfWork; _logger = logger; @@ -81,6 +85,7 @@ public class ExternalMetadataService : IExternalMetadataService _coverDbService = coverDbService; _kavitaPlusApiService = kavitaPlusApiService; _fileCacheService = fileCacheService; + _auditService = auditService; FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } @@ -333,6 +338,8 @@ public class ExternalMetadataService : IExternalMetadataService // Name can be null on Series even with a direct match _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, metadata.Series.Name); + await _auditService.LogMatchAsync(KavitaPlusEventType.SeriesMatchFixed, seriesId, + new AuditLogMatchClearedParamsDto { SeriesName = series.Name, MatchedName = metadata.Series.Name }, ct: ct); } catch (KavitaException ex) { @@ -366,6 +373,9 @@ public class ExternalMetadataService : IExternalMetadataService _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(ct); + + await _auditService.LogMatchAsync(KavitaPlusEventType.SeriesDontMatchSet, seriesId, + new AuditLogMatchDontMatchParamsDto { SeriesName = series.Name, DontMatch = dontMatch }, ct: ct); } /// @@ -391,10 +401,29 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName); SeriesDetailPlusApiDto? result = null; + await _auditService.LogAsync( + KavitaPlusAuditCategory.Metadata, + KavitaPlusEventType.MetadataFetched, + AuditStatus.Info, + AuditSubjectType.Series, + seriesId: seriesId, + payload: new AuditLogMetadataFetchParamsDto + { + SeriesId = seriesId, + LibraryId = series.Library?.Id, + Format = series.Format, + MangaBakaId = series.MangaBakaId, + CbrId = series.CbrId, + AniListId = series.AniListId, + HardcoverId = series.HardcoverId, + }, + ct: ct); + try { // This returns an AniListSeries and Match returns ExternalSeriesDto result = await _kavitaPlusApiService.GetSeriesDetailAsync(data, ct); + } catch (FlurlHttpException ex) { @@ -415,6 +444,8 @@ public class ExternalMetadataService : IExternalMetadataService { series.IsBlacklisted = true; await _unitOfWork.CommitAsync(ct); + await _auditService.LogMatchAsync(KavitaPlusEventType.SeriesBlacklisted, seriesId, + new AuditLogMatchFailureParamsDto { SeriesName = series.Name, Reason = "unknown-series" }, AuditStatus.Failure, ct: ct); } } } @@ -422,6 +453,8 @@ public class ExternalMetadataService : IExternalMetadataService if (result == null) { _logger.LogInformation("Hit rate limit twice, try again later"); + await _auditService.LogMatchAsync(KavitaPlusEventType.SeriesMatchFailed, seriesId, + new AuditLogMatchFailureParamsDto { SeriesName = series.Name, Reason = "rate-limit-hit" }, AuditStatus.Failure, ct: ct); return _defaultReturn; } @@ -460,10 +493,16 @@ public class ExternalMetadataService : IExternalMetadataService .Average(r => r.AverageScore) : 0; // prefer what was passed in (manual match), fall back to what K+ returned + var beforeIds = new AuditLogMatchExternalIdsParamsDto { AniListId = series.AniListId, MalId = series.MalId, MangaBakaId = series.MangaBakaId, CbrId = series.CbrId }; + externalSeriesMetadata.MalId = data.MalId ?? result.MalId ?? 0; externalSeriesMetadata.AniListId = data.AniListId ?? result.AniListId ?? 0; externalSeriesMetadata.CbrId = data.CbrId ?? result.CbrId ?? 0; series.MangaBakaId = data.MangabakaId ?? result.MangabakaId ?? 0; + var afterIds = new AuditLogMatchExternalIdsParamsDto { AniListId = externalSeriesMetadata.AniListId, MalId = externalSeriesMetadata.MalId, MangaBakaId = series.MangaBakaId, CbrId = externalSeriesMetadata.CbrId }; + + await _auditService.LogMatchAsync(KavitaPlusEventType.SeriesMatched, seriesId, + new AuditLogMatchedParamsDto { SeriesName = series.Name, Before = beforeIds, After = afterIds, MatchedName = result.Series?.Name }, ct: ct); // If there is metadata and the user has metadata download turned on var madeMetadataModification = false; @@ -553,34 +592,39 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogInformation("Writing External metadata to Series {SeriesName}", series.Name); var madeModification = false; + var fieldChanges = new List(); var processedGenres = new List(); var processedTags = new List(); - madeModification = UpdateSummary(series, settings, externalMetadata) || madeModification; - madeModification = UpdateReleaseYear(series, settings, externalMetadata) || madeModification; - madeModification = UpdateLocalizedName(series, settings, externalMetadata) || madeModification; - madeModification = await UpdatePublicationStatus(series, settings, externalMetadata) || madeModification; - madeModification = UpdateExternalIds(series, settings, externalMetadata) || madeModification; + // TODO: Clean this up with a helper + Accumulate(ref madeModification, fieldChanges, UpdateSummary(series, settings, externalMetadata)); + Accumulate(ref madeModification, fieldChanges, UpdateReleaseYear(series, settings, externalMetadata)); + Accumulate(ref madeModification, fieldChanges, UpdateLocalizedName(series, settings, externalMetadata)); + Accumulate(ref madeModification, fieldChanges, await UpdatePublicationStatus(series, settings, externalMetadata)); + Accumulate(ref madeModification, fieldChanges, UpdateExternalIds(series, settings, externalMetadata)); // Apply field mappings GenerateGenreAndTagLists(externalMetadata, settings, ref processedTags, ref processedGenres); - madeModification = await UpdateGenres(series, settings, externalMetadata, processedGenres) || madeModification; - madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification; - madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification; + Accumulate(ref madeModification, fieldChanges, await UpdateGenres(series, settings, externalMetadata, processedGenres)); + Accumulate(ref madeModification, fieldChanges, await UpdateTags(series, settings, externalMetadata, processedTags)); + Accumulate(ref madeModification, fieldChanges, UpdateAgeRating(series, settings, processedGenres.Concat(processedTags))); var staff = await SetNameAndAddAliases(settings, externalMetadata.Staff); - madeModification = await UpdateWriters(series, settings, staff) || madeModification; - madeModification = await UpdateArtists(series, settings, staff) || madeModification; - madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification; + Accumulate(ref madeModification, fieldChanges, await UpdateWriters(series, settings, staff)); + Accumulate(ref madeModification, fieldChanges, await UpdateArtists(series, settings, staff)); + Accumulate(ref madeModification, fieldChanges, await UpdateCharacters(series, settings, externalMetadata.Characters)); - madeModification = await UpdateRelationships(series, settings, externalMetadata.Relations, defaultAdmin) || madeModification; + Accumulate(ref madeModification, fieldChanges, await UpdateRelationships(series, settings, externalMetadata.Relations, defaultAdmin)); madeModification = await UpdateCoverImage(series, settings, externalMetadata) || madeModification; madeModification = await UpdateChapters(series, settings, externalMetadata) || madeModification; - + if (fieldChanges.Count > 0) + { + await _auditService.LogMetadataAsync(seriesId, fieldChanges, ct); + } return madeModification; } @@ -684,6 +728,8 @@ public class ExternalMetadataService : IExternalMetadataService { modified = true; person.Aliases.Add(new PersonAliasBuilder(mapping.PreferredName).Build()); + await _auditService.LogPersonAsync(KavitaPlusEventType.PersonAliasAdded, person.Id, + new AuditLogPersonAliasParamsDto { PersonName = person.Name, AliasAdded = mapping.PreferredName }); } } @@ -770,15 +816,16 @@ public class ExternalMetadataService : IExternalMetadataService GenerateGenreAndTagLists(genres, tags, settings, ref processedTags, ref processedGenres); } - private async Task UpdateRelationships(Series series, MetadataSettingsDto settings, IList? externalMetadataRelations, AppUser defaultAdmin) + private async Task<(bool, MetadataFieldChangeDto?)> UpdateRelationships(Series series, MetadataSettingsDto settings, IList? externalMetadataRelations, AppUser defaultAdmin) { - if (!settings.EnableRelationships) return false; + if (!settings.EnableRelationships) return (false, null); if (externalMetadataRelations == null || externalMetadataRelations.Count == 0 || defaultAdmin == null) { - return false; + return (false, null); } + var addedRelations = new List(); foreach (var relation in externalMetadataRelations.Where(r => r.Relation != RelationKind.Parent)) { List names = new [] {relation.SeriesName.PreferredTitle, relation.SeriesName.RomajiTitle, relation.SeriesName.EnglishTitle, relation.SeriesName.NativeTitle}.Where(s => !string.IsNullOrEmpty(s)).ToList()!; @@ -806,6 +853,7 @@ public class ExternalMetadataService : IExternalMetadataService SeriesId = series.Id, }; series.Relations.Add(newRelation); + addedRelations.Add(new { relatedSeriesName = relatedSeries.Name, relatedSeriesId = relatedSeries.Id, kind = relation.Relation.ToString() }); // Handle sequel/prequel: add reverse relationship if (relation.Relation is RelationKind.Prequel or RelationKind.Sequel) @@ -829,28 +877,26 @@ public class ExternalMetadataService : IExternalMetadataService _unitOfWork.SeriesRepository.Update(series); } - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - } + if (!_unitOfWork.HasChanges()) return (false, null); + await _unitOfWork.CommitAsync(); - return true; + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.Relationships, null, addedRelations)); } - private async Task UpdateCharacters(Series series, MetadataSettingsDto settings, IList? externalCharacters) + private async Task<(bool, MetadataFieldChangeDto?)> UpdateCharacters(Series series, MetadataSettingsDto settings, IList? externalCharacters) { - if (!settings.EnablePeople) return false; + if (!settings.EnablePeople) return (false, null); - if (externalCharacters == null || externalCharacters.Count == 0) return false; + if (externalCharacters == null || externalCharacters.Count == 0) return (false, null); if (series.Metadata.CharacterLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.People)) { - return false; + return (false, null); } if (!settings.IsPersonAllowed(PersonRole.Character)) { - return false; + return (false, null); } series.Metadata.People ??= []; @@ -871,7 +917,7 @@ public class ExternalMetadataService : IExternalMetadataService .DistinctBy(p => Parser.Normalize(p.Name)) .ToList(); - if (characters.Count == 0) return false; + if (characters.Count == 0) return (false, null); await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); @@ -911,28 +957,28 @@ public class ExternalMetadataService : IExternalMetadataService } series.Metadata.AddKPlusOverride(MetadataSettingField.People); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.Characters, null, externalCharacters.Select(c => c.Name).ToList())); } - private async Task UpdateArtists(Series series, MetadataSettingsDto settings, List staff) + private async Task<(bool, MetadataFieldChangeDto?)> UpdateArtists(Series series, MetadataSettingsDto settings, List staff) { - if (!settings.EnablePeople) return false; - + if (!settings.EnablePeople) return (false, null); var upstreamArtists = staff .Where(s => s.Role is "Art" or "Story & Art") .ToList(); - if (upstreamArtists.Count == 0) return false; + if (upstreamArtists.Count == 0) return (false, null); if (series.Metadata.CoverArtistLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.People)) { - return false; + return (false, null); } if (!settings.IsPersonAllowed(PersonRole.CoverArtist)) { - return false; + return (false, null); } series.Metadata.People ??= []; @@ -967,29 +1013,29 @@ public class ExternalMetadataService : IExternalMetadataService await _unitOfWork.CommitAsync(); await DownloadAndSetPersonCovers(upstreamArtists); - series.Metadata.AddKPlusOverride(MetadataSettingField.People); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.Artists, null, upstreamArtists.Select(a => a.Name).ToList())); } - private async Task UpdateWriters(Series series, MetadataSettingsDto settings, List staff) + private async Task<(bool, MetadataFieldChangeDto?)> UpdateWriters(Series series, MetadataSettingsDto settings, List staff) { - if (!settings.EnablePeople) return false; + if (!settings.EnablePeople) return (false, null); var upstreamWriters = staff .Where(s => s.Role is "Story" or "Story & Art") .ToList(); - if (upstreamWriters.Count == 0) return false; + if (upstreamWriters.Count == 0) return (false, null); if (series.Metadata.WriterLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.People)) { - return false; + return (false, null); } if (!settings.IsPersonAllowed(PersonRole.Writer)) { - return false; + return (false, null); } series.Metadata.People ??= []; @@ -1008,7 +1054,6 @@ public class ExternalMetadataService : IExternalMetadataService .DistinctBy(p => Parser.Normalize(p.Name)) .ToList(); - await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork); foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.Writer)) @@ -1026,25 +1071,27 @@ public class ExternalMetadataService : IExternalMetadataService await DownloadAndSetPersonCovers(upstreamWriters); series.Metadata.AddKPlusOverride(MetadataSettingField.People); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.Writers, null, upstreamWriters.Select(w => w.Name).ToList())); } - private async Task UpdateTags(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedTags) + private async Task<(bool, MetadataFieldChangeDto?)> UpdateTags(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedTags) { externalMetadata.Tags ??= []; - if (!settings.EnableTags || processedTags.Count == 0) return false; + if (!settings.EnableTags || processedTags.Count == 0) return (false, null); if (series.Metadata.TagsLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Tags)) { - return false; + return (false, null); } _logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name); var madeModification = false; + series.Metadata.Tags ??= []; + var before = series.Metadata.Tags.Select(t => t.Title).ToList(); var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize))) .ToList(); - series.Metadata.Tags ??= []; TagHelper.UpdateTagList(processedTags, series.Metadata.Tags, allTags, tag => { @@ -1052,12 +1099,11 @@ public class ExternalMetadataService : IExternalMetadataService madeModification = true; }, () => series.Metadata.TagsLocked = true); - if (madeModification) - { - series.Metadata.AddKPlusOverride(MetadataSettingField.Tags); - } + if (!madeModification) return (false, null); + series.Metadata.AddKPlusOverride(MetadataSettingField.Tags); + var after = series.Metadata.Tags.Select(t => t.Title).ToList(); - return madeModification; + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.Tags, before, after)); } private static List ApplyBlackWhiteList(MetadataSettingsDto settings, MetadataFieldType fieldType, List processedStrings) @@ -1078,22 +1124,23 @@ public class ExternalMetadataService : IExternalMetadataService }; } - private async Task UpdateGenres(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedGenres) + private async Task<(bool, MetadataFieldChangeDto?)> UpdateGenres(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedGenres) { externalMetadata.Genres ??= []; - if (!settings.EnableGenres || processedGenres.Count == 0) return false; + if (!settings.EnableGenres || processedGenres.Count == 0) return (false, null); if (series.Metadata.GenresLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Genres)) { - return false; + return (false, null); } _logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name); var madeModification = false; - var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); series.Metadata.Genres ??= []; - var exisitingGenres = series.Metadata.Genres; + var before = series.Metadata.Genres.Select(g => g.Title).ToList(); + var existingGenres = series.Metadata.Genres; + var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); TagHelper.UpdateTagList(processedGenres, series.Metadata.Genres, allGenres, genre => { @@ -1101,32 +1148,32 @@ public class ExternalMetadataService : IExternalMetadataService madeModification = true; }, () => series.Metadata.GenresLocked = true); - foreach (var genre in exisitingGenres) + foreach (var genre in existingGenres) { if (series.Metadata.Genres.FirstOrDefault(g => g.NormalizedTitle == genre.NormalizedTitle) != null) continue; series.Metadata.Genres.Add(genre); madeModification = true; } - if (madeModification) - { - series.Metadata.AddKPlusOverride(MetadataSettingField.Genres); - } + if (!madeModification) return (false, null); + series.Metadata.AddKPlusOverride(MetadataSettingField.Genres); + var after = series.Metadata.Genres.Select(g => g.Title).ToList(); - return madeModification; + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.Genres, before, after)); } - private async Task UpdatePublicationStatus(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + private async Task<(bool, MetadataFieldChangeDto?)> UpdatePublicationStatus(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) { - if (!settings.EnablePublicationStatus) return false; + if (!settings.EnablePublicationStatus) return (false, null); if (series.Metadata.PublicationStatusLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.PublicationStatus)) { - return false; + return (false, null); } try { + var from = series.Metadata.PublicationStatus; var chapters = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes .SelectMany(v => v.Chapters).ToList(); @@ -1135,37 +1182,38 @@ public class ExternalMetadataService : IExternalMetadataService series.Metadata.PublicationStatus = status; series.Metadata.PublicationStatusLocked = true; series.Metadata.AddKPlusOverride(MetadataSettingField.PublicationStatus); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.PublicationStatus, from.ToString(), status.ToString())); } catch (Exception ex) { _logger.LogError(ex, "There was an issue determining Publication Status for Series {SeriesName} ({SeriesId})", series.Name, series.Id); } - return false; + return (false, null); } - private bool UpdateAgeRating(Series series, MetadataSettingsDto settings, IEnumerable allExternalTags) + private (bool, MetadataFieldChangeDto?) UpdateAgeRating(Series series, MetadataSettingsDto settings, IEnumerable allExternalTags) { - if (series.Metadata.AgeRatingLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.AgeRating)) { - return false; + return (false, null); } try { - // Determine Age Rating var totalTags = allExternalTags .Concat(series.Metadata.Genres.Select(g => g.Title)) .Concat(series.Metadata.Tags.Select(g => g.Title)); + var from = series.Metadata.AgeRating; var ageRating = DetermineAgeRating(totalTags, settings.AgeRatingMappings); if (series.Metadata.AgeRating <= ageRating) { series.Metadata.AgeRating = ageRating; series.Metadata.AddKPlusOverride(MetadataSettingField.AgeRating); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.AgeRating, from.ToString(), ageRating.ToString())); } } catch (Exception ex) @@ -1173,12 +1221,13 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogError(ex, "There was an issue determining Age Rating for Series {SeriesName} ({SeriesId})", series.Name, series.Id); } - return false; + return (false, null); } - private static bool UpdateExternalIds(Series series, MetadataSettingsDto _, ExternalSeriesDetailDto externalMetadata) + private static (bool, MetadataFieldChangeDto?) UpdateExternalIds(Series series, MetadataSettingsDto _, ExternalSeriesDetailDto externalMetadata) { var madeModification = false; + var from = new { aniListId = series.AniListId, malId = series.MalId, cbrId = series.CbrId, mangaBakaId = series.MangaBakaId }; if (externalMetadata.AniListId is > 0) { series.AniListId = externalMetadata.AniListId.Value; @@ -1205,7 +1254,10 @@ public class ExternalMetadataService : IExternalMetadataService // TODO: Add the rest of the Ids when Kavita+ has them - return madeModification; + if (!madeModification) return (false, null); + var to = new { aniListId = series.AniListId, malId = series.MalId, cbrId = series.CbrId, mangaBakaId = series.MangaBakaId }; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.ExternalIds, from, to)); } @@ -1232,11 +1284,11 @@ public class ExternalMetadataService : IExternalMetadataService foreach (var (chapter, potentialMatch) in matchedChapters) { _logger.LogDebug("Updating {ChapterNumber} with metadata", chapter.Range); + var chapterFieldChanges = new List(); - // Write the metadata - madeModification = UpdateChapterTitle(chapter, settings, potentialMatch.Title, series.Name) || madeModification; - madeModification = UpdateChapterSummary(chapter, settings, potentialMatch.Summary) || madeModification; - madeModification = UpdateChapterReleaseDate(chapter, settings, potentialMatch.ReleaseDate) || madeModification; + Accumulate(ref madeModification, chapterFieldChanges, UpdateChapterTitle(chapter, settings, potentialMatch.Title, series.Name)); + Accumulate(ref madeModification, chapterFieldChanges, UpdateChapterSummary(chapter, settings, potentialMatch.Summary)); + Accumulate(ref madeModification, chapterFieldChanges, UpdateChapterReleaseDate(chapter, settings, potentialMatch.ReleaseDate)); var hasUpdatedPublisher = await UpdateChapterPublisher(chapter, settings, potentialMatch.Publisher); if (hasUpdatedPublisher) chapter.AddKPlusOverride(MetadataSettingField.ChapterPublisher); @@ -1245,9 +1297,14 @@ public class ExternalMetadataService : IExternalMetadataService madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.CoverArtist, potentialMatch.Artists) || madeModification; madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification; - madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification; + madeModification = await UpdateChapterCoverImage(chapter, settings, series.Id, potentialMatch.CoverImageUrl) || madeModification; madeModification = await UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification; + if (chapterFieldChanges.Count > 0) + { + await _auditService.LogChapterMetadataAsync(chapter.Id, series.Id, chapterFieldChanges); + } + _unitOfWork.ChapterRepository.Update(chapter); await _unitOfWork.CommitAsync(); } @@ -1352,67 +1409,73 @@ public class ExternalMetadataService : IExternalMetadataService } - private static bool UpdateChapterSummary(Chapter chapter, MetadataSettingsDto settings, string? summary) + private static (bool, MetadataFieldChangeDto?) UpdateChapterSummary(Chapter chapter, MetadataSettingsDto settings, string? summary) { - if (!settings.EnableChapterSummary) return false; + if (!settings.EnableChapterSummary) return (false, null); - if (string.IsNullOrEmpty(summary)) return false; + if (string.IsNullOrEmpty(summary)) return (false, null); if (chapter.SummaryLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterSummary)) { - return false; + return (false, null); } if (!string.IsNullOrWhiteSpace(summary) && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterSummary)) { - return false; + return (false, null); } + var from = chapter.Summary; chapter.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(summary)); chapter.AddKPlusOverride(MetadataSettingField.ChapterSummary); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.Summary, from, chapter.Summary)); } - private static bool UpdateChapterTitle(Chapter chapter, MetadataSettingsDto settings, string? title, string seriesName) + private static (bool, MetadataFieldChangeDto?) UpdateChapterTitle(Chapter chapter, MetadataSettingsDto settings, string? title, string seriesName) { - if (!settings.EnableChapterTitle) return false; + if (!settings.EnableChapterTitle) return (false, null); - if (string.IsNullOrEmpty(title)) return false; + if (string.IsNullOrEmpty(title)) return (false, null); if (chapter.TitleNameLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterTitle)) { - return false; + return (false, null); } if (!title.Contains(seriesName) && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterTitle)) { - return false; + return (false, null); } + var from = chapter.TitleName; chapter.TitleName = title; chapter.AddKPlusOverride(MetadataSettingField.ChapterTitle); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.Title, from, title)); } - private static bool UpdateChapterReleaseDate(Chapter chapter, MetadataSettingsDto settings, DateTime? releaseDate) + private static (bool, MetadataFieldChangeDto?) UpdateChapterReleaseDate(Chapter chapter, MetadataSettingsDto settings, DateTime? releaseDate) { - if (!settings.EnableChapterReleaseDate) return false; + if (!settings.EnableChapterReleaseDate) return (false, null); - if (releaseDate == null || releaseDate == DateTime.MinValue) return false; + if (releaseDate == null || releaseDate == DateTime.MinValue) return (false, null); if (chapter.ReleaseDateLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterReleaseDate)) { - return false; + return (false, null); } if (!HasForceOverride(settings, chapter, MetadataSettingField.ChapterReleaseDate)) { - return false; + return (false, null); } + var from = chapter.ReleaseDate; chapter.ReleaseDate = releaseDate.Value; chapter.AddKPlusOverride(MetadataSettingField.ChapterReleaseDate); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.ReleaseDate, from, releaseDate.Value)); } private async Task UpdateChapterPublisher(Chapter chapter, MetadataSettingsDto settings, string? publisher) @@ -1442,7 +1505,7 @@ public class ExternalMetadataService : IExternalMetadataService return await UpdateChapterPeople(chapter, settings, PersonRole.Publisher, [publisher]); } - private async Task UpdateChapterCoverImage(Chapter chapter, MetadataSettingsDto settings, string? coverUrl) + private async Task UpdateChapterCoverImage(Chapter chapter, MetadataSettingsDto settings, int seriesId, string? coverUrl) { if (!settings.EnableChapterCoverImage) return false; @@ -1450,16 +1513,16 @@ public class ExternalMetadataService : IExternalMetadataService if (chapter.CoverImageLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterCovers)) { - return false; - } - - if (string.IsNullOrEmpty(coverUrl)) - { + _logger.LogDebug("Kavita+ Update Chapter was skipped as cover was locked, Chapter: {ChapterId}", chapter.Id); return false; } await DownloadChapterCovers(chapter, coverUrl); chapter.AddKPlusOverride(MetadataSettingField.ChapterCovers); + await _auditService.LogAsync(KavitaPlusAuditCategory.Metadata, KavitaPlusEventType.ChapterCoverUpdated, AuditStatus.Success, + AuditSubjectType.Chapter, seriesId: seriesId, subjectId: chapter.Id, + payload: new AuditLogChapterCoverParamsDto { IssueNumber = chapter.Range, CoverUrl = coverUrl }); + return true; } @@ -1530,45 +1593,52 @@ public class ExternalMetadataService : IExternalMetadataService await DownloadSeriesCovers(series, externalMetadata.CoverUrl); series.Metadata.AddKPlusOverride(MetadataSettingField.Covers); + await _auditService.LogAsync(KavitaPlusAuditCategory.Metadata, KavitaPlusEventType.CoverUpdated, AuditStatus.Success, + AuditSubjectType.Series, seriesId: series.Id, + payload: new AuditLogSeriesCoverParamsDto { SeriesName = series.Name, CoverUrl = externalMetadata.CoverUrl }); return true; } - private static bool UpdateReleaseYear(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + private static (bool, MetadataFieldChangeDto?) UpdateReleaseYear(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) { - if (!settings.EnableStartDate) return false; + if (!settings.EnableStartDate) return (false, null); - if (!externalMetadata.StartDate.HasValue) return false; + if (!externalMetadata.StartDate.HasValue) return (false, null); if (series.Metadata.ReleaseYearLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.StartDate)) { - return false; + return (false, null); } if (series.Metadata.ReleaseYear != 0 && !HasForceOverride(settings, series.Metadata, MetadataSettingField.StartDate)) { - return false; + return (false, null); } + var from = series.Metadata.ReleaseYear; series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; series.Metadata.AddKPlusOverride(MetadataSettingField.StartDate); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.ReleaseYear, from, series.Metadata.ReleaseYear)); } - private static bool UpdateLocalizedName(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + private static (bool, MetadataFieldChangeDto?) UpdateLocalizedName(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) { - if (!settings.EnableLocalizedName) return false; + if (!settings.EnableLocalizedName) return (false, null); if (series.LocalizedNameLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.LocalizedName)) { - return false; + return (false, null); } if (!string.IsNullOrWhiteSpace(series.LocalizedName) && !HasForceOverride(settings, series.Metadata, MetadataSettingField.LocalizedName)) { - return false; + return (false, null); } + var from = series.LocalizedName; + // We need to make the best appropriate guess if (externalMetadata.Name == series.Name) { @@ -1578,7 +1648,7 @@ public class ExternalMetadataService : IExternalMetadataService .Where(s => s.ToNormalized() != series.Name.ToNormalized()) .ToList(); - if (validSynonyms.Count == 0) return false; + if (validSynonyms.Count == 0) return (false, null); series.LocalizedName = validSynonyms[^1]; series.LocalizedNameLocked = true; @@ -1591,31 +1661,40 @@ public class ExternalMetadataService : IExternalMetadataService series.Metadata.AddKPlusOverride(MetadataSettingField.LocalizedName); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.LocalizedName, from, series.LocalizedName)); } - private static bool UpdateSummary(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + private static (bool, MetadataFieldChangeDto?) UpdateSummary(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) { - if (!settings.EnableSummary) return false; + if (!settings.EnableSummary) return (false, null); - if (string.IsNullOrEmpty(externalMetadata.Summary)) return false; + if (string.IsNullOrEmpty(externalMetadata.Summary)) return (false, null); if (series.Metadata.SummaryLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Summary)) { - return false; + return (false, null); } if (!string.IsNullOrWhiteSpace(series.Metadata.Summary) && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Summary)) { - return false; + return (false, null); } + var from = series.Metadata.Summary; series.Metadata.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(externalMetadata.Summary)); series.Metadata.AddKPlusOverride(MetadataSettingField.Summary); - return true; + + return (true, new MetadataFieldChangeDto(MetadataFieldChangeKind.Summary, from, series.Metadata.Summary)); } + private static void Accumulate(ref bool madeModification, List changes, (bool Modified, MetadataFieldChangeDto? Change) result) + { + madeModification = result.Modified || madeModification; + if (result.Change != null) changes.Add(result.Change); + } + private static RelationKind GetReverseRelation(RelationKind relation) { return relation switch @@ -1664,12 +1743,13 @@ public class ExternalMetadataService : IExternalMetadataService try { await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false, true); + await _auditService.LogPersonAsync(KavitaPlusEventType.PersonCoverUpdated, person.Id, + new AuditLogPersonCoverParamsDto { PersonName = person.Name, AniListId = aniListId, ImageUrl = staff.ImageUrl }); } catch (Exception ex) { _logger.LogError(ex, "There was an exception saving cover image for Person {PersonName} ({PersonId})", person.Name, person.Id); } - } } diff --git a/Kavita.Services/Plus/KavitaPlusAuditService.cs b/Kavita.Services/Plus/KavitaPlusAuditService.cs new file mode 100644 index 000000000..e3fd0cbd2 --- /dev/null +++ b/Kavita.Services/Plus/KavitaPlusAuditService.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Kavita.API.Database; +using Kavita.API.Services.Plus; +using Kavita.Models.DTOs.KavitaPlus; +using Kavita.Models.DTOs.KavitaPlus.Audit; +using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.Audit; +using Kavita.Models.Entities.History; +using Microsoft.Extensions.Logging; + +namespace Kavita.Services.Plus; + +public class KavitaPlusAuditService(IUnitOfWork unitOfWork, ILogger logger) + : IKavitaPlusAuditService +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; + private const int RetentionDays = 90; + + public async Task LogAsync( + KavitaPlusAuditCategory category, + KavitaPlusEventType eventType, + AuditStatus status, + AuditSubjectType subjectType = AuditSubjectType.Global, + int? seriesId = null, + int? subjectId = null, + object? payload = null, + string? error = null, + int? userId = null, + CancellationToken ct = default) + { + try + { + var entry = new KavitaPlusAuditLog + { + CreatedUtc = DateTime.UtcNow, + Category = category, + EventType = eventType, + Status = status, + SubjectType = subjectType, + SeriesId = seriesId, + SubjectId = subjectId, + Payload = payload != null ? JsonSerializer.Serialize(payload, JsonOptions) : null, + ErrorMessage = error, + UserId = userId, + }; + unitOfWork.KavitaPlusAuditRepository.Add(entry); + await unitOfWork.CommitAsync(ct); + } + catch (Exception ex) + { + // Audit failures must never surface to callers + logger.LogWarning(ex, "[Kavita+ Audit] Failed to write audit entry {EventType}", eventType); + } + } + + public Task LogMatchAsync(KavitaPlusEventType type, int seriesId, object payload, + AuditStatus status = AuditStatus.Success, string? error = null, CancellationToken ct = default) => + LogAsync(KavitaPlusAuditCategory.Match, type, status, + AuditSubjectType.Series, seriesId: seriesId, payload: payload, error: error, ct: ct); + + public Task LogMetadataAsync(int seriesId, IList changes, CancellationToken ct = default) => + LogAsync(KavitaPlusAuditCategory.Metadata, KavitaPlusEventType.MetadataUpdated, AuditStatus.Success, + AuditSubjectType.Series, seriesId: seriesId, payload: new AuditLogMetadataChangesParamsDto { Changes = changes }, ct: ct); + + public Task LogChapterMetadataAsync(int chapterId, int seriesId, IList changes, + CancellationToken ct = default) => + LogAsync(KavitaPlusAuditCategory.Metadata, KavitaPlusEventType.ChapterMetadataUpdated, AuditStatus.Success, + AuditSubjectType.Chapter, seriesId: seriesId, subjectId: chapterId, payload: new AuditLogMetadataChangesParamsDto { Changes = changes }, ct: ct); + + public Task LogPersonAsync(KavitaPlusEventType type, int personId, object payload, + AuditStatus status = AuditStatus.Success, CancellationToken ct = default) => + LogAsync(KavitaPlusAuditCategory.Metadata, type, status, + AuditSubjectType.Person, subjectId: personId, payload: payload, ct: ct); + + public Task LogCollectionAsync(KavitaPlusEventType type, int collectionId, object payload, + AuditStatus status = AuditStatus.Success, int? userId = null, CancellationToken ct = default) => + LogAsync(KavitaPlusAuditCategory.Sync, type, status, + AuditSubjectType.Collection, subjectId: collectionId, payload: payload, userId: userId, ct: ct); + + public Task LogScrobbleAsync(KavitaPlusEventType type, int seriesId, AuditLogScrobbleParamsDto details, + AuditStatus status, string? error = null, int? userId = null, CancellationToken ct = default) => + LogAsync(KavitaPlusAuditCategory.Scrobble, type, status, + AuditSubjectType.Series, seriesId: seriesId, payload: details, error: error, userId: userId, ct: ct); + + public async Task PurgeOldLogsAsync(CancellationToken ct = default) + { + var cutoff = DateTime.UtcNow.AddDays(-RetentionDays); + await unitOfWork.KavitaPlusAuditRepository.DeleteOlderThanAsync(cutoff, ct); + logger.LogInformation("[Kavita+ Audit] Purged audit logs older than {Cutoff:yyyy-MM-dd}", cutoff); + } +} diff --git a/Kavita.Services/Plus/ScrobblingService.cs b/Kavita.Services/Plus/ScrobblingService.cs index c066eda43..d2426baf1 100644 --- a/Kavita.Services/Plus/ScrobblingService.cs +++ b/Kavita.Services/Plus/ScrobblingService.cs @@ -16,14 +16,17 @@ using Kavita.Common; using Kavita.Common.Helpers; using Kavita.Models.DTOs.Filtering.v2; using Kavita.Models.DTOs.Filtering.v2.Requests; +using Kavita.Models.DTOs.KavitaPlus; using Kavita.Models.DTOs.Scrobbling; using Kavita.Models.DTOs.SignalR; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.Audit; using Kavita.Models.Entities.Metadata; using Kavita.Models.Entities.Scrobble; using Kavita.Models.Entities.User; using Kavita.Models.Extensions; +using Kavita.Services.Helpers; using Kavita.Services.Scanner; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -78,6 +81,7 @@ public class ScrobblingService : IScrobblingService private readonly ILocalizationService _localizationService; private readonly IEmailService _emailService; private readonly IKavitaPlusApiService _kavitaPlusApiService; + private readonly IKavitaPlusAuditService _auditService; public const string AniListWeblinkWebsite = ScrobblingHelper.AniListWeblinkWebsite; public const string MalWeblinkWebsite = ScrobblingHelper.MalWeblinkWebsite; @@ -111,7 +115,7 @@ public class ScrobblingService : IScrobblingService public ScrobblingService(IUnitOfWork unitOfWork, IEventHub eventHub, ILogger logger, ILicenseService licenseService, ILocalizationService localizationService, IEmailService emailService, - IKavitaPlusApiService kavitaPlusApiService) + IKavitaPlusApiService kavitaPlusApiService, IKavitaPlusAuditService auditService) { _unitOfWork = unitOfWork; _eventHub = eventHub; @@ -120,6 +124,7 @@ public class ScrobblingService : IScrobblingService _localizationService = localizationService; _emailService = emailService; _kavitaPlusApiService = kavitaPlusApiService; + _auditService = auditService; FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } @@ -206,11 +211,13 @@ public class ScrobblingService : IScrobblingService { var token = await GetTokenForProvider(userId, provider); + if (string.IsNullOrEmpty(token)) return true; + if (await HasTokenExpired(token, provider)) { // NOTE: Should this side effect be here? await _eventHub.SendMessageToAsync(MessageFactory.ScrobblingKeyExpired, - MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), userId, ct); + MessageFactory.ScrobblingKeyExpiredEvent(provider), userId, ct); return true; } @@ -280,12 +287,15 @@ public class ScrobblingService : IScrobblingService ScrobbleEventType.ScoreUpdated, true, ct); if (existingEvt is {IsProcessed: false}) { - // We need to just update Volume/Chapter number _logger.LogDebug("Overriding scrobble event for {Series} from Rating {Rating} -> {UpdatedRating}", existingEvt.Series.Name, existingEvt.Rating, rating); + var prevRating = existingEvt.Rating; existingEvt.Rating = rating; _unitOfWork.ScrobbleRepository.Update(existingEvt); await _unitOfWork.CommitAsync(ct); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventUpdated, seriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = ScrobbleEventType.ScoreUpdated, Rating = rating, LibraryType = series.Library.Type }, + AuditStatus.Info, userId: userId, ct: ct); return; } @@ -303,6 +313,9 @@ public class ScrobblingService : IScrobblingService _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(ct); _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {AppUserId}", series.Name, userId); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventCreated, seriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = ScrobbleEventType.ScoreUpdated, Rating = rating, LibraryType = series.Library.Type }, + AuditStatus.Info, userId: userId, ct: ct); } public async Task ScrobbleReadingUpdate(int userId, int seriesId, CancellationToken ct = default) @@ -349,6 +362,9 @@ public class ScrobblingService : IScrobblingService _logger.LogDebug("Overriding scrobble event for {Series} from vol {PrevVol} ch {PrevChap} -> vol {UpdatedVol} ch {UpdatedChap}", existingEvt.Series.Name, prevVol, prevChapter, existingEvt.VolumeNumber, existingEvt.ChapterNumber); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventUpdated, seriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = ScrobbleEventType.ChapterRead, ChapterNumber = existingEvt.ChapterNumber, VolumeNumber = existingEvt.VolumeNumber, LibraryType = series.Library.Type }, + AuditStatus.Info, userId: userId, ct: ct); return; } @@ -382,6 +398,9 @@ public class ScrobblingService : IScrobblingService _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(ct); _logger.LogDebug("Added Scrobbling Read update on {SeriesName} - Volume: {VolumeNumber} Chapter: {ChapterNumber} for User: {AppUserId}", series.Name, evt.VolumeNumber, evt.ChapterNumber, userId); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventCreated, seriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = ScrobbleEventType.ChapterRead, ChapterNumber = evt.ChapterNumber, VolumeNumber = evt.VolumeNumber, LibraryType = series.Library.Type }, + AuditStatus.Info, userId: userId, ct: ct); } catch (Exception ex) { @@ -426,6 +445,9 @@ public class ScrobblingService : IScrobblingService _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(ct); _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {AppUserId} ", series.Name, userId); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventCreated, seriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = evt.ScrobbleEventType, LibraryType = series.Library.Type }, + AuditStatus.Info, userId: userId, ct: ct); } #endregion @@ -439,17 +461,29 @@ public class ScrobblingService : IScrobblingService /// private async Task CheckIfCannotScrobble(int userId, int seriesId, Series series) { - if (series.DontMatch) return true; + if (series.DontMatch) + { + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventSkipped, seriesId, + new AuditLogScrobbleParamsDto(), AuditStatus.Info, "series-dont-match", userId); + return true; + } if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) { _logger.LogInformation("Series {SeriesName} is on AppUserId {AppUserId}'s hold list. Not scrobbling", series.Name, userId); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventSkipped, seriesId, + new AuditLogScrobbleParamsDto(), AuditStatus.Info, "scrobble-hold-active", userId); return true; } // TODO: Double check if all callers pass with Library or not var library = series.Library ?? await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); - if (library is not {AllowScrobbling: true} || !ExternalMetadataService.IsPlusEligible(library.Type)) return true; + if (library is not {AllowScrobbling: true} || !ExternalMetadataService.IsPlusEligible(library.Type)) + { + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventSkipped, seriesId, + new AuditLogScrobbleParamsDto(), AuditStatus.Info, "library-scrobbling-disabled", userId); + return true; + } return false; } @@ -750,6 +784,9 @@ public class ScrobblingService : IScrobblingService SeriesId = evt.SeriesId }); await _unitOfWork.CommitAsync(); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventFailed, evt.SeriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = evt.ScrobbleEventType }, + AuditStatus.Failure, "token-expired", evt.AppUserId); return false; } @@ -779,6 +816,9 @@ public class ScrobblingService : IScrobblingService evt.ProcessDateUtc = DateTime.UtcNow; _unitOfWork.ScrobbleRepository.Update(evt); await _unitOfWork.CommitAsync(); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventFailed, evt.SeriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = evt.ScrobbleEventType }, + AuditStatus.Failure, "unknown-series", evt.AppUserId); return false; } @@ -814,7 +854,15 @@ public class ScrobblingService : IScrobblingService /// private async Task ProcessEvents(IEnumerable events, ScrobbleSyncContext ctx, Func> createEvent) { - foreach (var evt in events.Where(CanProcessScrobbleEvent)) + var eventList = events.ToList(); + foreach (var evt in eventList.Where(e => !CanProcessScrobbleEvent(e))) + { + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventSkipped, evt.SeriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = evt.ScrobbleEventType }, + AuditStatus.Info, userId: evt.AppUserId); + } + + foreach (var evt in eventList.Where(CanProcessScrobbleEvent)) { _logger.LogDebug("Processing Scrobble Events: {Count} / {Total}", ctx.ProgressCounter, ctx.TotalCount); ctx.ProgressCounter++; @@ -825,6 +873,9 @@ public class ScrobblingService : IScrobblingService var count = await SetAndCheckRateLimit(ctx.RateLimits, evt.AppUser, ctx.License); if (count == 0) { + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventSkipped, evt.SeriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = evt.ScrobbleEventType }, + AuditStatus.Failure, "rate-limit-hit", userId: evt.AppUserId); if (ctx.Users.Count == 1) break; continue; } @@ -936,12 +987,20 @@ public class ScrobblingService : IScrobblingService _logger.LogDebug("K+ API Scrobble response for series {SeriesName}: Successful {Successful}, ErrorMessage {ErrorMessage}, ExtraInformation: {ExtraInformation}, RateLeft: {RateLeft}", data.SeriesName, response.Successful, response.ErrorMessage, response.ExtraInformation, response.RateLeft); - if (response.Successful || response.ErrorMessage == null) return response.RateLeft; + if (response.Successful || response.ErrorMessage == null) + { + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventSent, evt.SeriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = data.ScrobbleEventType, ChapterNumber = data.ChapterNumber, VolumeNumber = data.VolumeNumber, Rating = data.Rating, LibraryType = evt.Series?.Library?.Type ?? LibraryType.Manga }, + AuditStatus.Success, userId: evt.AppUserId); + return response.RateLeft; + } // Might want to log this under ScrobbleError if (response.ErrorMessage.Contains("Too Many Requests")) { _logger.LogInformation("Hit Too many requests while posting scrobble updates, sleeping to regain requests and retrying"); + await _auditService.LogAsync(KavitaPlusAuditCategory.Scrobble, KavitaPlusEventType.ScrobbleRateLimitHit, + AuditStatus.Failure, AuditSubjectType.Global, error: "rate-limit-hit"); await Task.Delay(TimeSpan.FromMinutes(10)); return await PostScrobbleUpdate(data, license, evt); } @@ -957,6 +1016,9 @@ public class ScrobblingService : IScrobblingService if (response.ErrorMessage.Contains("Access token is invalid")) { evt.SetErrorMessage(AccessTokenErrorMessage); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventFailed, evt.SeriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = evt.ScrobbleEventType, LibraryType = evt.Series?.Library?.Type ?? LibraryType.Manga }, + AuditStatus.Failure, "invalid-token", userId: evt.AppUserId); throw new KavitaException("Access token is invalid"); } @@ -966,6 +1028,9 @@ public class ScrobblingService : IScrobblingService _logger.LogInformation("Kavita+ was unable to match the series: {SeriesName}", evt.Series.Name); await MarkSeriesAsUnknown(data, evt); evt.SetErrorMessage(UnknownSeriesErrorMessage); + await _auditService.LogScrobbleAsync(KavitaPlusEventType.ScrobbleEventFailed, evt.SeriesId, + new AuditLogScrobbleParamsDto { ScrobbleEventType = evt.ScrobbleEventType, LibraryType = evt.Series?.Library?.Type ?? LibraryType.Manga }, + AuditStatus.Failure, "unknown-series", userId: evt.AppUserId); } else if (response.ErrorMessage.StartsWith("Review")) { // Log the Series name and Id in ScrobbleErrors @@ -1193,6 +1258,56 @@ public class ScrobblingService : IScrobblingService await _unitOfWork.CommitAsync(ct); } + public async Task RetryScrobbleAsync(int authUserId, KavitaPlusAuditEntryDto auditEntry, CancellationToken ct = default) + { + if (auditEntry.ScrobbleDetails == null) return false; + if (auditEntry.Status != AuditStatus.Failure) return false; + if (auditEntry.SubjectType != AuditSubjectType.Series && auditEntry.SubjectType != AuditSubjectType.Chapter) return false; + + if (!auditEntry.CanRetry) return false; + + switch (auditEntry.ScrobbleDetails!.ScrobbleEventType) + { + case ScrobbleEventType.ChapterRead: + if (auditEntry.SeriesId == null || auditEntry.UserId == null) return false; + await ScrobbleReadingUpdate(auditEntry.UserId.Value, auditEntry.SeriesId.Value, ct); + break; + case ScrobbleEventType.AddWantToRead: + if (auditEntry.SeriesId == null || auditEntry.UserId == null) return false; + await ScrobbleWantToReadUpdate(auditEntry.UserId.Value, auditEntry.SeriesId.Value, true, ct); + break; + case ScrobbleEventType.RemoveWantToRead: + if (auditEntry.SeriesId == null || auditEntry.UserId == null) return false; + await ScrobbleWantToReadUpdate(auditEntry.UserId.Value, auditEntry.SeriesId.Value, false, ct); + break; + case ScrobbleEventType.ScoreUpdated: + if (auditEntry.SeriesId == null || auditEntry.UserId == null) return false; + await ScrobbleRatingUpdate(auditEntry.UserId.Value, auditEntry.SeriesId.Value, auditEntry.ScrobbleDetails.Rating ?? 0f, ct); + break; + case ScrobbleEventType.Review: + if (auditEntry.SeriesId == null || auditEntry.UserId == null) return false; + string? reviewBody; + if (auditEntry is {SubjectType: AuditSubjectType.Chapter, SubjectId: not null}) + { + var chapterRating = await _unitOfWork.UserRepository.GetUserChapterRatingAsync(auditEntry.UserId.Value, auditEntry.SubjectId.Value, ct); + reviewBody = chapterRating?.Review; + } + else + { + var seriesRating = await _unitOfWork.UserRepository.GetUserRatingAsync(auditEntry.SeriesId.Value, auditEntry.UserId.Value, ct); + reviewBody = seriesRating?.Review; + } + if (string.IsNullOrEmpty(reviewBody)) return false; + await ScrobbleReviewUpdate(auditEntry.UserId.Value, auditEntry.SeriesId.Value, string.Empty, reviewBody, ct); + break; + default: + return false; + } + + await _unitOfWork.KavitaPlusAuditRepository.MarkAsRetriedAsync(auditEntry.Id, ct); + return true; + } + /// /// Removes all events that have been processed that are 7 days old /// diff --git a/Kavita.Services/Plus/SmartCollectionSyncService.cs b/Kavita.Services/Plus/SmartCollectionSyncService.cs index 0c93f0658..212e8bc01 100644 --- a/Kavita.Services/Plus/SmartCollectionSyncService.cs +++ b/Kavita.Services/Plus/SmartCollectionSyncService.cs @@ -13,10 +13,12 @@ using Kavita.API.Services.SignalR; using Kavita.Common; using Kavita.Common.Extensions; using Kavita.Common.Helpers; +using Kavita.Models.DTOs.KavitaPlus.Audit; using Kavita.Models.DTOs.KavitaPlus.ExternalMetadata; using Kavita.Models.DTOs.SignalR; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.Audit; using Kavita.Models.Entities.User; using Kavita.Models.Extensions; using Microsoft.Extensions.Logging; @@ -38,7 +40,8 @@ public class SmartCollectionSyncService( IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, - ILicenseService licenseService) + ILicenseService licenseService, + IKavitaPlusAuditService auditService) : ISmartCollectionSyncService { private const int SyncDelta = -2; @@ -105,8 +108,17 @@ public class SmartCollectionSyncService( { if (!RateLimiter.TryAcquire(string.Empty)) { - // Request not allowed due to rate limit logger.LogDebug("Rate Limit hit for Smart Collection Sync"); + await auditService.LogAsync( + KavitaPlusAuditCategory.Sync, + KavitaPlusEventType.SyncFailed, + AuditStatus.Failure, + AuditSubjectType.Collection, + subjectId: collection.Id, + payload: new AuditLogCollectionFailedParamsDto { CollectionName = collection.Title }, + error: "rate-limit-hit", + userId: collection.AppUserId, + ct: ct); throw new RateLimitException(); } @@ -114,9 +126,29 @@ public class SmartCollectionSyncService( if (info == null) { logger.LogInformation("Unable to find collection through Kavita+"); + await auditService.LogAsync( + KavitaPlusAuditCategory.Sync, + KavitaPlusEventType.SyncFailed, + AuditStatus.Failure, + AuditSubjectType.Collection, + subjectId: collection.Id, + payload: new AuditLogCollectionFailedParamsDto { CollectionName = collection.Title }, + error: "api-unavailable", + userId: collection.AppUserId, + ct: ct); return; } + await auditService.LogAsync( + KavitaPlusAuditCategory.Sync, + KavitaPlusEventType.SyncStarted, + AuditStatus.Info, + AuditSubjectType.Collection, + subjectId: collection.Id, + payload: new AuditLogCollectionStartedParamsDto { CollectionName = info.Title, StackId = collection.SourceUrl, TotalItems = info.TotalItems }, + userId: collection.AppUserId, + ct: ct); + // Check each series in the collection against what's in the target // For everything that's not there, link it up for this user. logger.LogInformation("Starting Sync on {CollectionName} with {SeriesCount} Series", info.Title, info.TotalItems); @@ -168,7 +200,8 @@ public class SmartCollectionSyncService( { // Add the new series to the collection collection.Items.Add(newSeries); - + await auditService.LogCollectionAsync(KavitaPlusEventType.CollectionItemAdded, collection.Id, + new AuditLogCollectionItemParamsDto { CollectionName = collection.Title, SeriesName = newSeries.Name, SeriesId = newSeries.Id }, userId: collection.AppUserId, ct: ct); } else { @@ -214,10 +247,22 @@ public class SmartCollectionSyncService( logger.LogInformation("Finished Syncing Collection {CollectionName} - Missing {MissingCount} series", collection.Title, missingCount); + await auditService.LogCollectionAsync(KavitaPlusEventType.CollectionSynced, collection.Id, + new AuditLogCollectionSyncedParamsDto { CollectionName = collection.Title, StackId = collection.SourceUrl, ItemCount = collection.TotalSourceCount, MissingCount = missingCount }, userId: collection.AppUserId, ct: ct); } catch (Exception ex) { logger.LogError(ex, "There was an error during saving the collection"); + await auditService.LogAsync( + KavitaPlusAuditCategory.Sync, + KavitaPlusEventType.SyncFailed, + AuditStatus.Failure, + AuditSubjectType.Collection, + subjectId: collection.Id, + payload: new AuditLogCollectionFailedParamsDto { CollectionName = collection.Title }, + error: ex.Message, + userId: collection.AppUserId, + ct: ct); } } diff --git a/Kavita.Services/Plus/WantToReadSyncService.cs b/Kavita.Services/Plus/WantToReadSyncService.cs index 92f8db68a..9d9906e98 100644 --- a/Kavita.Services/Plus/WantToReadSyncService.cs +++ b/Kavita.Services/Plus/WantToReadSyncService.cs @@ -10,6 +10,8 @@ using Kavita.API.Repositories; using Kavita.API.Services.Plus; using Kavita.Common; using Kavita.Common.Extensions; +using Kavita.Models.Entities.Enums.Audit; +using Kavita.Models.DTOs.KavitaPlus.Audit; using Kavita.Models.DTOs.KavitaPlus.Metadata; using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.User; @@ -24,7 +26,8 @@ namespace Kavita.Services.Plus; public class WantToReadSyncService( IUnitOfWork unitOfWork, ILogger logger, - ILicenseService licenseService) + ILicenseService licenseService, + IKavitaPlusAuditService auditService) : IWantToReadSyncService { public async Task Sync(CancellationToken ct = default) @@ -41,6 +44,14 @@ public class WantToReadSyncService( try { logger.LogInformation("Syncing want to read for user: {UserName}", user.UserName); + await auditService.LogAsync( + KavitaPlusAuditCategory.Sync, + KavitaPlusEventType.SyncStarted, + AuditStatus.Info, + AuditSubjectType.Global, + userId: user.Id, + payload: new AuditLogWantToReadSyncParamsDto { UserName = user.UserName, HasMal = !string.IsNullOrEmpty(user.MalUserName), HasAniList = !string.IsNullOrEmpty(user.AniListAccessToken) }, + ct: ct); var wantToReadSeries = await ( $"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/want-to-read?malUsername={user.MalUserName}&aniListToken={user.AniListAccessToken}") @@ -69,17 +80,33 @@ public class WantToReadSyncService( // Remove existing Want to Read that are duplicates user.WantToRead = user.WantToRead.DistinctBy(d => d.SeriesId).ToList(); - // TODO: Need to write in the history table the last sync time - - // Save the left over entities + // Save the leftover entities unitOfWork.UserRepository.Update(user); await unitOfWork.CommitAsync(ct); + await auditService.LogAsync( + KavitaPlusAuditCategory.Sync, + KavitaPlusEventType.SyncCompleted, + AuditStatus.Success, + AuditSubjectType.Global, + userId: user.Id, + payload: new AuditLogWantToReadSyncCompletedParamsDto { UserName = user.UserName, SeriesMatched = user.WantToRead.Count, HasMal = !string.IsNullOrEmpty(user.MalUserName), HasAniList = !string.IsNullOrEmpty(user.AniListAccessToken) }, + ct: ct); + // Trigger CleanupService to cleanup any series in WantToRead that don't belong RecurringJob.TriggerJob(TaskScheduler.RemoveFromWantToReadTaskId); } catch (Exception ex) { + await auditService.LogAsync( + KavitaPlusAuditCategory.Sync, + KavitaPlusEventType.SyncFailed, + AuditStatus.Failure, + AuditSubjectType.Global, + userId: user.Id, + payload: new AuditLogWantToReadSyncParamsDto { UserName = user.UserName }, + error: ex.Message, + ct: ct); logger.LogError(ex, "There was an exception when processing want to read series sync for {User}", user.UserName); } } diff --git a/Kavita.Services/TaskScheduler.cs b/Kavita.Services/TaskScheduler.cs index 70175c022..c7c6698a3 100644 --- a/Kavita.Services/TaskScheduler.cs +++ b/Kavita.Services/TaskScheduler.cs @@ -79,6 +79,7 @@ public class TaskScheduler : ITaskScheduler public const string AuthKeyExpirationId = TaskSchedulerConstants.AuthKeyExpirationId; public const string EnsureSideNavId = TaskSchedulerConstants.EnsureSideNavId; public const string FlushUserActiveTaskId = TaskSchedulerConstants.FlushUserActiveTaskId; + public const string PurgeKavitaPlusAuditLogsId = TaskSchedulerConstants.PurgeKavitaPlusAuditLogsId; private const int BaseRetryDelay = 60; // 1-minute @@ -293,6 +294,10 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(KavitaPlusWantToReadSyncId, () => _wantToReadSyncService.Sync(CancellationToken.None), Cron.Weekly(DayOfWeekHelper.Random()), RecurringJobOptions); + + RecurringJob.AddOrUpdate(PurgeKavitaPlusAuditLogsId, + service => service.PurgeOldLogsAsync(CancellationToken.None), + Cron.Daily, RecurringJobOptions); } @@ -308,6 +313,7 @@ public class TaskScheduler : ITaskScheduler RecurringJob.RemoveIfExists(KavitaPlusDataRefreshId); RecurringJob.RemoveIfExists(KavitaPlusStackSyncId); RecurringJob.RemoveIfExists(KavitaPlusWantToReadSyncId); + RecurringJob.RemoveIfExists(PurgeKavitaPlusAuditLogsId); } #region StatsTasks diff --git a/UI/Web/src/app/_models/kavitaplus/audit-status.enum.ts b/UI/Web/src/app/_models/kavitaplus/audit-status.enum.ts new file mode 100644 index 000000000..b6f85382c --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/audit-status.enum.ts @@ -0,0 +1,9 @@ +export enum AuditStatus { + Success = 0, + Failure = 1, + Info = 2, +} + +export const allAuditStatuses: AuditStatus[] = [ + AuditStatus.Success, AuditStatus.Info, AuditStatus.Failure +] diff --git a/UI/Web/src/app/_models/kavitaplus/audit-subject-type.enum.ts b/UI/Web/src/app/_models/kavitaplus/audit-subject-type.enum.ts new file mode 100644 index 000000000..bee79b8d0 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/audit-subject-type.enum.ts @@ -0,0 +1,12 @@ +export enum AuditSubjectType { + Series = 0, + Person = 1, + Collection = 2, + Chapter = 3, + Global = 4, +} + +export const allAuditSubjectTypes = [ + AuditSubjectType.Series, AuditSubjectType.Person, AuditSubjectType.Collection, + AuditSubjectType.Chapter, AuditSubjectType.Global, +] diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-category.enum.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-category.enum.ts new file mode 100644 index 000000000..01d133500 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-category.enum.ts @@ -0,0 +1,6 @@ +export enum KavitaPlusAuditCategory { + Match = 0, + Metadata = 1, + Scrobble = 2, + Sync = 3, +} diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-entry.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-entry.ts new file mode 100644 index 000000000..deb747cee --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-entry.ts @@ -0,0 +1,32 @@ +import {KavitaPlusAuditCategory} from './kavita-plus-audit-category.enum'; +import {KavitaPlusEventType} from './kavita-plus-event-type.enum'; +import {AuditStatus} from './audit-status.enum'; +import {AuditSubjectType} from './audit-subject-type.enum'; +import {KavitaPlusScrobbleDetails} from './kavita-plus-scrobble-details'; +import {MetadataFieldChange} from './metadata-field-change'; +import {KavitaPlusAuditMatchDetails} from './kavita-plus-audit-match-details'; +import {KavitaPlusAuditSyncDetails} from './kavita-plus-audit-sync-details'; +import {KavitaPlusAuditMetadataExtras} from './kavita-plus-audit-metadata-extras'; + + +export interface KavitaPlusAuditEntry { + id: number; + createdUtc: string; + category: KavitaPlusAuditCategory; + eventType: KavitaPlusEventType; + status: AuditStatus; + seriesId: number | null; + libraryId: number | null; + seriesName: string | null; + subjectType: AuditSubjectType; + subjectId: number | null; + userId: number | null; + username: string | null; + diff: MetadataFieldChange[] | null; + errorMessage: string | null; + scrobbleDetails: KavitaPlusScrobbleDetails | null; + matchDetails: KavitaPlusAuditMatchDetails | null; + syncDetails: KavitaPlusAuditSyncDetails | null; + metadataExtras: KavitaPlusAuditMetadataExtras | null; + canRetry: boolean; +} diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-filter.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-filter.ts new file mode 100644 index 000000000..8b196e985 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-filter.ts @@ -0,0 +1,14 @@ +import {KavitaPlusAuditCategory} from './kavita-plus-audit-category.enum'; +import {AuditStatus} from './audit-status.enum'; +import {AuditSubjectType} from './audit-subject-type.enum'; + +export interface KavitaPlusAuditFilter { + category?: KavitaPlusAuditCategory | null; + status?: AuditStatus | null; + subjectType?: AuditSubjectType | null; + userId?: number | null; + seriesId?: number | null; + fromUtc?: string | null; + toUtc?: string | null; + search?: string | null; +} diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-match-details.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-match-details.ts new file mode 100644 index 000000000..87fb7548e --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-match-details.ts @@ -0,0 +1,14 @@ +export interface AuditMatchExternalIds { + aniListId: number; + malId: number; + mangaBakaId: number; + cbrId: number; +} + +export interface KavitaPlusAuditMatchDetails { + matchedName: string | null; + before: AuditMatchExternalIds | null; + after: AuditMatchExternalIds | null; + reason: string | null; + dontMatch: boolean | null; +} diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-metadata-extras.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-metadata-extras.ts new file mode 100644 index 000000000..1c4e50e81 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-metadata-extras.ts @@ -0,0 +1,6 @@ +export interface KavitaPlusAuditMetadataExtras { + coverUrl: string | null; + issueNumber: string | null; + personName: string | null; + aliasAdded: string | null; +} diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-series-info.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-series-info.ts new file mode 100644 index 000000000..ec3ef147c --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-series-info.ts @@ -0,0 +1,18 @@ +import {KavitaPlusAuditEntry} from './kavita-plus-audit-entry'; + +export interface KavitaPlusAuditSeriesInfo { + seriesId: number; + libraryId: number; + seriesName: string; + isMatched: boolean; + mangaBakaId?: number; + aniListId?: number; + malId?: number; + hardcoverId?: number; + metronId?: number; + comicVineId?: string; + cbrId?: number; + nextRefreshUtc: string | null; + lastRefreshedUtc: string | null; + recentEvents: KavitaPlusAuditEntry[]; +} diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-stats.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-stats.ts new file mode 100644 index 000000000..df1ef8c66 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-stats.ts @@ -0,0 +1,12 @@ +export interface KavitaPlusAuditStats { + events24H: number; + failures24H: number; + unresolvedMatchFailures: number; + matchedSeriesCount: number; + totalEligibleSeriesCount: number; + scrobbleQueueCount: number; + /** Matched but needs refresh (automatic) */ + staleMatchesCount: number; + /** Failed to match **/ + blacklistedSeriesCount: number; +} diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-sync-details.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-sync-details.ts new file mode 100644 index 000000000..8127c8815 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-audit-sync-details.ts @@ -0,0 +1,17 @@ +export interface KavitaPlusAuditSyncDetails { + // CollectionSynced + collectionName: string | null; + stackId: string | null; + itemCount: number | null; + missingCount: number | null; + + // CollectionItemAdded + seriesName: string | null; + seriesId: number | null; + + // SyncCompleted (WantToRead) + userName: string | null; + hasMal: boolean | null; + hasAniList: boolean | null; + seriesMatched: number | null; +} diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-event-type.enum.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-event-type.enum.ts new file mode 100644 index 000000000..76f670986 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-event-type.enum.ts @@ -0,0 +1,40 @@ +export enum KavitaPlusEventType { + // Match + SeriesMatched = 0, + SeriesMatchFailed = 1, + SeriesBlacklisted = 2, + SeriesMatchFixed = 3, + SeriesDontMatchSet = 4, + + // Metadata - Series + MetadataFetched = 10, + MetadataUpdated = 11, + CoverUpdated = 13, + + // Metadata - Chapter/Issue + ChapterMetadataUpdated = 20, + ChapterCoverUpdated = 21, + + // Metadata - People + PersonCoverUpdated = 30, + PersonAliasAdded = 31, + + // Metadata - Collections + CollectionSynced = 40, + CollectionItemAdded = 41, + + // Scrobble + ScrobbleEventCreated = 50, + ScrobbleEventUpdated = 51, + ScrobbleEventSent = 52, + ScrobbleEventFailed = 53, + ScrobbleRateLimitHit = 54, + ScrobbleEventSkipped = 55, + ScrobbleHoldRemoved = 56, + ScrobbleHoldAdded = 57, + + // Sync (global background jobs) + SyncStarted = 60, + SyncCompleted = 61, + SyncFailed = 62, +} diff --git a/UI/Web/src/app/_models/kavitaplus/kavita-plus-scrobble-details.ts b/UI/Web/src/app/_models/kavitaplus/kavita-plus-scrobble-details.ts new file mode 100644 index 000000000..afff63b1e --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/kavita-plus-scrobble-details.ts @@ -0,0 +1,12 @@ +import {LibraryType} from "../library/library"; +import {ScrobbleProvider} from "../../_services/scrobbling.service"; +import {ScrobbleEventType} from "../scrobbling/scrobble-event"; + +export interface KavitaPlusScrobbleDetails { + scrobbleEventType: ScrobbleEventType | null; + chapterNumber: number | null; + volumeNumber: number | null; + rating: number | null; + provider: ScrobbleProvider; + libraryType: LibraryType; +} diff --git a/UI/Web/src/app/_models/kavitaplus/metadata-field-change-kind.enum.ts b/UI/Web/src/app/_models/kavitaplus/metadata-field-change-kind.enum.ts new file mode 100644 index 000000000..26cc01678 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/metadata-field-change-kind.enum.ts @@ -0,0 +1,16 @@ +export enum MetadataFieldChangeKind { + Relationships = 1, + Characters = 2, + Artists = 3, + Writers = 4, + Tags = 5, + Genres = 6, + PublicationStatus = 7, + AgeRating = 8, + ExternalIds = 9, + Summary = 10, + Title = 11, + ReleaseDate = 12, + ReleaseYear = 13, + LocalizedName = 14 +} diff --git a/UI/Web/src/app/_models/kavitaplus/metadata-field-change.ts b/UI/Web/src/app/_models/kavitaplus/metadata-field-change.ts new file mode 100644 index 000000000..1a32157d6 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/metadata-field-change.ts @@ -0,0 +1,7 @@ +import {MetadataFieldChangeKind} from "./metadata-field-change-kind.enum"; + +export interface MetadataFieldChange { + field: MetadataFieldChangeKind; + from: unknown; + to: unknown; +} diff --git a/UI/Web/src/app/_models/tabs.ts b/UI/Web/src/app/_models/tabs.ts index c7693a891..1215126f0 100644 --- a/UI/Web/src/app/_models/tabs.ts +++ b/UI/Web/src/app/_models/tabs.ts @@ -50,4 +50,10 @@ export enum Tabs { Uploaded = 'uploaded-tab', Other = 'other-tab', + All = 'all-tab', + Scrobbles = 'scrobbles-tab', + Failed = 'failed-tab', + MyChanges = 'my-changes-tab', + ScrobbleHolds = 'scrobble-holds-tab', + } diff --git a/UI/Web/src/app/_pipes/audit-log-error.pipe.ts b/UI/Web/src/app/_pipes/audit-log-error.pipe.ts new file mode 100644 index 000000000..dee9a8e5e --- /dev/null +++ b/UI/Web/src/app/_pipes/audit-log-error.pipe.ts @@ -0,0 +1,18 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {TranslocoService} from "@jsverse/transloco"; + +@Pipe({ + name: 'auditLogError' +}) +export class AuditLogErrorPipe implements PipeTransform { + private readonly translocoService = inject(TranslocoService); + + transform(key: string): string { + if (key.includes(' ')) return key; + + const fullKey = 'audit-log-messages.' + key; + const translated = this.translocoService.translate(fullKey); + return translated !== fullKey ? translated : key; + } + +} diff --git a/UI/Web/src/app/_pipes/audit-status-title.pipe.ts b/UI/Web/src/app/_pipes/audit-status-title.pipe.ts new file mode 100644 index 000000000..d75ba1667 --- /dev/null +++ b/UI/Web/src/app/_pipes/audit-status-title.pipe.ts @@ -0,0 +1,21 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {AuditStatus} from "../_models/kavitaplus/audit-status.enum"; +import {TranslocoService} from "@jsverse/transloco"; + +@Pipe({ + name: 'auditStatusTitle', +}) +export class AuditStatusTitlePipe implements PipeTransform { + private readonly translocoService = inject(TranslocoService); + + transform(value: AuditStatus): string { + switch (value) { + case AuditStatus.Success: + return this.translocoService.translate('audit-status-title-pipe.success'); + case AuditStatus.Failure: + return this.translocoService.translate('audit-status-title-pipe.failure'); + case AuditStatus.Info: + return this.translocoService.translate('audit-status-title-pipe.info'); + } + } +} diff --git a/UI/Web/src/app/_pipes/audit-subject-title.pipe.ts b/UI/Web/src/app/_pipes/audit-subject-title.pipe.ts new file mode 100644 index 000000000..7844bbacd --- /dev/null +++ b/UI/Web/src/app/_pipes/audit-subject-title.pipe.ts @@ -0,0 +1,25 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {AuditSubjectType} from "../_models/kavitaplus/audit-subject-type.enum"; +import {TranslocoService} from "@jsverse/transloco"; + +@Pipe({ + name: 'auditSubjectTitle', +}) +export class AuditSubjectTitlePipe implements PipeTransform { + private readonly translocoService = inject(TranslocoService); + + transform(value: AuditSubjectType): string { + switch (value) { + case AuditSubjectType.Series: + return this.translocoService.translate('audit-subject-title-pipe.series'); + case AuditSubjectType.Person: + return this.translocoService.translate('audit-subject-title-pipe.person'); + case AuditSubjectType.Collection: + return this.translocoService.translate('audit-subject-title-pipe.collection'); + case AuditSubjectType.Chapter: + return this.translocoService.translate('audit-subject-title-pipe.chapter'); + case AuditSubjectType.Global: + return this.translocoService.translate('audit-subject-title-pipe.global'); + } + } +} diff --git a/UI/Web/src/app/_pipes/kavita-plus-event-description.pipe.ts b/UI/Web/src/app/_pipes/kavita-plus-event-description.pipe.ts new file mode 100644 index 000000000..f1d23036e --- /dev/null +++ b/UI/Web/src/app/_pipes/kavita-plus-event-description.pipe.ts @@ -0,0 +1,57 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {TranslocoService} from '@jsverse/transloco'; +import {KavitaPlusAuditEntry} from '../_models/kavitaplus/kavita-plus-audit-entry'; +import {KavitaPlusEventType} from '../_models/kavitaplus/kavita-plus-event-type.enum'; +import {ScrobbleEventType} from '../_models/scrobbling/scrobble-event'; +import {EntityTitleService} from '../_services/entity-title.service'; + +const PREFIX = 'kavita-plus-event-description-pipe'; + +@Pipe({ + name: 'kavitaPlusEventDescription', + standalone: true, +}) +export class KavitaPlusEventDescriptionPipe implements PipeTransform { + private readonly translocoService = inject(TranslocoService); + private readonly entityTitleService = inject(EntityTitleService); + + transform(entry: KavitaPlusAuditEntry): string { + const sd = entry.scrobbleDetails; + if (sd) { + switch (sd.scrobbleEventType) { + case ScrobbleEventType.ChapterRead: { + const chapter = this.entityTitleService.scrobbleDetailLabel(sd); + return chapter ? this.translocoService.translate(`${PREFIX}.read-progress-sent`, {chapter}) : ''; + } + case ScrobbleEventType.ScoreUpdated: + return this.translocoService.translate(`${PREFIX}.rating-updated`, {rating: sd.rating}); + case ScrobbleEventType.AddWantToRead: + return this.translocoService.translate(`${PREFIX}.add-want-to-read`); + case ScrobbleEventType.RemoveWantToRead: + return this.translocoService.translate(`${PREFIX}.remove-want-to-read`); + case ScrobbleEventType.Review: + return this.translocoService.translate(`${PREFIX}.review-submitted`); + default: + return ''; + } + } + + if ( + (entry.eventType === KavitaPlusEventType.MetadataUpdated || + entry.eventType === KavitaPlusEventType.ChapterMetadataUpdated) && + entry.diff?.length + ) { + return this.translocoService.translate(`${PREFIX}.fields-updated`, {count: entry.diff.length}); + } + + if (entry.eventType === KavitaPlusEventType.ChapterCoverUpdated) { + return this.translocoService.translate(`${PREFIX}.chapter-cover-updated`, {chapter: entry.metadataExtras!.issueNumber}); + } else if (entry.eventType === KavitaPlusEventType.CoverUpdated) { + return this.translocoService.translate(`${PREFIX}.series-cover-updated`); + } else if (entry.eventType === KavitaPlusEventType.SeriesMatchFixed) { + return this.translocoService.translate(`${PREFIX}.series-match-fixed`, {matchName: entry.matchDetails?.matchedName}); + } + + return ''; + } +} diff --git a/UI/Web/src/app/_pipes/kavita-plus-event-type.pipe.ts b/UI/Web/src/app/_pipes/kavita-plus-event-type.pipe.ts new file mode 100644 index 000000000..999ec1967 --- /dev/null +++ b/UI/Web/src/app/_pipes/kavita-plus-event-type.pipe.ts @@ -0,0 +1,68 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {KavitaPlusEventType} from '../_models/kavitaplus/kavita-plus-event-type.enum'; +import {TranslocoService} from '@jsverse/transloco'; + +@Pipe({ + name: 'kavitaPlusEventType', + standalone: true +}) +export class KavitaPlusEventTypePipe implements PipeTransform { + private readonly translocoService = inject(TranslocoService); + + transform(value: KavitaPlusEventType): string { + switch (value) { + case KavitaPlusEventType.SeriesMatched: + return this.translocoService.translate('kavita-plus-event-type-pipe.series-matched'); + case KavitaPlusEventType.SeriesMatchFailed: + return this.translocoService.translate('kavita-plus-event-type-pipe.series-match-failed'); + case KavitaPlusEventType.SeriesBlacklisted: + return this.translocoService.translate('kavita-plus-event-type-pipe.series-blacklisted'); + case KavitaPlusEventType.SeriesMatchFixed: + return this.translocoService.translate('kavita-plus-event-type-pipe.series-match-fixed'); + case KavitaPlusEventType.SeriesDontMatchSet: + return this.translocoService.translate('kavita-plus-event-type-pipe.series-dont-match-set'); + case KavitaPlusEventType.MetadataFetched: + return this.translocoService.translate('kavita-plus-event-type-pipe.metadata-fetched'); + case KavitaPlusEventType.MetadataUpdated: + return this.translocoService.translate('kavita-plus-event-type-pipe.metadata-updated'); + case KavitaPlusEventType.CoverUpdated: + return this.translocoService.translate('kavita-plus-event-type-pipe.cover-updated'); + case KavitaPlusEventType.ChapterMetadataUpdated: + return this.translocoService.translate('kavita-plus-event-type-pipe.chapter-metadata-updated'); + case KavitaPlusEventType.ChapterCoverUpdated: + return this.translocoService.translate('kavita-plus-event-type-pipe.chapter-cover-updated'); + case KavitaPlusEventType.PersonCoverUpdated: + return this.translocoService.translate('kavita-plus-event-type-pipe.person-cover-updated'); + case KavitaPlusEventType.PersonAliasAdded: + return this.translocoService.translate('kavita-plus-event-type-pipe.person-alias-added'); + case KavitaPlusEventType.CollectionSynced: + return this.translocoService.translate('kavita-plus-event-type-pipe.collection-synced'); + case KavitaPlusEventType.CollectionItemAdded: + return this.translocoService.translate('kavita-plus-event-type-pipe.collection-item-added'); + case KavitaPlusEventType.ScrobbleEventCreated: + return this.translocoService.translate('kavita-plus-event-type-pipe.scrobble-created'); + case KavitaPlusEventType.ScrobbleEventUpdated: + return this.translocoService.translate('kavita-plus-event-type-pipe.scrobble-updated'); + case KavitaPlusEventType.ScrobbleEventSent: + return this.translocoService.translate('kavita-plus-event-type-pipe.scrobble-sent'); + case KavitaPlusEventType.ScrobbleEventFailed: + return this.translocoService.translate('kavita-plus-event-type-pipe.scrobble-failed'); + case KavitaPlusEventType.ScrobbleRateLimitHit: + return this.translocoService.translate('kavita-plus-event-type-pipe.scrobble-rate-limit'); + case KavitaPlusEventType.ScrobbleEventSkipped: + return this.translocoService.translate('kavita-plus-event-type-pipe.scrobble-skipped'); + case KavitaPlusEventType.ScrobbleHoldAdded: + return this.translocoService.translate('kavita-plus-event-type-pipe.scrobble-hold-added'); + case KavitaPlusEventType.ScrobbleHoldRemoved: + return this.translocoService.translate('kavita-plus-event-type-pipe.scrobble-hold-removed'); + case KavitaPlusEventType.SyncStarted: + return this.translocoService.translate('kavita-plus-event-type-pipe.sync-started'); + case KavitaPlusEventType.SyncCompleted: + return this.translocoService.translate('kavita-plus-event-type-pipe.sync-completed'); + case KavitaPlusEventType.SyncFailed: + return this.translocoService.translate('kavita-plus-event-type-pipe.sync-failed'); + default: + return String(value); + } + } +} diff --git a/UI/Web/src/app/_pipes/metadata-field-change-kind-title.pipe.ts b/UI/Web/src/app/_pipes/metadata-field-change-kind-title.pipe.ts new file mode 100644 index 000000000..afb48b3f5 --- /dev/null +++ b/UI/Web/src/app/_pipes/metadata-field-change-kind-title.pipe.ts @@ -0,0 +1,50 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {MetadataFieldChangeKind} from "../_models/kavitaplus/metadata-field-change-kind.enum"; +import {TranslocoService} from "@jsverse/transloco"; + +@Pipe({ + name: 'metadataFieldChangeKindTitle', +}) +export class MetadataFieldChangeKindTitlePipe implements PipeTransform { + + private readonly translocoService = inject(TranslocoService); + + transform(value: MetadataFieldChangeKind): string { + const key = this.getKey(value); + return this.translocoService.translate(`metadata-field-change-kind-title-pipe.${key}`); + } + + private getKey(value: MetadataFieldChangeKind): string { + switch (value) { + case MetadataFieldChangeKind.Relationships: + return 'relationships'; + case MetadataFieldChangeKind.Characters: + return 'characters'; + case MetadataFieldChangeKind.Artists: + return 'artists'; + case MetadataFieldChangeKind.Writers: + return 'writers'; + case MetadataFieldChangeKind.Tags: + return 'tags'; + case MetadataFieldChangeKind.Genres: + return 'genres'; + case MetadataFieldChangeKind.PublicationStatus: + return 'publication-status'; + case MetadataFieldChangeKind.AgeRating: + return 'age-rating'; + case MetadataFieldChangeKind.ExternalIds: + return 'external-ids'; + case MetadataFieldChangeKind.Summary: + return 'summary'; + case MetadataFieldChangeKind.Title: + return 'title'; + case MetadataFieldChangeKind.ReleaseDate: + return 'release-date'; + case MetadataFieldChangeKind.ReleaseYear: + return 'release-year'; + case MetadataFieldChangeKind.LocalizedName: + return 'localized-name'; + } + } + +} diff --git a/UI/Web/src/app/_pipes/provider-image.pipe.ts b/UI/Web/src/app/_pipes/provider-image.pipe.ts index 81341818b..cc12edf29 100644 --- a/UI/Web/src/app/_pipes/provider-image.pipe.ts +++ b/UI/Web/src/app/_pipes/provider-image.pipe.ts @@ -9,6 +9,10 @@ export class ProviderImagePipe implements PipeTransform { transform(value: ScrobbleProvider, large: boolean = false): string { switch (value) { + case ScrobbleProvider.Hardcover: + return `assets/images/ExternalServices/hardcover${large ? '-lg' : ''}.png`; + case ScrobbleProvider.MangaBaka: + return `assets/images/ExternalServices/mangabaka${large ? '-lg' : ''}.png`; case ScrobbleProvider.AniList: return `assets/images/ExternalServices/AniList${large ? '-lg' : ''}.png`; case ScrobbleProvider.Mal: diff --git a/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts index d01f9a288..751f0dd61 100644 --- a/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts +++ b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts @@ -13,6 +13,8 @@ export class ScrobbleProviderNamePipe implements PipeTransform { case ScrobbleProvider.Mal: return 'MAL'; case ScrobbleProvider.Kavita: return 'Kavita'; case ScrobbleProvider.Cbr: return 'Comicbook Roundup'; + case ScrobbleProvider.Hardcover: return 'Hardcover'; + case ScrobbleProvider.MangaBaka: return 'MangaBaka'; } } diff --git a/UI/Web/src/app/_services/entity-title.service.ts b/UI/Web/src/app/_services/entity-title.service.ts index ddafbb8fa..46c948d68 100644 --- a/UI/Web/src/app/_services/entity-title.service.ts +++ b/UI/Web/src/app/_services/entity-title.service.ts @@ -4,6 +4,8 @@ import {UtilityService} from '../shared/_services/utility.service'; import {Chapter, LooseLeafOrDefaultNumber} from '../_models/chapter'; import {LibraryType} from '../_models/library/library'; import {Volume} from '../_models/volume'; +import {ScrobbleEventType} from '../_models/scrobbling/scrobble-event'; +import {KavitaPlusScrobbleDetails} from "../_models/kavitaplus/kavita-plus-scrobble-details"; const LooseLeafOrSpecial = LooseLeafOrDefaultNumber + ''; @@ -14,11 +16,39 @@ export class EntityTitleService { /** - * Formats a Chapter name based on the library it's in - * @param libraryType - * @param plural Pluralize word - * @returns + * Returns the formatted label for scrobble details with no leading separator. Callers append " - " themselves. + * Returns an empty string when there is nothing to display. */ + scrobbleDetailLabel(details: KavitaPlusScrobbleDetails): string { + if (details.scrobbleEventType === ScrobbleEventType.ChapterRead) { + const parts: string[] = []; + if (details.volumeNumber != null) { + parts.push(this.translocoService.translate('common.volume-num-shorthand', {num: details.volumeNumber})); + } + if (details.chapterNumber != null) { + parts.push(this.translocoService.translate(this.chapterKey(details.libraryType), {num: details.chapterNumber})); + } + return parts.join(' '); + } + if (details.scrobbleEventType === ScrobbleEventType.ScoreUpdated && details.rating != null) { + return `${details.rating}/5`; + } + return ''; + } + + private chapterKey(libraryType: LibraryType): string { + switch (libraryType) { + case LibraryType.Comic: + case LibraryType.ComicVine: + return 'common.issue-num-shorthand'; + case LibraryType.Book: + case LibraryType.LightNovel: + return 'common.book-num-shorthand'; + default: + return 'common.chapter-num-shorthand'; + } + } + formatChapterName(libraryType: LibraryType, plural: boolean = false) { const pluralKeyPart = plural ? '-plural' : ''; diff --git a/UI/Web/src/app/_services/kavitaplus-audit.service.ts b/UI/Web/src/app/_services/kavitaplus-audit.service.ts new file mode 100644 index 000000000..8925dbb25 --- /dev/null +++ b/UI/Web/src/app/_services/kavitaplus-audit.service.ts @@ -0,0 +1,44 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {environment} from '../../environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {KavitaPlusAuditEntry} from '../_models/kavitaplus/kavita-plus-audit-entry'; +import {KavitaPlusAuditFilter} from '../_models/kavitaplus/kavita-plus-audit-filter'; +import {KavitaPlusAuditStats} from '../_models/kavitaplus/kavita-plus-audit-stats'; +import {KavitaPlusAuditSeriesInfo} from '../_models/kavitaplus/kavita-plus-audit-series-info'; +import {PaginatedResult} from '../_models/pagination'; + +@Injectable({ + providedIn: 'root' +}) +export class KavitaPlusAuditService { + private readonly httpClient = inject(HttpClient); + private readonly utilityService = inject(UtilityService); + private readonly baseUrl = environment.apiUrl + 'kavita-plus-audit/'; + + getSeriesInfo(seriesId: number): Observable { + return this.httpClient.get( + `${this.baseUrl}entries/series/${seriesId}` + ); + } + + getEntries(filter: KavitaPlusAuditFilter, pageNum?: number, itemsPerPage?: number): Observable> { + const params = this.utilityService.addPaginationIfExists(new HttpParams(), pageNum, itemsPerPage); + return this.httpClient.post>( + `${this.baseUrl}entries`, filter, { observe: 'response', params } + ).pipe(map(res => this.utilityService.createPaginatedResult(res))); + } + + getStats(): Observable { + return this.httpClient.get(`${this.baseUrl}stats`); + } + + getMyActivity(filter: KavitaPlusAuditFilter, pageNum?: number, itemsPerPage?: number): Observable> { + const params = this.utilityService.addPaginationIfExists(new HttpParams(), pageNum, itemsPerPage); + return this.httpClient.post>( + `${this.baseUrl}my-activity`, filter, { observe: 'response', params } + ).pipe(map(res => this.utilityService.createPaginatedResult(res))); + } +} diff --git a/UI/Web/src/app/_services/scrobbling.service.ts b/UI/Web/src/app/_services/scrobbling.service.ts index 4038ae95e..b964b923e 100644 --- a/UI/Web/src/app/_services/scrobbling.service.ts +++ b/UI/Web/src/app/_services/scrobbling.service.ts @@ -9,12 +9,27 @@ import {ScrobbleHold} from "../_models/scrobbling/scrobble-hold"; import {PaginatedResult} from "../_models/pagination"; import {ScrobbleEventFilter} from "../_models/scrobbling/scrobble-event-filter"; import {UtilityService} from "../shared/_services/utility.service"; +import {forkJoin} from "rxjs"; +import {KavitaPlusAuditEntry} from "../_models/kavitaplus/kavita-plus-audit-entry"; export enum ScrobbleProvider { Kavita = 0, AniList = 1, Mal = 2, - Cbr = 4 + Cbr = 4, + Hardcover = 5, + MangaBaka = 6, +} + +/** + * TODO: This is a temp wrapper until I merge Amelia's Scrobble Provider Rework branch + */ +export interface UserScrobbleProvider { + userName: string | null; + authenticationToken: string | null; + validUntilUtc: string; + lastSyncedUtc: string; + provider: ScrobbleProvider; } @Injectable({ @@ -56,6 +71,42 @@ export class ScrobblingService { return this.httpClient.get<{username: string, accessToken: string}>(this.baseUrl + 'scrobbling/mal-token'); } + /** + * Returns all providers with user's information filled out + */ + getScrobbleProviders() { + // TODO: Port this to the backend + + const defaultProviders = [ + {provider: ScrobbleProvider.AniList}, + {provider: ScrobbleProvider.Mal}, + {provider: ScrobbleProvider.MangaBaka}, + {provider: ScrobbleProvider.Hardcover}, + ] as UserScrobbleProvider[]; + + return forkJoin({ + aniList: this.getAniListToken(), + mal: this.getMalToken(), + }).pipe(map(res => { + + const data = [...defaultProviders]; + data[0].authenticationToken = res.aniList; + + data[1].authenticationToken = res.mal.accessToken; + data[1].userName = res.mal.username; + + + return data; + })); + } + + /** + * Re-queues the underlying event to process. Only applicable if the event is in failed/rate limit state + * @param event + */ + retryScrobbleEvent(event: KavitaPlusAuditEntry) { + return this.httpClient.post(this.baseUrl + 'scrobbling/retry-scrobble', event, TextResonse).pipe(map(r => r === 'true')); + } hasRunScrobbleGen() { return this.httpClient.get(this.baseUrl + 'scrobbling/has-ran-scrobble-gen ', TextResonse).pipe(map(r => r === 'true')); diff --git a/UI/Web/src/app/_single-module/kavitaplus-timeline/kavitaplus-timeline.component.html b/UI/Web/src/app/_single-module/kavitaplus-timeline/kavitaplus-timeline.component.html new file mode 100644 index 000000000..41eda076f --- /dev/null +++ b/UI/Web/src/app/_single-module/kavitaplus-timeline/kavitaplus-timeline.component.html @@ -0,0 +1,111 @@ + + @if (isLoading()) { +
+
+ Loading... +
+
+ } @else if (entries().length === 0) { +
{{t('no-events')}}
+ } @else { + @for (group of groupedEntries(); track group.key; let isFirst = $first) { +
+ +
+
+ +
+
+ @if (group.label === 'today') { {{t('today')}} } + @else if (group.label === 'yesterday') { {{t('yesterday')}} } + @else { {{group.dateStr | date:'mediumDate'}} } +
+
+ {{t('events-count', {count: group.count})}} +
+ + @for (entry of group.events; track entry.id) { +
+ +
+
+ +
+
+ + @if (entryTemplate(); as tpl) { + + } @else { +
+ + @if (entry.seriesId) { +
+ +
+ } + +
+
+ @if (entry.seriesId != null && entry.libraryId != null) { + + } @else { + {{entry.seriesName ?? t('system')}} + } + @if (entry.scrobbleDetails?.provider != null) { + + + {{entry.scrobbleDetails!.provider | scrobbleProviderName}} + + } +
+
+ + {{entry.eventType | kavitaPlusEventType}} + @let desc = entry | kavitaPlusEventDescription; + @if (desc) { + {{desc}} + } + @if (entry.errorMessage) { + - {{entry.errorMessage | auditLogError }} + } + +
+
+ +
+ @if (showRetry() && entry.canRetry) { + + } + + {{entry.createdUtc | utcToLocalTime | timeAgo}} + +
+ +
+ } +
+ } + +
+ } + + @if (hasMore() || isLoadingMore()) { +
+ +
+ } + } +
diff --git a/UI/Web/src/app/_single-module/kavitaplus-timeline/kavitaplus-timeline.component.scss b/UI/Web/src/app/_single-module/kavitaplus-timeline/kavitaplus-timeline.component.scss new file mode 100644 index 000000000..022908613 --- /dev/null +++ b/UI/Web/src/app/_single-module/kavitaplus-timeline/kavitaplus-timeline.component.scss @@ -0,0 +1,95 @@ + +.timeline-group { + position: relative; + + &::before { + content: ''; + position: absolute; + left: calc(1rem - 1px); + top: 0; + bottom: 0; + width: 2px; + background: var(--hr-color); + z-index: 0; + } +} + +.spine-node { + width: 2rem; + z-index: 1; +} + +.day-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--hr-color); +} + +.spine-icon { + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + font-size: 0.8rem; +} + +.day-label { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--body-text-color); +} + +.count { + font-size: 0.9rem; +} + +.divider { + flex: 1 1 0%; + height: 0.0625rem; + background: var(--hr-color); +} + +.entry { + border-bottom: 0.0625rem solid var(--hr-color); + background-color: var(--elevation-layer1-dark-solid); + border-radius: 0.375rem; + + &:last-child { + border-bottom: none; + } +} + +.cover { + width: 3rem; + height: 4rem; +} + +.series { + font-size: 0.875rem; + color: var(--body-text-color); +} + +.series-link { + background: none; + border: none; + padding: 0; + text-align: left; + + &:hover { + text-decoration: underline; + } +} + +.time { font-size: 0.75rem; } + +.provider-badge { + border: 0.0625rem solid var(--tagbadge-border-color); + border-radius: 999px; + padding: 0.1rem 0.5rem 0.1rem 0.2rem; + font-size: 0.7rem; + color: var(--text-muted-color); + line-height: 1; + white-space: nowrap; +} diff --git a/UI/Web/src/app/_single-module/kavitaplus-timeline/kavitaplus-timeline.component.ts b/UI/Web/src/app/_single-module/kavitaplus-timeline/kavitaplus-timeline.component.ts new file mode 100644 index 000000000..49df9962b --- /dev/null +++ b/UI/Web/src/app/_single-module/kavitaplus-timeline/kavitaplus-timeline.component.ts @@ -0,0 +1,119 @@ +import {ChangeDetectionStrategy, Component, computed, inject, input, output, TemplateRef} from '@angular/core'; +import {DatePipe, NgTemplateOutlet} from '@angular/common'; +import {Router} from '@angular/router'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {KavitaPlusAuditEntry} from '../../_models/kavitaplus/kavita-plus-audit-entry'; +import {KavitaPlusAuditCategory} from '../../_models/kavitaplus/kavita-plus-audit-category.enum'; +import {AuditStatus} from '../../_models/kavitaplus/audit-status.enum'; +import {KavitaPlusEventTypePipe} from '../../_pipes/kavita-plus-event-type.pipe'; +import {KavitaPlusEventDescriptionPipe} from '../../_pipes/kavita-plus-event-description.pipe'; +import {ScrobbleProviderNamePipe} from '../../_pipes/scrobble-provider-name.pipe'; +import {TimeAgoPipe} from '../../_pipes/time-ago.pipe'; +import {UtcToLocalTimePipe} from '../../_pipes/utc-to-local-time.pipe'; +import {ImageService} from '../../_services/image.service'; +import {ImageComponent} from '../../shared/image/image.component'; +import { + ScrobbleProviderImageComponent +} from '../../shared/_components/scrobble-provider-image/scrobble-provider-image.component'; +import {AuditLogErrorPipe} from "../../_pipes/audit-log-error.pipe"; +import { + KavitaPlusAuditEventTypeIconComponent +} from "../../shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component"; + +interface DayGroup { + key: string; + label: 'today' | 'yesterday' | 'date'; + dateStr: string; + count: number; + events: KavitaPlusAuditEntry[]; +} + +function groupByDay(entries: KavitaPlusAuditEntry[]): DayGroup[] { + const now = new Date(); + const todayKey = now.toISOString().slice(0, 10); + const yesterdayKey = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10); + const map = new Map(); + + for (const entry of entries) { + const key = entry.createdUtc.slice(0, 10); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(entry); + } + + return Array.from(map.entries()) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([key, evts]) => ({ + key, + label: key === todayKey ? 'today' : key === yesterdayKey ? 'yesterday' : 'date', + dateStr: key, + count: evts.length, + events: evts, + })); +} + +@Component({ + selector: 'app-kavitaplus-timeline', + templateUrl: './kavitaplus-timeline.component.html', + styleUrls: ['./kavitaplus-timeline.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TranslocoDirective, + KavitaPlusEventTypePipe, + KavitaPlusEventDescriptionPipe, + ScrobbleProviderNamePipe, + TimeAgoPipe, + UtcToLocalTimePipe, + DatePipe, + ImageComponent, + ScrobbleProviderImageComponent, + AuditLogErrorPipe, + KavitaPlusAuditEventTypeIconComponent, + NgTemplateOutlet, + ], +}) +export class KavitaplusTimelineComponent { + protected readonly imageService = inject(ImageService); + private readonly router = inject(Router); + + entries = input.required(); + isLoading = input(false); + showRetry = input(true); + hasMore = input(false); + isLoadingMore = input(false); + entryTemplate = input>(); + + retry = output(); + loadMore = output(); + + groupedEntries = computed(() => groupByDay(this.entries())); + + categoryColor(category: KavitaPlusAuditCategory): string { + switch (category) { + case KavitaPlusAuditCategory.Match: return 'var(--audit-log-match-color)'; + case KavitaPlusAuditCategory.Scrobble: return 'var(--audit-log-scrobble-color)'; + case KavitaPlusAuditCategory.Sync: return 'var(--audit-log-sync-color)'; + default: return 'var(--audit-log-metadata-color)'; + } + } + + categoryBg(category: KavitaPlusAuditCategory): string { + return `color-mix(in srgb, ${this.categoryColor(category)} 12%, transparent)`; + } + + descriptionColor(entry: KavitaPlusAuditEntry): string { + return entry.status === AuditStatus.Failure + ? 'var(--toast-warning-bg-color)' + : ''; + } + + navigateToSeries(entry: KavitaPlusAuditEntry): void { + if (entry.seriesId == null || entry.libraryId == null) return; + this.router.navigate(['library', entry.libraryId, 'series', entry.seriesId]); + } + + retryScrobbleEvent(entry: KavitaPlusAuditEntry) { + this.retry.emit(entry); + } + + +} diff --git a/UI/Web/src/app/admin/kavita-plus/kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component.html b/UI/Web/src/app/admin/kavita-plus/kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component.html new file mode 100644 index 000000000..9612c7268 --- /dev/null +++ b/UI/Web/src/app/admin/kavita-plus/kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component.html @@ -0,0 +1,85 @@ + +
+ +
+ + @if (entry().subjectId !== null && entry().subjectType === AuditSubjectType.Chapter) { +
+ +
+ } @else if (entry().seriesId) { +
+ +
+ } + +
+
+ @if (entry().seriesId != null && entry().libraryId != null) { + + } @else { + {{entry().seriesName ?? t('system')}} + } + + {{entry().status | auditStatusTitle}} + + @if (entry().scrobbleDetails?.provider != null) { + + + {{entry().scrobbleDetails!.provider | scrobbleProviderName}} + + } +
+ +
+ {{entry().eventType | kavitaPlusEventType}} + @let desc = entry() | kavitaPlusEventDescription; + @if (desc) { + {{desc}} + } + @if (entry().errorMessage) { + - {{entry().errorMessage! | auditLogError}} + } +
+
+ +
+ + {{entry().createdUtc | utcToLocalTime | timeAgo}} + + @if (entry().userId) { +
+ + {{entry().username}} +
+ } + @if (supportsDiff(entry())) { + + } +
+ +
+ + @if (supportsDiff(entry())) { +
+
+ @if (entry().diff?.length) { + + } +
+
+ } + +
+
diff --git a/UI/Web/src/app/admin/kavita-plus/kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component.scss b/UI/Web/src/app/admin/kavita-plus/kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component.scss new file mode 100644 index 000000000..ed4f6b443 --- /dev/null +++ b/UI/Web/src/app/admin/kavita-plus/kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component.scss @@ -0,0 +1,52 @@ +:host { + --accordion-body-bg: var(--elevation-layer3); + --diff-from-color: var(--text-muted-color); + --diff-to-bg: rgba(74, 198, 148, 0.12); + --error-bg: rgba(220, 53, 69, 0.12); + display: contents; +} + +.entry { + border-bottom: 0.0625rem solid var(--hr-color); + background-color: var(--elevation-layer1-dark-solid); + border-radius: 0.375rem; + + &:last-child { + border-bottom: none; + } +} + +.cover { + width: 3rem; + height: 4rem; +} + +.series-link { + font-size: 0.875rem; + color: var(--body-text-color); + + &:hover { + text-decoration: underline; + } +} + +.provider-badge { + border: 0.0625rem solid var(--tagbadge-border-color); + border-radius: 999px; + padding: 0.1rem 0.5rem 0.1rem 0.2rem; + font-size: 0.7rem; + color: var(--text-muted-color); + line-height: 1; + white-space: nowrap; +} + +.time { + font-size: 0.75rem; +} + +.diff-body { + background: var(--accordion-body-bg); + border-radius: 0.25rem; + padding: 0.75rem; +} + diff --git a/UI/Web/src/app/admin/kavita-plus/kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component.ts b/UI/Web/src/app/admin/kavita-plus/kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component.ts new file mode 100644 index 000000000..2166d3f6d --- /dev/null +++ b/UI/Web/src/app/admin/kavita-plus/kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component.ts @@ -0,0 +1,79 @@ +import {ChangeDetectionStrategy, Component, computed, inject, input, signal} from '@angular/core'; +import {NgbCollapse} from '@ng-bootstrap/ng-bootstrap'; +import {NgClass} from '@angular/common'; +import {Router} from '@angular/router'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {KavitaPlusAuditEntry} from '../../../_models/kavitaplus/kavita-plus-audit-entry'; +import {KavitaPlusAuditCategory} from '../../../_models/kavitaplus/kavita-plus-audit-category.enum'; +import {KavitaPlusEventType} from '../../../_models/kavitaplus/kavita-plus-event-type.enum'; +import {AuditStatus} from '../../../_models/kavitaplus/audit-status.enum'; +import {ImageService} from '../../../_services/image.service'; +import {ImageComponent} from '../../../shared/image/image.component'; +import {ProfileIconComponent} from '../../../_single-module/profile-icon/profile-icon.component'; +import { + ScrobbleProviderImageComponent +} from '../../../shared/_components/scrobble-provider-image/scrobble-provider-image.component'; +import {ScrobbleProviderNamePipe} from '../../../_pipes/scrobble-provider-name.pipe'; +import {KavitaPlusEventTypePipe} from '../../../_pipes/kavita-plus-event-type.pipe'; +import {KavitaPlusEventDescriptionPipe} from '../../../_pipes/kavita-plus-event-description.pipe'; +import {AuditLogErrorPipe} from '../../../_pipes/audit-log-error.pipe'; +import {TimeAgoPipe} from '../../../_pipes/time-ago.pipe'; +import {UtcToLocalTimePipe} from '../../../_pipes/utc-to-local-time.pipe'; +import {AuditStatusTitlePipe} from "../../../_pipes/audit-status-title.pipe"; +import {KavitaplusDiffComponent} from "../kavitaplus-diff/kavitaplus-diff.component"; +import {AuditSubjectType} from "../../../_models/kavitaplus/audit-subject-type.enum"; + +@Component({ + selector: 'app-kavitaplus-audit-accordion-item', + imports: [ + NgbCollapse, + NgClass, + TranslocoDirective, + ImageComponent, + ProfileIconComponent, + ScrobbleProviderImageComponent, + ScrobbleProviderNamePipe, + KavitaPlusEventTypePipe, + KavitaPlusEventDescriptionPipe, + AuditLogErrorPipe, + TimeAgoPipe, + UtcToLocalTimePipe, + AuditStatusTitlePipe, + KavitaplusDiffComponent, + ], + templateUrl: './kavitaplus-audit-accordion-item.component.html', + styleUrl: './kavitaplus-audit-accordion-item.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class KavitaplusAuditAccordionItemComponent { + protected readonly imageService = inject(ImageService); + private readonly router = inject(Router); + + entry = input.required(); + + collapsed = signal(true); + + statusBadgeClass = computed(() => { + switch (this.entry().status) { + case AuditStatus.Success: + return 'bg-success'; + case AuditStatus.Failure: + return 'bg-danger'; + default: + return 'bg-secondary'; + } + }); + + supportsDiff(entry: KavitaPlusAuditEntry) { + return [KavitaPlusEventType.MetadataUpdated, KavitaPlusEventType.ChapterMetadataUpdated].includes(entry.eventType); + } + + navigateToSeries() { + const e = this.entry(); + if (e.seriesId == null || e.libraryId == null) return; + this.router.navigate(['library', e.libraryId, 'series', e.seriesId]); + } + + protected readonly KavitaPlusAuditCategory = KavitaPlusAuditCategory; + protected readonly AuditSubjectType = AuditSubjectType; +} diff --git a/UI/Web/src/app/admin/kavita-plus/kavitaplus-diff/kavitaplus-diff.component.html b/UI/Web/src/app/admin/kavita-plus/kavitaplus-diff/kavitaplus-diff.component.html new file mode 100644 index 000000000..eeffdae8c --- /dev/null +++ b/UI/Web/src/app/admin/kavita-plus/kavitaplus-diff/kavitaplus-diff.component.html @@ -0,0 +1,62 @@ + + @if (rows().length) { + + + + + + + + + + @for (row of rows(); track row.field) { + + + @if (row.subRows.length === 0) { + + + } @else { + + } + + @for (sub of row.subRows; track sub.key) { + + + + + + } + } + +
{{t('field-label')}}{{t('before-label')}}{{t('after-label')}}
{{row.field | metadataFieldChangeKindTitle}} + + + + {{t('object-expanded')}}
{{sub.key}} + + + +
+ } + + + @switch (c.kind) { + @case ('null') { + {{null | defaultValue}} + } + @case ('primitive') { + {{c.text}} + } + @case ('array') { +
+ @for (item of c.items; track item) { + {{item}} + } +
+ } + @case ('object') { + {{t('object-expanded')}} + } + } +
+
diff --git a/UI/Web/src/app/admin/kavita-plus/kavitaplus-diff/kavitaplus-diff.component.scss b/UI/Web/src/app/admin/kavita-plus/kavitaplus-diff/kavitaplus-diff.component.scss new file mode 100644 index 000000000..212d2e1ac --- /dev/null +++ b/UI/Web/src/app/admin/kavita-plus/kavitaplus-diff/kavitaplus-diff.component.scss @@ -0,0 +1,48 @@ +:host { + display: block; +} + +.diff-table { + border-collapse: collapse; + + th { + text-align: left; + padding: 0.25rem 0.5rem; + color: var(--text-muted-color); + font-weight: 600; + border-bottom: 0.0625rem solid var(--hr-color); + } + + td { + padding: 0.25rem 0.5rem; + vertical-align: top; + word-break: break-word; + } +} + +.top-row td { + border-top: 0.0625rem solid var(--hr-color); +} + +.sub-row td { + background: var(--elevation-layer3); + font-size: 0.8rem; +} + +.diff-from { + color: var(--diff-from-color, var(--text-muted-color)); + text-decoration: line-through; +} + +.diff-to { + background: var(--diff-to-bg, rgba(74, 198, 148, 0.12)); +} + +.array-items .badge { + font-size: 0.7rem; + font-weight: 400; +} + +.null-value { + font-size: 0.85em; +} diff --git a/UI/Web/src/app/admin/kavita-plus/kavitaplus-diff/kavitaplus-diff.component.ts b/UI/Web/src/app/admin/kavita-plus/kavitaplus-diff/kavitaplus-diff.component.ts new file mode 100644 index 000000000..6eded470e --- /dev/null +++ b/UI/Web/src/app/admin/kavita-plus/kavitaplus-diff/kavitaplus-diff.component.ts @@ -0,0 +1,91 @@ +import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; +import {MetadataFieldChange} from "../../../_models/kavitaplus/metadata-field-change"; +import {MetadataFieldChangeKindTitlePipe} from "../../../_pipes/metadata-field-change-kind-title.pipe"; +import {MetadataFieldChangeKind} from "../../../_models/kavitaplus/metadata-field-change-kind.enum"; + +type ValueKind = 'null' | 'primitive' | 'array' | 'object'; + +interface DiffCell { + kind: ValueKind; + text: string | null; + items: string[] | null; +} + +interface SubRow { + key: string; + from: DiffCell; + to: DiffCell; +} + +interface ProcessedRow { + field: MetadataFieldChangeKind; + from: DiffCell; + to: DiffCell; + subRows: SubRow[]; +} + +function stringify(value: unknown): string { + if (value === null || value === undefined) return ''; + return String(value); +} + +function processCell(value: unknown, depth: number): DiffCell { + if (value === null || value === undefined) { + return {kind: 'null', text: null, items: null}; + } + if (typeof value !== 'object') { + return {kind: 'primitive', text: String(value), items: null}; + } + if (Array.isArray(value)) { + return {kind: 'array', text: null, items: (value as unknown[]).map(stringify)}; + } + // object + if (depth >= 2) { + return {kind: 'primitive', text: JSON.stringify(value), items: null}; + } + // depth < 2: caller handles sub-row expansion, return object marker + return {kind: 'object', text: null, items: null}; +} + +function expandSubRows(from: unknown, to: unknown): SubRow[] { + const fromObj = (from !== null && typeof from === 'object' && !Array.isArray(from)) + ? from as Record : {}; + const toObj = (to !== null && typeof to === 'object' && !Array.isArray(to)) + ? to as Record : {}; + + const keys = new Set([...Object.keys(fromObj), ...Object.keys(toObj)]); + return Array.from(keys).map(key => ({ + key, + from: processCell(fromObj[key] ?? null, 2), + to: processCell(toObj[key] ?? null, 2), + })); +} + +@Component({ + selector: 'app-kavitaplus-diff', + imports: [TranslocoDirective, NgTemplateOutlet, DefaultValuePipe, MetadataFieldChangeKindTitlePipe], + templateUrl: './kavitaplus-diff.component.html', + styleUrl: './kavitaplus-diff.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class KavitaplusDiffComponent { + diff = input.required(); + + rows = computed(() => + this.diff().map(change => { + const from = processCell(change.from, 1); + const to = processCell(change.to, 1); + const isObjectExpansion = from.kind === 'object' || to.kind === 'object'; + + return { + field: change.field, + from, + to, + subRows: isObjectExpansion ? expandSubRows(change.from, change.to) : [], + }; + }) + ); +} diff --git a/UI/Web/src/app/admin/license/license.component.html b/UI/Web/src/app/admin/kavita-plus/license/license.component.html similarity index 100% rename from UI/Web/src/app/admin/license/license.component.html rename to UI/Web/src/app/admin/kavita-plus/license/license.component.html diff --git a/UI/Web/src/app/admin/license/license.component.scss b/UI/Web/src/app/admin/kavita-plus/license/license.component.scss similarity index 100% rename from UI/Web/src/app/admin/license/license.component.scss rename to UI/Web/src/app/admin/kavita-plus/license/license.component.scss diff --git a/UI/Web/src/app/admin/license/license.component.ts b/UI/Web/src/app/admin/kavita-plus/license/license.component.ts similarity index 87% rename from UI/Web/src/app/admin/license/license.component.ts rename to UI/Web/src/app/admin/kavita-plus/license/license.component.ts index 185bb06ce..7067d8540 100644 --- a/UI/Web/src/app/admin/license/license.component.ts +++ b/UI/Web/src/app/admin/kavita-plus/license/license.component.ts @@ -1,22 +1,22 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, inject, OnInit, signal} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; -import {AccountService} from "../../_services/account.service"; -import {ToastrService} from "ngx-toastr"; -import {ConfirmService} from "../../shared/confirm.service"; -import {LoadingComponent} from '../../shared/loading/loading.component'; -import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; -import {environment} from "../../../environments/environment"; +import {LicenseInfo} from "../../../_models/kavitaplus/license-info"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {LoadingComponent} from "../../../shared/loading/loading.component"; import {translate, TranslocoDirective} from "@jsverse/transloco"; -import {WikiLink} from "../../_models/wiki"; -import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component"; +import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; +import {UtcToLocalTimePipe} from "../../../_pipes/utc-to-local-time.pipe"; +import {SettingButtonComponent} from "../../../settings/_components/setting-button/setting-button.component"; import {DecimalPipe} from "@angular/common"; -import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; -import {switchMap} from "rxjs"; -import {LicenseInfo} from "../../_models/kavitaplus/license-info"; -import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; -import {filter, tap} from "rxjs/operators"; -import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; -import {LicenseService} from "../../_services/license.service"; +import {ToastrService} from "ngx-toastr"; +import {ConfirmService} from "../../../shared/confirm.service"; +import {AccountService} from "../../../_services/account.service"; +import {LicenseService} from "../../../_services/license.service"; +import {environment} from "../../../../environments/environment"; +import {WikiLink} from "../../../_models/wiki"; +import {switchMap} from "rxjs/operators"; +import {filter, tap} from "rxjs"; @Component({ selector: 'app-license', diff --git a/UI/Web/src/app/admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component.html b/UI/Web/src/app/admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component.html new file mode 100644 index 000000000..4401ec820 --- /dev/null +++ b/UI/Web/src/app/admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component.html @@ -0,0 +1,136 @@ + + + +
+ +
+
+ + {{t('stats-events-label')}} +
+
{{stats()?.events24H | defaultValue}}
+
+ +
+
+ + {{t('stats-failures-label')}} +
+
{{stats()?.failures24H | defaultValue}}
+ @let unresolved = stats()?.unresolvedMatchFailures ?? 0; + @if (unresolved > 0) { +
{{t('stats-unresolved', {count: unresolved})}}
+ } +
+ +
+
+ + {{t('stats-match-label')}} +
+ @if (!stats() || stats()!.totalEligibleSeriesCount === 0) { +
{{null | defaultValue}}
+ } @else { +
{{stats()!.matchedSeriesCount}} / {{stats()!.totalEligibleSeriesCount}}
+
+ {{t('stats-coverage-subtitle', {percent: matchedPercent()})}} +
+ +
+ @if (stats()!.staleMatchesCount > 0) { + {{t('stats-stale-subtitle', {count: stats()!.staleMatchesCount})}} + } + @if (stats()!.blacklistedSeriesCount > 0) { + + {{t('stats-blacklisted-subtitle', {count: stats()!.blacklistedSeriesCount})}} + } +
+ + } +
+ +
+
+ + {{t('stats-queue-label')}} +
+
{{stats()?.scrobbleQueueCount | defaultValue}}
+
+
+ + +
+ +
+ + + + + +
+ +
+ + + + + + + + + + + +
+ + + +
+ + + + + + + + +
diff --git a/UI/Web/src/app/admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component.scss b/UI/Web/src/app/admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component.scss new file mode 100644 index 000000000..4a79966b0 --- /dev/null +++ b/UI/Web/src/app/admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component.scss @@ -0,0 +1,44 @@ +:host { + --filter-chip-active-bg: var(--btn-primary-bg-color); + --filter-chip-active-color: var(--btn-primary-text-color); + display: block; +} + +.stat-header { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted-color); + margin-bottom: 0.375rem; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + line-height: 1.2; +} + +.stat-subtitle { + font-size: 0.75rem; + color: var(--text-muted-color); + margin-top: 0.125rem; +} + +.btn.active-filter { + background: var(--filter-chip-active-bg); + color: var(--filter-chip-active-color); + border-color: transparent; +} + +.search-input { + max-width: 12.5rem; + + @media (max-width: 991.98px) { + max-width: none; + width: 100%; + } +} diff --git a/UI/Web/src/app/admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component.ts b/UI/Web/src/app/admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component.ts new file mode 100644 index 000000000..0cdd23c6e --- /dev/null +++ b/UI/Web/src/app/admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component.ts @@ -0,0 +1,167 @@ +import {ChangeDetectionStrategy, Component, computed, DestroyRef, inject, OnInit, signal} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {KavitaplusTimelineComponent} from "../../../_single-module/kavitaplus-timeline/kavitaplus-timeline.component"; +import { + KavitaplusAuditAccordionItemComponent +} from "../kavitaplus-audit-accordion-item/kavitaplus-audit-accordion-item.component"; +import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; +import {AuditStatusTitlePipe} from "../../../_pipes/audit-status-title.pipe"; +import {AuditSubjectTitlePipe} from "../../../_pipes/audit-subject-title.pipe"; +import {KavitaPlusAuditService} from "../../../_services/kavitaplus-audit.service"; +import {MemberService} from "../../../_services/member.service"; +import {KavitaPlusAuditStats} from "../../../_models/kavitaplus/kavita-plus-audit-stats"; +import {KavitaPlusAuditEntry} from "../../../_models/kavitaplus/kavita-plus-audit-entry"; +import {allAuditStatuses, AuditStatus} from "../../../_models/kavitaplus/audit-status.enum"; +import {allAuditSubjectTypes, AuditSubjectType} from "../../../_models/kavitaplus/audit-subject-type.enum"; +import {Member} from "../../../_models/auth/member"; +import {Pagination} from "../../../_models/pagination"; +import {KavitaPlusAuditCategory} from "../../../_models/kavitaplus/kavita-plus-audit-category.enum"; +import {KavitaPlusAuditFilter} from "../../../_models/kavitaplus/kavita-plus-audit-filter"; + +@Component({ + selector: 'app-manage-kavitaplus-activity', + imports: [ + TranslocoDirective, + KavitaplusTimelineComponent, + KavitaplusAuditAccordionItemComponent, + DefaultValuePipe, + AuditStatusTitlePipe, + AuditSubjectTitlePipe, + ], + templateUrl: './manage-kavitaplus-activity.component.html', + styleUrl: './manage-kavitaplus-activity.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManageKavitaplusActivityComponent implements OnInit { + private readonly auditService = inject(KavitaPlusAuditService); + private readonly destroyRef = inject(DestroyRef); + private readonly memberService = inject(MemberService); + private readonly PAGE_SIZE = 50; + + stats = signal(null); + entries = signal([]); + isLoading = signal(false); + isLoadingMore = signal(false); + categoryFilter = signal(null); + statusFilter = signal(null); + searchQuery = signal(''); + + subjectFilter = signal(null); + userFilter = signal(null); + timeFrameFilter = signal<'all' | '24h' | '7d' | '30d'>('7d'); + + members = signal([]); + currentPage = signal(0); + pagination = signal(null); + + matchedPercent = computed(() => { + const s = this.stats(); + if (!s || s.totalEligibleSeriesCount === 0) return 0; + return Math.round(s.matchedSeriesCount / s.totalEligibleSeriesCount * 100); + }); + + hasMore = computed(() => { + const p = this.pagination(); + return p != null && p.currentPage < p.totalPages - 1; + }); + + ngOnInit() { + this.loadStats(); + this.loadEntries(); + + this.memberService.getMembers(false).subscribe(members => { + this.members.set(members); + }) + } + + private loadStats() { + this.auditService.getStats().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: s => this.stats.set(s), + }); + } + + private timeFrameToFromUtc(): string | null { + const tf = this.timeFrameFilter(); + if (tf === 'all') return null; + const msMap = { '24h': 86_400_000, '7d': 604_800_000, '30d': 2_592_000_000 }; + return new Date(Date.now() - msMap[tf]).toISOString(); + } + + private loadEntries(reset = true) { + if (reset) { + this.currentPage.set(0); + this.entries.set([]); + this.isLoading.set(true); + } else { + this.isLoadingMore.set(true); + } + + const filter: KavitaPlusAuditFilter = { + category: this.categoryFilter(), + status: this.statusFilter(), + search: this.searchQuery() || null, + subjectType: this.subjectFilter() || null, + fromUtc: this.timeFrameToFromUtc(), + userId: this.userFilter() || null, + }; + + this.auditService.getEntries(filter, this.currentPage(), this.PAGE_SIZE).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: result => { + this.pagination.set(result.pagination); + if (reset) { + this.entries.set(result.result ?? []); + this.isLoading.set(false); + } else { + this.entries.update(prev => [...prev, ...(result.result ?? [])]); + this.isLoadingMore.set(false); + } + }, + error: () => { + this.isLoading.set(false); + this.isLoadingMore.set(false); + }, + }); + } + + loadMore() { + this.currentPage.update(p => p + 1); + this.loadEntries(false); + } + + setCategoryFilter(cat: KavitaPlusAuditCategory | null) { + this.categoryFilter.set(cat); + this.loadEntries(); + } + + onStatusFilterChange(value: AuditStatus | null) { + this.statusFilter.set(value === null ? null : Number(value) as AuditStatus); + this.loadEntries(); + } + + onSubjectFilterChange(value: AuditSubjectType | null) { + this.subjectFilter.set(value === null ? null : Number(value) as AuditSubjectType); + this.loadEntries(); + } + + onTimeFilterChange(value: string) { + this.timeFrameFilter.set(value as 'all' | '24h' | '7d' | '30d'); + this.loadEntries(); + } + + onUserFilterChange(value: number) { + this.userFilter.set(value < 0 ? null : Number(value)); + this.loadEntries(); + } + + onSearchChange(value: string) { + this.searchQuery.set(value); + this.loadEntries(); + } + + + protected readonly KavitaPlusAuditCategory = KavitaPlusAuditCategory; + protected readonly allAuditStatuses = allAuditStatuses; + protected readonly allAuditSubjectTypes = allAuditSubjectTypes; +} + diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html b/UI/Web/src/app/admin/kavita-plus/manage-matched-metadata/manage-matched-metadata.component.html similarity index 100% rename from UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html rename to UI/Web/src/app/admin/kavita-plus/manage-matched-metadata/manage-matched-metadata.component.html diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.scss b/UI/Web/src/app/admin/kavita-plus/manage-matched-metadata/manage-matched-metadata.component.scss similarity index 100% rename from UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.scss rename to UI/Web/src/app/admin/kavita-plus/manage-matched-metadata/manage-matched-metadata.component.scss diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts b/UI/Web/src/app/admin/kavita-plus/manage-matched-metadata/manage-matched-metadata.component.ts similarity index 73% rename from UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts rename to UI/Web/src/app/admin/kavita-plus/manage-matched-metadata/manage-matched-metadata.component.ts index e47c8e938..b85d345b7 100644 --- a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts +++ b/UI/Web/src/app/admin/kavita-plus/manage-matched-metadata/manage-matched-metadata.component.ts @@ -1,31 +1,32 @@ import {ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal} from '@angular/core'; import {translate, TranslocoDirective} from "@jsverse/transloco"; -import {ImageComponent} from "../../shared/image/image.component"; -import {ImageService} from "../../_services/image.service"; -import {Series} from "../../_models/series"; -import {ActionService} from "../../_services/action.service"; -import {ManageService} from "../../_services/manage.service"; -import {ManageMatchSeries} from "../../_models/kavitaplus/manage-match-series"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {LibraryTypePipe} from "../../../_pipes/library-type.pipe"; +import {ImageComponent} from "../../../shared/image/image.component"; import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; -import {ManageMatchFilter} from "../../_models/kavitaplus/manage-match-filter"; -import {allMatchStates, MatchStateOption} from "../../_models/kavitaplus/match-state-option"; -import {MatchStateOptionPipe} from "../../_pipes/match-state.pipe"; -import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; -import {debounceTime, distinctUntilChanged, switchMap, tap} from "rxjs"; -import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {MatchStateOptionPipe} from "../../../_pipes/match-state.pipe"; +import {UtcToLocalTimePipe} from "../../../_pipes/utc-to-local-time.pipe"; +import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; import {NgxDatatableModule} from "@siemens/ngx-datatable"; -import {LibraryNamePipe} from "../../_pipes/library-name.pipe"; +import {LibraryNamePipe} from "../../../_pipes/library-name.pipe"; import {APP_BASE_HREF, AsyncPipe} from "@angular/common"; -import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; -import {ScanSeriesEvent} from "../../_models/events/scan-series-event"; -import {LibraryTypePipe} from "../../_pipes/library-type.pipe"; -import {allKavitaPlusMetadataApplicableTypes} from "../../_models/library/library"; -import {ExternalMatchRateLimitErrorEvent} from "../../_models/events/external-match-rate-limit-error-event"; +import {ResponsiveTableComponent} from "../../../shared/_components/responsive-table/responsive-table.component"; +import {allKavitaPlusMetadataApplicableTypes} from "../../../_models/library/library"; +import {ActionService} from "../../../_services/action.service"; +import {ManageService} from "../../../_services/manage.service"; +import {EVENTS, MessageHubService} from "../../../_services/message-hub.service"; +import {ManageMatchSeries} from "../../../_models/kavitaplus/manage-match-series"; +import {Series} from "../../../_models/series"; +import {ManageMatchFilter} from "../../../_models/kavitaplus/manage-match-filter"; +import {debounceTime, distinctUntilChanged, tap} from "rxjs"; +import {switchMap} from "rxjs/operators"; +import {ExternalMatchRateLimitErrorEvent} from "../../../_models/events/external-match-rate-limit-error-event"; import {ToastrService} from "ngx-toastr"; -import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; -import {Pagination} from "../../_models/pagination"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {ImageService} from "../../../_services/image.service"; +import {Pagination} from "../../../_models/pagination"; +import {ScanSeriesEvent} from "../../../_models/events/scan-series-event"; +import {allMatchStates, MatchStateOption} from "../../../_models/kavitaplus/match-state-option"; @Component({ selector: 'app-manage-matched-metadata', @@ -48,7 +49,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; changeDetection: ChangeDetectionStrategy.OnPush }) export class ManageMatchedMetadataComponent implements OnInit { - protected readonly MatchStateOption = MatchStateOption; + protected readonly allMatchStates = allMatchStates.filter(m => m !== MatchStateOption.Matched); // Matched will have too many protected readonly allLibraryTypes = allKavitaPlusMetadataApplicableTypes; @@ -138,4 +139,6 @@ export class ManageMatchedMetadataComponent implements OnInit { this.data.update(x => x.filter(s => s.series.id !== series.id)); }); } + + protected readonly MatchStateOption = MatchStateOption; } diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html b/UI/Web/src/app/admin/kavita-plus/manage-scrobble-errors/manage-scrobble-errors.component.html similarity index 100% rename from UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html rename to UI/Web/src/app/admin/kavita-plus/manage-scrobble-errors/manage-scrobble-errors.component.html diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.scss b/UI/Web/src/app/admin/kavita-plus/manage-scrobble-errors/manage-scrobble-errors.component.scss similarity index 100% rename from UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.scss rename to UI/Web/src/app/admin/kavita-plus/manage-scrobble-errors/manage-scrobble-errors.component.scss diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts b/UI/Web/src/app/admin/kavita-plus/manage-scrobble-errors/manage-scrobble-errors.component.ts similarity index 80% rename from UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts rename to UI/Web/src/app/admin/kavita-plus/manage-scrobble-errors/manage-scrobble-errors.component.ts index b2aeb6ea7..f28edd428 100644 --- a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts +++ b/UI/Web/src/app/admin/kavita-plus/manage-scrobble-errors/manage-scrobble-errors.component.ts @@ -5,25 +5,24 @@ import { DestroyRef, inject, OnInit, - signal, - output + output, + signal } from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; -import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {filter, shareReplay} from "rxjs"; -import {ScrobblingService} from "../../_services/scrobbling.service"; -import {ScrobbleError} from "../../_models/scrobbling/scrobble-error"; - -import {SeriesService} from "../../_services/series.service"; -import {FilterPipe} from "../../_pipes/filter.pipe"; import {TranslocoModule} from "@jsverse/transloco"; -import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; -import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; -import {ActionService} from "../../_services/action.service"; -import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; +import {ScrobblingService} from "../../../_services/scrobbling.service"; +import {FilterPipe} from "../../../_pipes/filter.pipe"; +import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; +import {UtcToLocalTimePipe} from "../../../_pipes/utc-to-local-time.pipe"; +import {ResponsiveTableComponent} from "../../../shared/_components/responsive-table/responsive-table.component"; +import {EVENTS, MessageHubService} from "../../../_services/message-hub.service"; +import {SeriesService} from "../../../_services/series.service"; +import {ActionService} from "../../../_services/action.service"; +import {ScrobbleError} from "../../../_models/scrobbling/scrobble-error"; @Component({ selector: 'app-manage-scrobble-errors', diff --git a/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.html b/UI/Web/src/app/admin/kavita-plus/manage-scrobling/manage-scrobbling.component.html similarity index 59% rename from UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.html rename to UI/Web/src/app/admin/kavita-plus/manage-scrobling/manage-scrobbling.component.html index 70674b634..24ec9d8e6 100644 --- a/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.html +++ b/UI/Web/src/app/admin/kavita-plus/manage-scrobling/manage-scrobbling.component.html @@ -1,3 +1,6 @@ + diff --git a/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.scss b/UI/Web/src/app/admin/kavita-plus/manage-scrobling/manage-scrobbling.component.scss similarity index 100% rename from UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.scss rename to UI/Web/src/app/admin/kavita-plus/manage-scrobling/manage-scrobbling.component.scss diff --git a/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.ts b/UI/Web/src/app/admin/kavita-plus/manage-scrobling/manage-scrobbling.component.ts similarity index 56% rename from UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.ts rename to UI/Web/src/app/admin/kavita-plus/manage-scrobling/manage-scrobbling.component.ts index 2650cc7cf..c409e0ba4 100644 --- a/UI/Web/src/app/admin/manage-scrobling/manage-scrobbling.component.ts +++ b/UI/Web/src/app/admin/kavita-plus/manage-scrobling/manage-scrobbling.component.ts @@ -1,19 +1,18 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; import {ManageScrobbleErrorsComponent} from "../manage-scrobble-errors/manage-scrobble-errors.component"; -import {AsyncPipe} from "@angular/common"; -import {AccountService} from "../../_services/account.service"; -import {ScrobblingHoldsComponent} from "../../user-settings/user-holds/scrobbling-holds.component"; +import {RouterLink} from "@angular/router"; +import {AccountService} from "../../../_services/account.service"; import { UserScrobbleHistoryComponent -} from "../../_single-module/user-scrobble-history/user-scrobble-history.component"; +} from "../../../_single-module/user-scrobble-history/user-scrobble-history.component"; @Component({ selector: 'app-manage-scrobling', - imports: [ - ManageScrobbleErrorsComponent, - AsyncPipe, - UserScrobbleHistoryComponent - ], + imports: [ + ManageScrobbleErrorsComponent, + UserScrobbleHistoryComponent, + RouterLink + ], templateUrl: './manage-scrobbling.component.html', styleUrl: './manage-scrobbling.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/series-detail/_components/kavitaplus-drawer/kavitaplus-drawer.component.html b/UI/Web/src/app/series-detail/_components/kavitaplus-drawer/kavitaplus-drawer.component.html new file mode 100644 index 000000000..371940a67 --- /dev/null +++ b/UI/Web/src/app/series-detail/_components/kavitaplus-drawer/kavitaplus-drawer.component.html @@ -0,0 +1,21 @@ + + + + +
+
{{t('panel-title')}}
+ +
+ +
+ +
+ +
diff --git a/UI/Web/src/app/series-detail/_components/kavitaplus-drawer/kavitaplus-drawer.component.scss b/UI/Web/src/app/series-detail/_components/kavitaplus-drawer/kavitaplus-drawer.component.scss new file mode 100644 index 000000000..a7a16b873 --- /dev/null +++ b/UI/Web/src/app/series-detail/_components/kavitaplus-drawer/kavitaplus-drawer.component.scss @@ -0,0 +1,3 @@ +.offcanvas-body { + overflow-y: auto; +} diff --git a/UI/Web/src/app/series-detail/_components/kavitaplus-drawer/kavitaplus-drawer.component.ts b/UI/Web/src/app/series-detail/_components/kavitaplus-drawer/kavitaplus-drawer.component.ts new file mode 100644 index 000000000..9d53fef6f --- /dev/null +++ b/UI/Web/src/app/series-detail/_components/kavitaplus-drawer/kavitaplus-drawer.component.ts @@ -0,0 +1,27 @@ +import {ChangeDetectionStrategy, Component, inject, input} from '@angular/core'; +import {NgbActiveOffcanvas} from '@ng-bootstrap/ng-bootstrap'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {KavitaplusTooltipComponent} from '../kavitaplus-tooltip/kavitaplus-tooltip.component'; +import {OffCanvasResizeComponent, ResizeMode} from '../../../shared/_components/off-canvas-resize/off-canvas-resize.component'; +import {BreakpointService} from '../../../_services/breakpoint.service'; + +@Component({ + selector: 'app-kavitaplus-drawer', + templateUrl: './kavitaplus-drawer.component.html', + styleUrls: ['./kavitaplus-drawer.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslocoDirective, KavitaplusTooltipComponent, OffCanvasResizeComponent], +}) +export class KavitaplusDrawerComponent { + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + readonly breakpointService = inject(BreakpointService); + + seriesId = input.required(); + + protected readonly ResizeMode = ResizeMode; + protected readonly window = window; + + close() { + this.activeOffcanvas.close(); + } +} diff --git a/UI/Web/src/app/series-detail/_components/kavitaplus-tooltip/kavitaplus-tooltip.component.html b/UI/Web/src/app/series-detail/_components/kavitaplus-tooltip/kavitaplus-tooltip.component.html new file mode 100644 index 000000000..6747d66e9 --- /dev/null +++ b/UI/Web/src/app/series-detail/_components/kavitaplus-tooltip/kavitaplus-tooltip.component.html @@ -0,0 +1,123 @@ + +
+ + + @if (!activeOffcanvas) { +
+
K+
+ {{t('panel-title')}} +
+ } + + @if (isLoading()) { +
+
+ {{t('loading')}} +
+
+ } @else if (seriesInfo(); as info) { + +
+ + +
+ @if (info.isMatched) { + + @if (info.mangaBakaId) { + + {{ScrobbleProvider.MangaBaka | scrobbleProviderName}} + + } @else if (info.hardcoverId) { + + {{ScrobbleProvider.Hardcover | scrobbleProviderName}} + + } @else if (info.cbrId) { + {{ScrobbleProvider.Cbr | scrobbleProviderName}} + } @else if (info.aniListId) { + + {{ScrobbleProvider.AniList | scrobbleProviderName}} + + } + + + @if (info.lastRefreshedUtc != NULL_DATE) { + {{t('refreshed')}} {{info.lastRefreshedUtc | utcToLocalTime | timeAgo}} + + {{t('next-refresh')}} {{info.nextRefreshUtc | utcToLocalTime:'shortDate'}} + } + } @else { + {{t('unmatched')}} + } +
+ +
+ + +
+ + + + +
+ +
+ + +
+ @for (event of displayedEvents(); track event.id) { +
+ +
+ +
+
+
{{event.eventType | kavitaPlusEventType}}
+ + @let desc = event | kavitaPlusEventDescription; + @if (desc) { +
{{desc}}
+ } + @if (event.errorMessage) { +
{{event.errorMessage | auditLogError}}
+ } + +
+ {{event.createdUtc | utcToLocalTime | timeAgo}} +
+ } @empty { +
{{t('no-events')}}
+ } +
+ +
+ + +
+ {{t('showing-latest', {count: displayedEvents().length})}} +
+ + @if (isAdmin()) { + + } +
+
+ + } @else { +
{{t('no-data')}}
+ } + +
+
diff --git a/UI/Web/src/app/series-detail/_components/kavitaplus-tooltip/kavitaplus-tooltip.component.scss b/UI/Web/src/app/series-detail/_components/kavitaplus-tooltip/kavitaplus-tooltip.component.scss new file mode 100644 index 000000000..18e1dfd13 --- /dev/null +++ b/UI/Web/src/app/series-detail/_components/kavitaplus-tooltip/kavitaplus-tooltip.component.scss @@ -0,0 +1,154 @@ +::ng-deep .kplus-popover { + .popover-body { + min-width: 28rem; + padding: 0; + } +} + +::ng-deep .popover { + background-color: transparent; + border-color: transparent; +} + +.kplus-panel { + gap: 0.75rem; + overflow-y: auto; +} + +.kplus-kbadge { + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + background: var(--btn-primary-bg-color); + color: var(--btn-primary-text-color); + font-weight: 700; + font-size: 0.75rem; +} + +.kplus-title { + font-size: 0.9375rem; + line-height: 1.2; +} + +.kplus-divider { + height: 1px; + background: var(--bs-border-color-translucent); + margin: 0 -1rem; +} + +.kplus-provider-badge { + padding: 0.1rem 0.5rem; + border-radius: 999px; + color: #fff; + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; + text-decoration: none; + cursor: pointer; +} + +// Filter chips + +.kplus-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + padding: 0.3rem 0.65rem; + border-radius: 999px; + border: 1px solid var(--bs-border-color); + background: transparent; + color: var(--body-text-color); + cursor: pointer; + white-space: nowrap; + transition: background 0.12s, border-color 0.12s, color 0.12s; + + &:hover:not(.active) { + border-color: var(--btn-primary-bg-color); + } + + &.active { + background: var(--btn-primary-bg-color); + color: var(--btn-primary-text-color); + border-color: var(--btn-primary-bg-color); + + .kplus-chip-count { + color: var(--btn-primary-text-color); + opacity: 0.8; + } + } +} + +.kplus-chip-count { + font-size: 0.7rem; + color: var(--text-muted-color); +} + +// Event list + +.kplus-loading-area { + min-height: 5rem; +} + +.kplus-event { + padding: 0.375rem 0; + + & + .kplus-event { + border-top: 1px solid var(--bs-border-color-translucent); + padding-top: 0.375rem; + } +} + +.kplus-event-icon { + flex-shrink: 0; + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + font-size: 0.8rem; + + &--metadata { + background: color-mix(in srgb, var(--audit-log-metadata-color) 15%, transparent); + } + + &--scrobble { + background: color-mix(in srgb, var(--audit-log-scrobble-color) 15%, transparent); + } + + &--match { + background: color-mix(in srgb, var(--audit-log-match-color) 15%, transparent); + } + + &--sync { + background: color-mix(in srgb, var(--audit-log-sync-color) 15%, transparent); + } +} + +.kplus-event-body { + flex: 1; + min-width: 0; +} + +.kplus-event-title { + font-size: 0.875rem; + font-weight: 500; + line-height: 1.3; +} + +.kplus-event-desc { + font-size: 0.775rem; + margin-top: 0.1rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kplus-event-time { + font-size: 0.75rem; + padding-top: 0.125rem; +} + +.kplus-showing { + font-size: 0.775rem; +} diff --git a/UI/Web/src/app/series-detail/_components/kavitaplus-tooltip/kavitaplus-tooltip.component.ts b/UI/Web/src/app/series-detail/_components/kavitaplus-tooltip/kavitaplus-tooltip.component.ts new file mode 100644 index 000000000..d1ae0e000 --- /dev/null +++ b/UI/Web/src/app/series-detail/_components/kavitaplus-tooltip/kavitaplus-tooltip.component.ts @@ -0,0 +1,91 @@ +import {ChangeDetectionStrategy, Component, computed, inject, input, OnInit, signal} from '@angular/core'; +import {NavigationExtras, Router} from '@angular/router'; +import {NgbActiveOffcanvas} from '@ng-bootstrap/ng-bootstrap'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {AccountService} from '../../../_services/account.service'; +import {KavitaPlusAuditService} from '../../../_services/kavitaplus-audit.service'; +import {KavitaPlusAuditSeriesInfo} from '../../../_models/kavitaplus/kavita-plus-audit-series-info'; +import {KavitaPlusAuditCategory} from '../../../_models/kavitaplus/kavita-plus-audit-category.enum'; +import {TimeAgoPipe} from '../../../_pipes/time-ago.pipe'; +import {KavitaPlusEventTypePipe} from '../../../_pipes/kavita-plus-event-type.pipe'; +import {KavitaPlusEventDescriptionPipe} from '../../../_pipes/kavita-plus-event-description.pipe'; +import {UtcToLocalTimePipe} from "../../../_pipes/utc-to-local-time.pipe"; +import {NULL_DATE} from "../../../_pipes/date-year-range.pipe"; +import { + KavitaPlusAuditEventTypeIconComponent +} from "../../../shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component"; +import {AuditLogErrorPipe} from "../../../_pipes/audit-log-error.pipe"; +import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.component"; +import { + ScrobbleProviderImageComponent +} from "../../../shared/_components/scrobble-provider-image/scrobble-provider-image.component"; +import {ScrobbleProvider} from "../../../_services/scrobbling.service"; +import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe"; + +@Component({ + selector: 'app-kavitaplus-tooltip', + templateUrl: './kavitaplus-tooltip.component.html', + styleUrls: ['./kavitaplus-tooltip.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslocoDirective, TimeAgoPipe, KavitaPlusEventTypePipe, KavitaPlusEventDescriptionPipe, UtcToLocalTimePipe, KavitaPlusAuditEventTypeIconComponent, AuditLogErrorPipe, ScrobbleProviderImageComponent, ScrobbleProviderNamePipe], +}) +export class KavitaplusTooltipComponent implements OnInit { + private readonly auditService = inject(KavitaPlusAuditService); + private readonly router = inject(Router); + protected readonly activeOffcanvas = inject(NgbActiveOffcanvas, { optional: true }); + protected readonly isAdmin = inject(AccountService).hasAdminRole; + + seriesId = input.required(); + + seriesInfo = signal(null); + categoryFilter = signal(null); + isLoading = signal(true); + + filteredEvents = computed(() => { + const info = this.seriesInfo(); + if (!info) return []; + const f = this.categoryFilter(); + return f === null ? info.recentEvents : info.recentEvents.filter(e => e.category === f); + }); + + displayedEvents = computed(() => this.filteredEvents().slice(0, 5)); + + totalCount = computed(() => this.seriesInfo()?.recentEvents.length ?? 0); + metadataCount = computed(() => this.seriesInfo()?.recentEvents.filter(e => e.category === KavitaPlusAuditCategory.Metadata).length ?? 0); + scrobbleCount = computed(() => this.seriesInfo()?.recentEvents.filter(e => e.category === KavitaPlusAuditCategory.Scrobble).length ?? 0); + matchCount = computed(() => this.seriesInfo()?.recentEvents.filter(e => e.category === KavitaPlusAuditCategory.Match).length ?? 0); + + ngOnInit() { + this.auditService.getSeriesInfo(this.seriesId()).subscribe({ + next: info => { this.seriesInfo.set(info); this.isLoading.set(false); }, + error: () => this.isLoading.set(false), + }); + } + + setFilter(cat: KavitaPlusAuditCategory | null) { + this.categoryFilter.set(cat); + } + + navigateAndClose(commands: unknown[], extras?: NavigationExtras) { + this.activeOffcanvas?.close(); + this.router.navigate(commands, extras); + } + + categoryColorClass(category: KavitaPlusAuditCategory): string { + switch (category) { + case KavitaPlusAuditCategory.Match: + return 'match'; + case KavitaPlusAuditCategory.Scrobble: + return 'scrobble'; + case KavitaPlusAuditCategory.Sync: + return 'sync'; + default: + return 'metadata'; + } + } + + protected readonly AuditCategory = KavitaPlusAuditCategory; + protected readonly NULL_DATE = NULL_DATE; + protected readonly SettingsTabId = SettingsTabId; + protected readonly ScrobbleProvider = ScrobbleProvider; +} diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 9f13d4f27..453dab7c4 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -86,7 +86,7 @@ -
+
} + @if (licenseService.hasValidLicense()) { +
+ +
+
+ +
+ } +
+ + + +
diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 310e56ab7..64d94a53d 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -28,8 +28,12 @@ import { NgbNavItem, NgbNavLink, NgbNavOutlet, + NgbPopover, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import {DrawerService} from '../../../_services/drawer.service'; +import {KavitaplusDrawerComponent} from '../kavitaplus-drawer/kavitaplus-drawer.component'; +import {KavitaplusTooltipComponent} from '../kavitaplus-tooltip/kavitaplus-tooltip.component'; import {ToastrService} from 'ngx-toastr'; import {catchError, debounceTime, EMPTY, of, ReplaySubject, tap} from 'rxjs'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; @@ -141,7 +145,7 @@ const READING_HISTORY_PAGE_SIZE = 10; imports: [CardActionablesComponent, ReactiveFormsModule, NgStyle, NgbTooltip, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, BulkOperationsComponent, - NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet, + NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet, NgbPopover, KavitaplusTooltipComponent, TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent, NgClass, DetailsTabComponent, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, RouterLink, BadgeExpanderComponent, PublicationStatusPipe, MetadataDetailRowComponent, DownloadButtonComponent, RelatedTabComponent, CoverImageComponent, ReviewsComponent, @@ -181,6 +185,7 @@ class SeriesDetailComponent implements OnInit, AfterViewInit { protected readonly breakpointService = inject(BreakpointService); private readonly entityTitleService = inject(EntityTitleService); private readonly statisticsService = inject(StatisticsService); + private readonly drawerService = inject(DrawerService); readonly scrollingBlock = viewChild>('scrollingBlock'); @@ -519,7 +524,7 @@ class SeriesDetailComponent implements OnInit, AfterViewInit { } } else if (event.event === EVENTS.CoverUpdate) { const coverUpdateEvent = event.payload as CoverUpdateEvent; - if (coverUpdateEvent.id === this.seriesId()) { + if (coverUpdateEvent.id === this.seriesId() && coverUpdateEvent.entityType === 'series') { this.themeService.refreshColorScape('series', this.seriesId()).subscribe(); } } else if (event.event === EVENTS.ChapterRemoved) { @@ -915,6 +920,11 @@ class SeriesDetailComponent implements OnInit, AfterViewInit { this.isScrobbling.update(x => !x); } + openKavitaPlusDrawer() { + const ref = this.drawerService.open(KavitaplusDrawerComponent, { position: 'end', panelClass: 'kplus-offcanvas' }); + ref.setInput('seriesId', this.seriesId()); + } + switchTabsToDetail() { this.activeTabId = Tabs.Details; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.html b/UI/Web/src/app/settings/_components/settings/settings.component.html index 6f09ffb6c..a6302de27 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -170,6 +170,16 @@ } } + @case (SettingsTabId.ManageKavitaPlusActivity) { + @if (hasActiveLicense() && accountService.hasAdminRole()) { + @defer (prefetch on idle) { +
+ +
+ } + } + } + @case (SettingsTabId.MatchedMetadata) { @if (accountService.hasAdminRole() && licenseService.hasValidLicense()) { @defer (prefetch on idle) { @@ -231,6 +241,16 @@ } } + @case (SettingsTabId.MyActivity) { + @if (hasActiveLicense()) { + @defer (prefetch on idle) { +
+ +
+ } + } + } + @case (SettingsTabId.MALStackImport) { @if (hasActiveLicense()) { @defer (prefetch on idle) { diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.ts b/UI/Web/src/app/settings/_components/settings/settings.component.ts index 7f4c9605c..50a79c56c 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -13,7 +13,6 @@ import {TranslocoDirective} from "@jsverse/transloco"; import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.component"; import {AccountService} from "../../../_services/account.service"; import {WikiLink} from "../../../_models/wiki"; -import {LicenseComponent} from "../../../admin/license/license.component"; import {ManageEmailSettingsComponent} from "../../../admin/manage-email-settings/manage-email-settings.component"; import {ManageLibraryComponent} from "../../../admin/manage-library/manage-library.component"; import {ManageMediaSettingsComponent} from "../../../admin/manage-media-settings/manage-media-settings.component"; @@ -25,7 +24,6 @@ import {ServerStatsComponent} from "../../../statistics/_components/server-stats import {SettingFragmentPipe} from "../../../_pipes/setting-fragment.pipe"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {tap} from "rxjs"; -import {ManageScrobblingComponent} from "../../../admin/manage-scrobling/manage-scrobbling.component"; import {ManageMediaIssuesComponent} from "../../../admin/manage-media-issues/manage-media-issues.component"; import { ManageCustomizationComponent @@ -34,7 +32,6 @@ import { ImportMalCollectionComponent } from "../../../collections/_components/import-mal-collection/import-mal-collection.component"; import {LicenseService} from "../../../_services/license.service"; -import {ManageMatchedMetadataComponent} from "../../../admin/manage-matched-metadata/manage-matched-metadata.component"; import {ManageUserTokensComponent} from "../../../admin/manage-user-tokens/manage-user-tokens.component"; import {EmailHistoryComponent} from "../../../admin/email-history/email-history.component"; import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobbling-holds.component"; @@ -56,6 +53,15 @@ import {ManageCustomKeyBindsComponent} from "../../../user-settings/custom-key-b import {AccountSettingsComponent} from "src/app/user-settings/account-settings/account-settings.component"; import {CblManagerComponent} from "../../../user-settings/cbl-manager/cbl-manager.component"; import {ManageRemapRulesComponent} from "../../../user-settings/manage-remap-rules/manage-remap-rules.component"; +import {KavitaplusActivityComponent} from "../../../user-settings/kavitaplus-activity/kavitaplus-activity.component"; +import { + ManageKavitaplusActivityComponent +} from "../../../admin/kavita-plus/manage-kavitaplus-activity/manage-kavitaplus-activity.component"; +import {ManageScrobblingComponent} from "../../../admin/kavita-plus/manage-scrobling/manage-scrobbling.component"; +import { + ManageMatchedMetadataComponent +} from "../../../admin/kavita-plus/manage-matched-metadata/manage-matched-metadata.component"; +import {LicenseComponent} from "../../../admin/kavita-plus/license/license.component"; @Component({ selector: 'app-settings', @@ -95,7 +101,9 @@ import {ManageRemapRulesComponent} from "../../../user-settings/manage-remap-rul ManageAuthKeysComponent, AccountSettingsComponent, CblManagerComponent, - ManageRemapRulesComponent + ManageRemapRulesComponent, + KavitaplusActivityComponent, + ManageKavitaplusActivityComponent, ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', diff --git a/UI/Web/src/app/shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component.html b/UI/Web/src/app/shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component.html new file mode 100644 index 000000000..562b9083d --- /dev/null +++ b/UI/Web/src/app/shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component.html @@ -0,0 +1 @@ + diff --git a/UI/Web/src/app/shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component.scss b/UI/Web/src/app/shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component.scss new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/UI/Web/src/app/shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component.scss @@ -0,0 +1 @@ + diff --git a/UI/Web/src/app/shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component.ts b/UI/Web/src/app/shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component.ts new file mode 100644 index 000000000..e8194126a --- /dev/null +++ b/UI/Web/src/app/shared/_components/kavitaplus-event-type-icon/kavita-plus-audit-event-type-icon.component.ts @@ -0,0 +1,85 @@ +import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core'; +import {KavitaPlusEventType} from '../../../_models/kavitaplus/kavita-plus-event-type.enum'; +import {KavitaPlusAuditCategory} from "../../../_models/kavitaplus/kavita-plus-audit-category.enum"; + +function resolveIcon(type: KavitaPlusEventType): string { + switch (type) { + case KavitaPlusEventType.SeriesMatched: return 'fas fa-table-list'; + case KavitaPlusEventType.SeriesMatchFailed: return 'fas fa-circle-exclamation'; + case KavitaPlusEventType.SeriesBlacklisted: return 'fas fa-circle-xmark'; + case KavitaPlusEventType.SeriesMatchFixed: return 'fas fa-eraser'; + case KavitaPlusEventType.SeriesDontMatchSet: return 'fas fa-table-cells-row-lock'; + case KavitaPlusEventType.MetadataFetched: return 'fas fa-magnifying-glass'; + case KavitaPlusEventType.MetadataUpdated: return 'fas fa-database'; + case KavitaPlusEventType.CoverUpdated: return 'fas fa-image'; + case KavitaPlusEventType.ChapterMetadataUpdated: return 'fas fa-database'; + case KavitaPlusEventType.ChapterCoverUpdated: return 'fas fa-image'; + case KavitaPlusEventType.PersonCoverUpdated: return 'fas fa-database'; + case KavitaPlusEventType.PersonAliasAdded: return 'fas fa-person-circle-plus'; + case KavitaPlusEventType.CollectionSynced: return 'fas fa-folder-open'; + case KavitaPlusEventType.CollectionItemAdded: return 'fas fa-folder-plus'; + case KavitaPlusEventType.ScrobbleEventCreated: return 'fa-regular fa-bookmark'; + case KavitaPlusEventType.ScrobbleEventUpdated: return 'fa-solid fa-bookmark'; + case KavitaPlusEventType.ScrobbleEventSent: return 'fas fa-paper-plane'; + case KavitaPlusEventType.ScrobbleEventFailed: return 'fas fa-circle-exclamation'; + case KavitaPlusEventType.ScrobbleRateLimitHit: return 'fas fa-circle-xmark'; + case KavitaPlusEventType.ScrobbleEventSkipped: return 'fas fa-circle-xmark'; + case KavitaPlusEventType.ScrobbleHoldRemoved: return 'fas fa-eraser'; + case KavitaPlusEventType.ScrobbleHoldAdded: return 'fas fa-table-cells-row-lock'; + case KavitaPlusEventType.SyncStarted: return 'fas fa-cloud-arrow-up'; + case KavitaPlusEventType.SyncCompleted: return 'fas fa-cloud-arrow-down'; + case KavitaPlusEventType.SyncFailed: return 'fas fa-cloud-arrow-down'; + default: return 'fas fa-circle-exclamation'; + } +} + +function resolveColor(type: KavitaPlusEventType): string { + switch (type) { + case KavitaPlusEventType.SeriesMatchFailed: + case KavitaPlusEventType.ScrobbleEventFailed: + case KavitaPlusEventType.SyncFailed: + return 'var(--error-color)'; + case KavitaPlusEventType.SeriesBlacklisted: + case KavitaPlusEventType.ScrobbleRateLimitHit: + case KavitaPlusEventType.ScrobbleEventSkipped: + return 'var(--warning-color)'; + default: + return ''; + } +} + +function resolveCategory(type: KavitaPlusAuditCategory): string { + switch (type) { + case KavitaPlusAuditCategory.Match: + return 'var(--audit-log-match-color)'; + case KavitaPlusAuditCategory.Metadata: + return 'var(--audit-log-metadata-color)'; + case KavitaPlusAuditCategory.Scrobble: + return 'var(--audit-log-scrobble-color)'; + case KavitaPlusAuditCategory.Sync: + return 'var(--audit-log-sync-color)'; + } +} + +@Component({ + selector: 'app-kavitaplus-audit-event-type-icon', + templateUrl: './kavita-plus-audit-event-type-icon.component.html', + styleUrl: './kavita-plus-audit-event-type-icon.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class KavitaPlusAuditEventTypeIconComponent { + type = input.required(); + /** Category will override colors when there is not an explicit color designation (error/warning) */ + category = input.required(); + + protected readonly iconClass = computed(() => resolveIcon(this.type())); + protected readonly iconColor = computed(() => { + + const color = resolveColor(this.type()); + const categoryColor = resolveCategory(this.category()); + + if (color === '') return categoryColor; + + return color; + }); +} diff --git a/UI/Web/src/app/shared/_components/scrobble-provider-image/scrobble-provider-image.component.html b/UI/Web/src/app/shared/_components/scrobble-provider-image/scrobble-provider-image.component.html new file mode 100644 index 000000000..3fe6207b1 --- /dev/null +++ b/UI/Web/src/app/shared/_components/scrobble-provider-image/scrobble-provider-image.component.html @@ -0,0 +1,8 @@ +@let providerName = provider() | scrobbleProviderName; +@let providerImageUrl = provider() | providerImage; + + diff --git a/UI/Web/src/app/shared/_components/scrobble-provider-image/scrobble-provider-image.component.scss b/UI/Web/src/app/shared/_components/scrobble-provider-image/scrobble-provider-image.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/shared/_components/scrobble-provider-image/scrobble-provider-image.component.ts b/UI/Web/src/app/shared/_components/scrobble-provider-image/scrobble-provider-image.component.ts new file mode 100644 index 000000000..8000c48aa --- /dev/null +++ b/UI/Web/src/app/shared/_components/scrobble-provider-image/scrobble-provider-image.component.ts @@ -0,0 +1,22 @@ +import {ChangeDetectionStrategy, Component, input} from '@angular/core'; +import {ScrobbleProvider} from "../../../_services/scrobbling.service"; +import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe"; +import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe"; +import {NgOptimizedImage} from "@angular/common"; + +@Component({ + selector: 'app-scrobble-provider-image', + imports: [ + ScrobbleProviderNamePipe, + ProviderImagePipe, + NgOptimizedImage + ], + templateUrl: './scrobble-provider-image.component.html', + styleUrl: './scrobble-provider-image.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ScrobbleProviderImageComponent { + provider = input.required(); + classes = input(''); + size = input(32); +} diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index dfb20dc4c..d6c996f92 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -29,6 +29,9 @@ import {MatchStateOption} from "../../_models/kavitaplus/match-state-option"; import {KeyBindService} from "../../_services/key-bind.service"; import {KeyBindTarget} from "../../_models/preferences/preferences"; import {BreakpointService} from "../../_services/breakpoint.service"; +import {KavitaPlusAuditService} from "../../_services/kavitaplus-audit.service"; +import {KavitaPlusAuditCategory} from "../../_models/kavitaplus/kavita-plus-audit-category.enum"; +import {AuditStatus} from "../../_models/kavitaplus/audit-status.enum"; export enum SettingsTabId { @@ -54,6 +57,7 @@ export enum SettingsTabId { MappingsImport = 'admin-mappings-import', MatchedMetadata = 'admin-matched-metadata', ManageUserTokens = 'admin-manage-tokens', + ManageKavitaPlusActivity = 'admin-manage-kavitaplus-activity', Metadata = 'admin-metadata', // Non-Admin @@ -67,6 +71,7 @@ export enum SettingsTabId { Devices = 'devices', Scrobbling = 'scrobbling', ScrobblingHolds = 'scrobble-holds', + MyActivity = 'my-activity', Customize = 'customize', CBLImport = 'cbl-import', RemapRules = 'remap-rules', @@ -145,6 +150,7 @@ export class PreferenceNavComponent implements AfterViewInit { private readonly document = inject(DOCUMENT); private readonly keyBindService = inject(KeyBindService); protected readonly breakpointService = inject(BreakpointService); + protected readonly kavitaplusAuditService = inject(KavitaPlusAuditService); readonly hasValidLicense$ = toObservable(this.licenseService.hasValidLicense); @@ -180,6 +186,15 @@ export class PreferenceNavComponent implements AfterViewInit { { initialValue: -1 } ); + private readonly scrobblingFailuresBadgeCount = toSignal( + this.kavitaplusAuditService.getMyActivity({category: KavitaPlusAuditCategory.Scrobble, userId: this.accountService.currentUser()!.id, status: AuditStatus.Failure}).pipe( + takeUntilDestroyed(this.destroyRef), + map(d => d.pagination.totalItems), + shareReplay({bufferSize: 1, refCount: true}) + ), + { initialValue: -1 } + ); + private readonly scrobblingErrorBadgeCount = toSignal( toObservable(this.accountService.hasAdminRole).pipe( take(1), @@ -289,6 +304,8 @@ export class PreferenceNavComponent implements AfterViewInit { SideNavItem.kPlusOnly(SettingsTabId.Metadata, [Role.Admin]), SideNavItem.kPlusOnly(SettingsTabId.MatchedMetadata, [Role.Admin], this.matchedMetadataBadgeCount), SideNavItem.kPlusOnly(SettingsTabId.ScrobblingHolds), + SideNavItem.kPlusOnly(SettingsTabId.ManageKavitaPlusActivity), + SideNavItem.kPlusOnly(SettingsTabId.MyActivity, [], this.scrobblingFailuresBadgeCount), SideNavItem.kPlusOnly(SettingsTabId.Scrobbling, [], this.scrobblingErrorBadgeCount), ] } diff --git a/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html index e9eaa55b7..fc6bf7321 100644 --- a/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html +++ b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html @@ -34,7 +34,7 @@
- + diff --git a/UI/Web/src/app/user-settings/kavitaplus-activity/kavitaplus-activity.component.html b/UI/Web/src/app/user-settings/kavitaplus-activity/kavitaplus-activity.component.html new file mode 100644 index 000000000..d31c5d79a --- /dev/null +++ b/UI/Web/src/app/user-settings/kavitaplus-activity/kavitaplus-activity.component.html @@ -0,0 +1,41 @@ + +
+

{{t('description')}}

+ +
+ @for(provider of scrobblingProviders(); track provider.provider) { +
+ +
+ } +
+ + + + + +
+
diff --git a/UI/Web/src/app/user-settings/kavitaplus-activity/kavitaplus-activity.component.scss b/UI/Web/src/app/user-settings/kavitaplus-activity/kavitaplus-activity.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/user-settings/kavitaplus-activity/kavitaplus-activity.component.ts b/UI/Web/src/app/user-settings/kavitaplus-activity/kavitaplus-activity.component.ts new file mode 100644 index 000000000..981daa093 --- /dev/null +++ b/UI/Web/src/app/user-settings/kavitaplus-activity/kavitaplus-activity.component.ts @@ -0,0 +1,108 @@ +import {ChangeDetectionStrategy, Component, computed, DestroyRef, inject, OnInit, signal} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {NgbNav, NgbNavItem, NgbNavLink} from '@ng-bootstrap/ng-bootstrap'; +import {KavitaPlusAuditService} from '../../_services/kavitaplus-audit.service'; +import {ScrobbleProvider, ScrobblingService, UserScrobbleProvider} from '../../_services/scrobbling.service'; +import {KavitaPlusAuditEntry} from '../../_models/kavitaplus/kavita-plus-audit-entry'; +import {KavitaPlusAuditCategory} from '../../_models/kavitaplus/kavita-plus-audit-category.enum'; +import {AuditStatus} from '../../_models/kavitaplus/audit-status.enum'; +import {KavitaplusTimelineComponent} from '../../_single-module/kavitaplus-timeline/kavitaplus-timeline.component'; +import {ScrobbleAccountCardComponent} from '../scrobble-account-card/scrobble-account-card.component'; +import {KavitaPlusEventType} from "../../_models/kavitaplus/kavita-plus-event-type.enum"; +import {Tabs} from "../../_models/tabs"; +import {TabTitlePipe} from "../../_pipes/tab-title.pipe"; +import {Pagination} from '../../_models/pagination'; + +@Component({ + selector: 'app-kavitaplus-activity', + templateUrl: './kavitaplus-activity.component.html', + styleUrls: ['./kavitaplus-activity.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslocoDirective, NgbNav, NgbNavItem, NgbNavLink, KavitaplusTimelineComponent, ScrobbleAccountCardComponent, TabTitlePipe], +}) +export class KavitaplusActivityComponent implements OnInit { + private readonly auditService = inject(KavitaPlusAuditService); + private readonly scrobblingService = inject(ScrobblingService); + private readonly destroyRef = inject(DestroyRef); + private readonly PAGE_SIZE = 50; + + entries = signal([]); + isLoading = signal(true); + isLoadingMore = signal(false); + activeTab = signal(Tabs.All); + scrobblingProviders = signal([]); + currentPage = signal(0); + pagination = signal(null); + + hasMore = computed(() => { + const p = this.pagination(); + return p != null && p.currentPage < p.totalPages - 1; + }); + + allCount = computed(() => this.entries().length); + scrobbleCount = computed(() => this.entries().filter(e => e.category === KavitaPlusAuditCategory.Scrobble && ![KavitaPlusEventType.ScrobbleHoldAdded, KavitaPlusEventType.ScrobbleHoldRemoved].includes(e.eventType)).length); + failedCount = computed(() => this.entries().filter(e => e.status === AuditStatus.Failure).length); + myChangesCount = computed(() => this.entries().filter(e => e.userId != null).length); + scrobbleHoldsCount = computed(() => this.entries().filter(e => e.category === KavitaPlusAuditCategory.Scrobble && [KavitaPlusEventType.ScrobbleHoldAdded, KavitaPlusEventType.ScrobbleHoldRemoved].includes(e.eventType)).length); + + filteredEntries = computed(() => { + const tab = this.activeTab(); + const all = this.entries(); + if (tab === Tabs.Scrobbles) return all.filter(e => e.category === KavitaPlusAuditCategory.Scrobble && ![KavitaPlusEventType.ScrobbleHoldAdded, KavitaPlusEventType.ScrobbleHoldRemoved].includes(e.eventType)); + if (tab === Tabs.Failed) return all.filter(e => e.status === AuditStatus.Failure); + if (tab === Tabs.MyChanges) return all.filter(e => e.userId != null); + if (tab === Tabs.ScrobbleHolds) return all.filter(e => e.category === KavitaPlusAuditCategory.Scrobble && [KavitaPlusEventType.ScrobbleHoldAdded, KavitaPlusEventType.ScrobbleHoldRemoved].includes(e.eventType)); + return all; + }); + + ngOnInit() { + this.loadData(); + + this.scrobblingService.getScrobbleProviders().subscribe(tokens => this.scrobblingProviders.set(tokens)); + } + + loadData(reset = true) { + if (reset) { + this.currentPage.set(0); + this.entries.set([]); + this.isLoading.set(true); + } else { + this.isLoadingMore.set(true); + } + this.auditService.getMyActivity({}, this.currentPage(), this.PAGE_SIZE) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: result => { + this.pagination.set(result.pagination); + if (reset) { + this.entries.set(result.result ?? []); + this.isLoading.set(false); + } else { + this.entries.update(prev => [...prev, ...(result.result ?? [])]); + this.isLoadingMore.set(false); + } + }, + error: () => { + this.isLoading.set(false); + this.isLoadingMore.set(false); + }, + }); + } + + + loadMore() { + this.currentPage.update(p => p + 1); + this.loadData(false); + } + + retryScrobbleEvent(event: KavitaPlusAuditEntry) { + this.scrobblingService.retryScrobbleEvent(event).subscribe((success) => { + if (!success) return; + this.loadData(); + }); + } + + protected readonly ScrobbleProvider = ScrobbleProvider; + protected readonly Tabs = Tabs; +} diff --git a/UI/Web/src/app/user-settings/scrobble-account-card/scrobble-account-card.component.html b/UI/Web/src/app/user-settings/scrobble-account-card/scrobble-account-card.component.html new file mode 100644 index 000000000..c11cd865c --- /dev/null +++ b/UI/Web/src/app/user-settings/scrobble-account-card/scrobble-account-card.component.html @@ -0,0 +1,60 @@ + + @let providerName = provider().provider | scrobbleProviderName; + +
+ + + +
+
+ {{providerName}} +
+ + + + @if (isConnected() && !hasExpired()) { + {{t('provider-connected')}} + } @else if (hasExpired()) { + {{t('provider-expired')}} + } @else { + {{t('provider-not-connected')}} + } + +
+
+ + @if (isConnected()) { +
{{username() ? username() : ''}}
+ @if (isScrobbleDisabled()) { +
+ + {{t('disabled')}} +
+ } + } @else { +
{{t('provider-description')}}
+ } +
+ + @if (isConnected()) { + + } @else { + + } +
+
diff --git a/UI/Web/src/app/user-settings/scrobble-account-card/scrobble-account-card.component.scss b/UI/Web/src/app/user-settings/scrobble-account-card/scrobble-account-card.component.scss new file mode 100644 index 000000000..e264909b8 --- /dev/null +++ b/UI/Web/src/app/user-settings/scrobble-account-card/scrobble-account-card.component.scss @@ -0,0 +1,32 @@ +:host { + --provider-card-border-color: #353635; +} + +.provider-card { + flex: 1; + max-width: 25rem; + height: 4.0625rem; + background: var(--elevation-layer2); + border-left-width: 0.1875rem !important; + border-color: var(--provider-card-border-color) !important; +} + +.provider-logo { + width: 2.75rem; + height: 2.75rem; +} + +.status-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + flex-shrink: 0; + + &--connected { background: var(--success-color); } + &--expired { background: var(--error-color); } + &--disconnected { background: var(--text-muted-color); } +} + +.warning { + color: var(--warning-color); +} diff --git a/UI/Web/src/app/user-settings/scrobble-account-card/scrobble-account-card.component.ts b/UI/Web/src/app/user-settings/scrobble-account-card/scrobble-account-card.component.ts new file mode 100644 index 000000000..5818f8e0e --- /dev/null +++ b/UI/Web/src/app/user-settings/scrobble-account-card/scrobble-account-card.component.ts @@ -0,0 +1,47 @@ +import {ChangeDetectionStrategy, Component, computed, inject, input} from '@angular/core'; +import {Router} from '@angular/router'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; +import {UserScrobbleProvider} from '../../_services/scrobbling.service'; +import {ScrobbleProviderNamePipe} from '../../_pipes/scrobble-provider-name.pipe'; +import {SettingsTabId} from '../../sidenav/preference-nav/preference-nav.component'; +import { + ScrobbleProviderImageComponent +} from '../../shared/_components/scrobble-provider-image/scrobble-provider-image.component'; +import {AccountService} from "../../_services/account.service"; + +@Component({ + selector: 'app-scrobble-account-card', + templateUrl: './scrobble-account-card.component.html', + styleUrls: ['./scrobble-account-card.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslocoDirective, ScrobbleProviderNamePipe, ScrobbleProviderImageComponent, NgbTooltip], +}) +export class ScrobbleAccountCardComponent { + private readonly router = inject(Router); + private readonly accountService = inject(AccountService); + + provider = input.required(); + + isConnected = computed(() => { + const token = this.provider().authenticationToken; + return token != null && token.length > 0; + }); + + hasExpired = computed(() => { + const until = this.provider().validUntilUtc; + if (!until) return false; + return new Date(until) < new Date(); + }); + + username = computed(() => this.provider().userName ?? ''); + + isScrobbleDisabled = computed(() => { + return !this.accountService.currentUser()?.preferences.aniListScrobblingEnabled; + }) + + + goToScrobbling() { + this.router.navigate(['/settings'], {fragment: SettingsTabId.Account}); + } +} diff --git a/UI/Web/src/app/user-settings/scrobble-provider-item/scrobble-provider-item.component.html b/UI/Web/src/app/user-settings/scrobble-provider-item/scrobble-provider-item.component.html index 4f4cb0f94..960ff8bb3 100644 --- a/UI/Web/src/app/user-settings/scrobble-provider-item/scrobble-provider-item.component.html +++ b/UI/Web/src/app/user-settings/scrobble-provider-item/scrobble-provider-item.component.html @@ -1,21 +1,23 @@ @let providerName = provider() | scrobbleProviderName;
- {{providerName}} + + @if(token() && token().length > 0) { {{t('token-set')}} + + + @if(hasExpired()) { + + {{t('token-expired')}} + } @else { + + {{providerName}}: {{t('token-valid')}} + } + } @else { {{t('no-token-set')}} } - - @if(hasExpired()) { - - {{t('token-expired')}} - } @else { - - {{providerName}}: {{t('token-valid')}} - } -
@if (isEditMode()) {
diff --git a/UI/Web/src/app/user-settings/scrobble-provider-item/scrobble-provider-item.component.ts b/UI/Web/src/app/user-settings/scrobble-provider-item/scrobble-provider-item.component.ts index 089ac883f..01223886b 100644 --- a/UI/Web/src/app/user-settings/scrobble-provider-item/scrobble-provider-item.component.ts +++ b/UI/Web/src/app/user-settings/scrobble-provider-item/scrobble-provider-item.component.ts @@ -1,18 +1,32 @@ -import {ChangeDetectionStrategy, Component, contentChild, inject, input, signal, TemplateRef} from '@angular/core'; -import {NgOptimizedImage, NgTemplateOutlet} from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + contentChild, + DestroyRef, + effect, + inject, + input, + signal, + TemplateRef +} from '@angular/core'; +import {NgTemplateOutlet} from "@angular/common"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {TranslocoDirective} from "@jsverse/transloco"; import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service"; import {ScrobbleProviderNamePipe} from "../../_pipes/scrobble-provider-name.pipe"; +import { + ScrobbleProviderImageComponent +} from "../../shared/_components/scrobble-provider-image/scrobble-provider-image.component"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Component({ selector: 'app-scrobble-provider-item', imports: [ - NgOptimizedImage, - NgbTooltip, - TranslocoDirective, - ScrobbleProviderNamePipe, - NgTemplateOutlet + NgbTooltip, + TranslocoDirective, + ScrobbleProviderNamePipe, + NgTemplateOutlet, + ScrobbleProviderImageComponent ], templateUrl: './scrobble-provider-item.component.html', styleUrl: './scrobble-provider-item.component.scss', @@ -21,6 +35,7 @@ import {ScrobbleProviderNamePipe} from "../../_pipes/scrobble-provider-name.pipe export class ScrobbleProviderItemComponent { private readonly scrobblingService = inject(ScrobblingService); + private readonly destroyRef = inject(DestroyRef); provider = input.required(); token = input.required(); @@ -30,8 +45,13 @@ export class ScrobbleProviderItemComponent { hasExpired = signal(false); constructor() { - this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => { - this.hasExpired.set(hasExpired); + + effect(() => { + this.scrobblingService.hasTokenExpired(this.provider()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(hasExpired => { + this.hasExpired.set(hasExpired); + }); }); } diff --git a/UI/Web/src/assets/images/ExternalServices/hardcover-lg.png b/UI/Web/src/assets/images/ExternalServices/hardcover-lg.png new file mode 100644 index 000000000..a2d8b727f Binary files /dev/null and b/UI/Web/src/assets/images/ExternalServices/hardcover-lg.png differ diff --git a/UI/Web/src/assets/images/ExternalServices/hardcover.png b/UI/Web/src/assets/images/ExternalServices/hardcover.png new file mode 100644 index 000000000..daf9ddae3 Binary files /dev/null and b/UI/Web/src/assets/images/ExternalServices/hardcover.png differ diff --git a/UI/Web/src/assets/images/ExternalServices/mangabaka-lg.png b/UI/Web/src/assets/images/ExternalServices/mangabaka-lg.png new file mode 100644 index 000000000..338cc0d93 Binary files /dev/null and b/UI/Web/src/assets/images/ExternalServices/mangabaka-lg.png differ diff --git a/UI/Web/src/assets/images/ExternalServices/mangabaka.png b/UI/Web/src/assets/images/ExternalServices/mangabaka.png new file mode 100644 index 000000000..d742cf80e Binary files /dev/null and b/UI/Web/src/assets/images/ExternalServices/mangabaka.png differ diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index da8b2b503..1b180037d 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1240,7 +1240,178 @@ "on": "{{reader-settings.on}}", "off": "{{reader-settings.off}}", "loading": "{{common.loading}}", - "read-count-tooltip": "Total Reads" + "read-count-tooltip": "Total Reads", + "kavitaplus-audit-alt": "Kavita+ Activity" + }, + + "kavitaplus-audit": { + "panel-title": "Kavita+: Audit Log", + "matched": "Matched", + "unmatched": "Unmatched", + "filter-all": "All", + "filter-match": "Match", + "filter-metadata": "Metadata", + "filter-scrobble": "Scrobble", + "filter-sync": "Sync", + "no-events": "No recent events", + "no-data": "No data available", + "loading": "{{common.loading}}", + "close": "{{common.close}}", + "refreshed": "Refreshed", + "next-refresh": "Next Refresh", + "showing-latest": "Showing latest {{count}} events", + "my-activity": "My Activity", + "admin-feed": "Admin feed", + "read-progress-sent-label": "Read progress - {{chapter}}", + "fields-updated": "{{count}} fields updated", + "rating-updated": "Rating updated", + "add-want-to-read": "Added to want to read", + "remove-want-to-read": "Removed from want to read", + "review-submitted": "Review submitted" + }, + + "kavitaplus-activity": { + "description": "Reading activity Kavita has synced to external services on your behalf - progress, ratings, and reading status." + }, + + "manage-kavitaplus-activity": { + "description": "90 days of metadata, match, scrobble and sync events. Triage failures and trigger a manual match from here.", + "stats-events-label": "Events (24h)", + "stats-failures-label": "Failures", + "stats-match-label": "Matched", + "stats-matched-label": "Matched {{percent}}%", + "stats-coverage-subtitle": "{{percent}}% coverage", + "stats-queue-label": "Queue", + "stats-unresolved": "{{count}} unresolved", + "stats-stale-subtitle": "{{count}} stale", + "stats-blacklisted-subtitle": "{{count}} failed to match", + "filter-all": "All", + "filter-match": "Match", + "filter-metadata": "Metadata", + "filter-scrobble": "Scrobble", + "filter-sync": "Sync", + "filter-status-label": "Status filter", + "today": "Today", + "yesterday": "Yesterday", + "events-count": "{{count}} events", + "subject-label": "Subject: {{subject}}", + "search-alt": "Search events", + "filter-subject-label": "Subject", + "filter-user-label": "User", + "filter-timeframe-label": "Time Frame", + "filter-timeframe-24h": "Last 24 Hours", + "filter-timeframe-7d": "Last 7 Days", + "filter-timeframe-30d": "Last 30 Days", + "system": "System" + }, + + "kavitaplus-diff": { + "field-label": "Field", + "before-label": "Before", + "after-label": "After", + "null-value": "—", + "object-expanded": "expanded below" + }, + + "scrobble-account-card": { + "provider-connected": "Connected", + "provider-not-connected": "Not connected", + "provider-expired": "Token expired", + "provider-connect": "Connect", + "provider-settings": "Settings", + "provider-description": "Link to send progress", + "disabled": "{{cron-frequency-pipe.disabled}}" + }, + + "kavitaplus-timeline": { + "today": "Today", + "yesterday": "Yesterday", + "events-count": "{{count}} events", + "no-events": "No events to display", + "retry": "Retry", + "load-more-label": "Load more" + }, + + "audit-status-title-pipe": { + "success": "Success", + "failure": "Failure", + "info": "Info" + }, + + "audit-subject-title-pipe": { + "series": "Series", + "person": "Person", + "collection": "Collection", + "chapter": "Chapter", + "global": "Global" + }, + + "metadata-field-change-kind-title-pipe": { + "relationships": "Relationships", + "characters": "Characters", + "artists": "Artists", + "writers": "Writers", + "tags": "Tags", + "genres": "Genres", + "publication-status": "Publication Status", + "age-rating": "Age Rating", + "external-ids": "External IDs", + "summary": "Summary", + "title": "Title", + "release-date": "Release Date", + "release-year": "Release Year", + "localized-name": "Localized Name" + }, + + + "kavita-plus-event-description-pipe": { + "read-progress-sent": "{{chapter}}", + "rating-updated": "Rating updated: {{rating}}/5", + "add-want-to-read": "Added to want to read", + "remove-want-to-read": "Removed from want to read", + "review-submitted": "Review submitted", + "fields-updated": "{{count}} fields updated", + "chapter-cover-updated": "Cover updated: {{chapter}}", + "series-cover-updated": "Series cover updated", + "series-match-fixed": "Series matched with {{matchName}}" + }, + + "kavita-plus-event-type-pipe": { + "series-matched": "Series Matched", + "series-match-failed": "Match Failed", + "series-blacklisted": "Blacklisted", + "series-match-fixed": "Match Fixed", + "series-dont-match-set": "Don't Match Set", + "metadata-fetched": "Metadata Fetched", + "metadata-updated": "Metadata Updated", + "cover-updated": "Cover Updated", + "chapter-metadata-updated": "Chapter Metadata Updated", + "chapter-cover-updated": "Chapter Cover Updated", + "person-cover-updated": "Person Cover Updated", + "person-alias-added": "Person Alias Added", + "collection-synced": "Collection Synced", + "collection-item-added": "Collection Item Added", + "scrobble-created": "Scrobble Created", + "scrobble-updated": "Scrobble Updated", + "scrobble-sent": "Scrobble Sent", + "scrobble-failed": "Scrobble Failed", + "scrobble-rate-limit": "Rate Limit Hit", + "scrobble-skipped": "Scrobble Skipped", + "scrobble-hold-added": "Scrobble Hold Added", + "scrobble-hold-removed": "Scrobble Hold Removed", + "sync-started": "Sync Started", + "sync-completed": "Sync Completed", + "sync-failed": "Sync Failed" + }, + + "audit-log-messages": { + "rate-limit-hit": "Rate Limit Hit", + "invalid-token": "Invalid AniList Token", + "unknown-series": "Unknown Series", + "series-dont-match": "Series Marked as Do Not Match", + "scrobble-hold-active": "Series Is on Hold", + "library-scrobbling-disabled": "Library Scrobbling Disabled", + "token-expired": "AniList Token Has Expired" }, "reading-progress-status-pipe": { @@ -2051,6 +2222,7 @@ "admin-metadata": "Manage Metadata", "admin-mappings-import": "Metadata Settings", "scrobble-holds": "Scrobble Holds", + "my-activity": "My Activity", "account": "Account", "preferences": "Preferences", "custom-key-binds": "Key Binds", @@ -2064,7 +2236,8 @@ "cbl-import": "Reading List Manager", "remap-rules": "Remap Rules", "mal-stack-import": "MAL Stack", - "admin-public-metadata": "Manage Metadata" + "admin-public-metadata": "Manage Metadata", + "admin-manage-kavitaplus-activity": "Manage Kavita+ Activity" }, "manage-custom-key-binds": { @@ -4050,7 +4223,12 @@ "uploaded-tab": "Uploaded", "current-tab": "Current", "kavita-plus-tab": "Kavita+", - "other-tab": "Other" + "other-tab": "Other", + "all-tab": "All", + "scrobbles-tab": "Scrobbles", + "failed-tab": "Failed", + "my-changes-tab": "My Changes", + "scrobble-holds-tab": "Scrobble Holds" }, "common": { diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index e4b942381..fc81d1894 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -156,3 +156,8 @@ body { animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } + +// Kavita+ audit side-drawer width +.kplus-offcanvas { + --bs-offcanvas-width: 28rem; +} diff --git a/UI/Web/src/theme/components/_modal.scss b/UI/Web/src/theme/components/_modal.scss index 3beb91308..3134c1c26 100644 --- a/UI/Web/src/theme/components/_modal.scss +++ b/UI/Web/src/theme/components/_modal.scss @@ -5,6 +5,20 @@ font-size: .9rem; } +.modal-header { + border-bottom: 1px solid var(--modal-footer-border-top-color); + //background-color: var(--modal-footer-bg-color); +} + +.modal-body { + background-color: var(--modal-body-bg-color); +} + +.modal-footer { + border-top: 1px solid var(--modal-footer-border-top-color); + background-color: var(--modal-footer-bg-color); +} + .modal-title { word-break: break-all; } @@ -16,4 +30,4 @@ input, textarea { font-size: 0.9rem !important; -} \ No newline at end of file +} diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index a742df7ef..ccb071962 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -46,7 +46,7 @@ --primary-color-darkest-shade: #25624A; --error-color: #BD362F; --error-accent-color: #ea0d02; - --warning-color: #FFECB5; + --warning-color: #dd9f50; --body-text-color: #efefef; --btn-icon-filter: invert(1) grayscale(100%) brightness(200%); --primary-color-scrollbar: rgba(255,255,255,0.3); @@ -93,6 +93,11 @@ --nav-offset: 3.75rem; --nav-mobile-offset: 3.4375rem; + /* Global Common Colors */ + --success-color: #22c55e; // Success, Online, Connected + // --text-muted-color (we can alias this) + + /* Should we render the series cover as background on mobile */ --mobile-series-img-background: true; @@ -207,7 +212,10 @@ --nav-link-text-color: var(--elevation-layer10); /* Modal */ - --modal-bg-color: #202122; // var(--bs-body-bg) + --modal-bg-color: #202122; + --modal-footer-bg-color: #292929; + --modal-footer-border-top-color: rgba(255, 255, 255, 0.05); + --modal-body-bg-color: var(--elevation-solid-1); /* Header */ --nav-header-text-color: white; @@ -508,4 +516,10 @@ --activity-card-client-platform-badge-bg-color: #8b5cf6; --activity-card-client-device-badge-bg-color: #3b82f6; + /** KavitaPlus Audit Log **/ + --audit-log-metadata-color: #4AC694; + --audit-log-scrobble-color: #FAC858; + --audit-log-match-color: #5470C6; + --audit-log-sync-color: #73C0DE; + }