diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index 9f45ca619..e24cf7a86 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Data.Common; using System.IO.Abstractions.TestingHelpers; using System.Linq; @@ -20,7 +21,7 @@ using NSubstitute; namespace API.Tests; -public abstract class AbstractDbTest +public abstract class AbstractDbTest : IDisposable { protected readonly DbConnection _connection; protected readonly DataContext _context; @@ -28,6 +29,7 @@ public abstract class AbstractDbTest protected const string CacheDirectory = "C:/kavita/config/cache/"; + protected const string CacheLongDirectory = "C:/kavita/config/cache-long/"; protected const string CoverImageDirectory = "C:/kavita/config/covers/"; protected const string BackupDirectory = "C:/kavita/config/backups/"; protected const string LogDirectory = "C:/kavita/config/logs/"; @@ -38,21 +40,22 @@ public abstract class AbstractDbTest protected AbstractDbTest() { - var contextOptions = new DbContextOptionsBuilder() + var contextOptions = new DbContextOptionsBuilder() .UseSqlite(CreateInMemoryDatabase()) .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; _context = new DataContext(contextOptions); + + _context.Database.EnsureCreated(); // Ensure DB schema is created + Task.Run(SeedDb).GetAwaiter().GetResult(); var config = new MapperConfiguration(cfg => cfg.AddProfile()); var mapper = config.CreateMapper(); - // Set up Hangfire to use in-memory storage for testing GlobalConfiguration.Configuration.UseInMemoryStorage(); - - _unitOfWork = new UnitOfWork(_context, mapper, null); } @@ -66,29 +69,43 @@ public abstract class AbstractDbTest private async Task SeedDb() { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); + try + { + await _context.Database.EnsureCreatedAsync(); + var filesystem = CreateFileSystem(); - await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); - setting.Value = BookmarkDirectory; + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); + setting.Value = BookmarkDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync(); - setting.Value = "10"; + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync(); + setting.Value = "10"; - _context.ServerSetting.Update(setting); + _context.ServerSetting.Update(setting); - _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) - .Build()); - return await _context.SaveChangesAsync() > 0; + + _context.Library.Add(new LibraryBuilder("Manga") + .WithFolderPath(new FolderPathBuilder(DataDirectory).Build()) + .Build()); + + await _context.SaveChangesAsync(); + + await Seed.SeedMetadataSettings(_context); + + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[SeedDb] Error: {ex.Message}"); + return false; + } } protected abstract Task ResetDb(); @@ -99,6 +116,7 @@ public abstract class AbstractDbTest fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); fileSystem.AddDirectory("C:/kavita/config/"); fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CacheLongDirectory); fileSystem.AddDirectory(CoverImageDirectory); fileSystem.AddDirectory(BackupDirectory); fileSystem.AddDirectory(BookmarkDirectory); @@ -109,4 +127,10 @@ public abstract class AbstractDbTest return fileSystem; } + + public void Dispose() + { + _context.Dispose(); + _connection.Dispose(); + } } diff --git a/API.Tests/Extensions/SeriesFilterTests.cs b/API.Tests/Extensions/SeriesFilterTests.cs index 7d88ff4fe..8041c9930 100644 --- a/API.Tests/Extensions/SeriesFilterTests.cs +++ b/API.Tests/Extensions/SeriesFilterTests.cs @@ -932,7 +932,8 @@ public class SeriesFilterTests : AbstractDbTest var seriesService = new SeriesService(_unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For(), Substitute.For()); + Substitute.For(), Substitute.For() + , Substitute.For()); // Select 0 Rating var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 385b63f51..cf92ea6ec 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -56,7 +56,7 @@ public class SeriesServiceTests : AbstractDbTest _seriesService = new SeriesService(_unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For(), locService); + Substitute.For(), locService, Substitute.For()); } #region Setup diff --git a/API/API.csproj b/API/API.csproj index 2ca86b600..2e1607c54 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -197,6 +197,7 @@ Always + diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 34b81c20b..fbf0dea89 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -81,7 +81,8 @@ public class LibraryController : BaseApiController .WithIncludeInDashboard(dto.IncludeInDashboard) .WithManageCollections(dto.ManageCollections) .WithManageReadingLists(dto.ManageReadingLists) - .WIthAllowScrobbling(dto.AllowScrobbling) + .WithAllowScrobbling(dto.AllowScrobbling) + .WithAllowMetadataMatching(dto.AllowMetadataMatching) .Build(); library.LibraryFileTypes = dto.FileGroupTypes diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index f72d36a0f..62d9e350e 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -631,13 +631,13 @@ public class SeriesController : BaseApiController /// /// This will perform the fix match /// - /// + /// /// /// [HttpPost("update-match")] - public async Task UpdateSeriesMatch(ExternalSeriesDetailDto dto, [FromQuery] int seriesId) + public async Task UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int aniListId) { - await _externalMetadataService.FixSeriesMatch(seriesId, dto); + await _externalMetadataService.FixSeriesMatch(seriesId, aniListId); return Ok(); } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 5bca31c95..1e7a67052 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -5,6 +5,7 @@ using System.Net; using System.Threading.Tasks; using API.Data; using API.DTOs.Email; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; @@ -534,4 +535,73 @@ public class SettingsController : BaseApiController if (string.IsNullOrEmpty(user?.Email)) return BadRequest("Your account has no email on record. Cannot email."); return Ok(await _emailService.SendTestEmail(user!.Email)); } + + /// + /// Get the metadata settings for Kavita+ users. + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("metadata-settings")] + public async Task> GetMetadataSettings() + { + return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto()); + + } + + /// + /// Update the metadata settings for Kavita+ users + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("metadata-settings")] + public async Task> UpdateMetadataSettings(MetadataSettingsDto dto) + { + var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + existingMetadataSetting.Enabled = dto.Enabled; + existingMetadataSetting.EnableSummary = dto.EnableSummary; + existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus; + existingMetadataSetting.EnableRelationships = dto.EnableRelationships; + existingMetadataSetting.EnablePeople = dto.EnablePeople; + existingMetadataSetting.EnableStartDate = dto.EnableStartDate; + existingMetadataSetting.EnableGenres = dto.EnableGenres; + existingMetadataSetting.EnableTags = dto.EnableTags; + existingMetadataSetting.PersonRoles = dto.PersonRoles; + existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming; + + existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? []; + + existingMetadataSetting.Blacklist = dto.Blacklist.DistinctBy(d => d.ToNormalized()).ToList() ?? []; + existingMetadataSetting.Whitelist = dto.Whitelist.DistinctBy(d => d.ToNormalized()).ToList() ?? []; + + // Handle Field Mappings + if (dto.FieldMappings != null) + { + // Clear existing mappings + existingMetadataSetting.FieldMappings ??= []; + _unitOfWork.SettingsRepository.RemoveRange(existingMetadataSetting.FieldMappings); + + existingMetadataSetting.FieldMappings.Clear(); + + + // Add new mappings + foreach (var mappingDto in dto.FieldMappings) + { + existingMetadataSetting.FieldMappings.Add(new MetadataFieldMapping + { + SourceType = mappingDto.SourceType, + DestinationType = mappingDto.DestinationType, + SourceValue = mappingDto.SourceValue, + DestinationValue = mappingDto.DestinationValue, + ExcludeFromSource = mappingDto.ExcludeFromSource + }); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + + // Return updated settings + return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto()); + } } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 6fee371bb..a119ad1f7 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -8,6 +8,7 @@ using API.DTOs.Uploads; using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Tasks.Metadata; using API.SignalR; using Flurl.Http; using Microsoft.AspNetCore.Authorization; @@ -31,11 +32,12 @@ public class UploadController : BaseApiController private readonly IEventHub _eventHub; private readonly IReadingListService _readingListService; private readonly ILocalizationService _localizationService; + private readonly ICoverDbService _coverDbService; /// public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService, - ILocalizationService localizationService) + ILocalizationService localizationService, ICoverDbService coverDbService) { _unitOfWork = unitOfWork; _imageService = imageService; @@ -45,6 +47,7 @@ public class UploadController : BaseApiController _eventHub = eventHub; _readingListService = readingListService; _localizationService = localizationService; + _coverDbService = coverDbService; } /// @@ -495,34 +498,8 @@ public class UploadController : BaseApiController var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id); if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); - if (!string.IsNullOrEmpty(uploadFileDto.Url)) - { - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetPersonFormat(uploadFileDto.Id)}"); - - if (!string.IsNullOrEmpty(filePath)) - { - person.CoverImage = filePath; - person.CoverImageLocked = true; - _imageService.UpdateColorScape(person); - _unitOfWork.PersonRepository.Update(person); - } - } - else - { - person.CoverImage = string.Empty; - person.CoverImageLocked = false; - _imageService.UpdateColorScape(person); - _unitOfWork.PersonRepository.Update(person); - } - - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false); - return Ok(); - } - + await _coverDbService.SetPersonCoverImage(person, uploadFileDto.Url, true); + return Ok(); } catch (Exception e) { diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index e290b3b9f..b5d7826c1 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -135,6 +135,14 @@ public class UsersController : BaseApiController existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode; existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode; + if (await _licenseService.HasActiveLicense()) + { + existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled; + existingPreferences.WantToReadSync = preferencesDto.WantToReadSync; + } + + + if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id) { var theme = await _unitOfWork.SiteThemeRepository.GetTheme(preferencesDto.Theme!.Id); @@ -147,6 +155,7 @@ public class UsersController : BaseApiController existingPreferences.Locale = preferencesDto.Locale; } + _unitOfWork.UserRepository.Update(existingPreferences); if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-pref")); diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs index a00896a03..f67fb1f4c 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -9,6 +10,7 @@ internal class SeriesDetailPlusApiDto public IEnumerable Recommendations { get; set; } public IEnumerable Reviews { get; set; } public IEnumerable Ratings { get; set; } + public ExternalSeriesDetailDto? Series { get; set; } public int? AniListId { get; set; } public long? MalId { get; set; } } diff --git a/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs similarity index 60% rename from API/DTOs/Recommendation/ExternalSeriesDetailDto.cs rename to API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs index efed70ba3..56c2e0274 100644 --- a/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs @@ -1,10 +1,15 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Scrobbling; using API.Services.Plus; namespace API.DTOs.Recommendation; #nullable enable +/// +/// This is AniListSeries +/// public class ExternalSeriesDetailDto { public string Name { get; set; } @@ -18,7 +23,15 @@ public class ExternalSeriesDetailDto public IList Staff { get; set; } public IList Tags { get; set; } public string? Summary { get; set; } - public int? VolumeCount { get; set; } - public int? ChapterCount { get; set; } public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; + + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int AverageScore { get; set; } + public int Chapters { get; set; } + public int Volumes { get; set; } + public IList? Relations { get; set; } + public IList? Characters { get; set; } + + } diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs new file mode 100644 index 000000000..796cfeb1a --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs @@ -0,0 +1,22 @@ +using API.Entities.Enums; + +namespace API.DTOs.KavitaPlus.Metadata; + +public class MetadataFieldMappingDto +{ + public int Id { get; set; } + public MetadataFieldType SourceType { get; set; } + public MetadataFieldType DestinationType { get; set; } + /// + /// The string in the source + /// + public string SourceValue { get; set; } + /// + /// Write the string as this in the Destination (can also just be the Source) + /// + public string DestinationValue { get; set; } + /// + /// If true, the tag will be Moved over vs Copied over + /// + public bool ExcludeFromSource { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs new file mode 100644 index 000000000..3dababa38 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; + +namespace API.DTOs.KavitaPlus.Metadata; + +public class MetadataSettingsDto +{ + /// + /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed + /// + public bool Enabled { get; set; } + + /// + /// Allow the Summary to be written + /// + public bool EnableSummary { get; set; } + /// + /// Allow Publication status to be derived and updated + /// + public bool EnablePublicationStatus { get; set; } + /// + /// Allow Relationships between series to be set + /// + public bool EnableRelationships { get; set; } + /// + /// Allow People to be created (including downloading images) + /// + public bool EnablePeople { get; set; } + /// + /// Allow Start date to be set within the Series + /// + public bool EnableStartDate { get; set; } + /// + /// Allow setting the Localized name + /// + public bool EnableLocalizedName { get; set; } + + // Need to handle the Genre/tags stuff + public bool EnableGenres { get; set; } = true; + public bool EnableTags { get; set; } = true; + + /// + /// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names. + /// + public bool FirstLastPeopleNaming { get; set; } + + /// + /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. + /// + public Dictionary AgeRatingMappings { get; set; } + + /// + /// A list of rules that allow mapping a genre/tag to another genre/tag + /// + public List FieldMappings { get; set; } + + /// + /// Do not allow any Genre/Tag in this list to be written to Kavita + /// + public List Blacklist { get; set; } + /// + /// Only allow these Tags to be written to Kavita + /// + public List Whitelist { get; set; } + /// + /// Which Roles to allow metadata downloading for + /// + public List PersonRoles { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs new file mode 100644 index 000000000..0f3fdb71e --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.KavitaPlus.Metadata; + +public class SeriesCharacter +{ + public string Name { get; set; } + public required string Description { get; set; } + public required string Url { get; set; } + public string? ImageUrl { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs new file mode 100644 index 000000000..bd42e73a1 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs @@ -0,0 +1,24 @@ +using API.DTOs.Scrobbling; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Services.Plus; + +namespace API.DTOs.KavitaPlus.Metadata; + +public class ALMediaTitle +{ + public string? EnglishTitle { get; set; } + public string RomajiTitle { get; set; } + public string NativeTitle { get; set; } + public string PreferredTitle { get; set; } +} + +public class SeriesRelationship +{ + public int AniListId { get; set; } + public int? MalId { get; set; } + public ALMediaTitle SeriesName { get; set; } + public RelationKind Relation { get; set; } + public ScrobbleProvider Provider { get; set; } + public PlusMediaFormat PlusMediaFormat { get; set; } = PlusMediaFormat.Manga; +} diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index c8c85063e..18dea9434 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -61,4 +61,10 @@ public class LibraryDto /// A set of globs that will exclude matching content from being scanned /// public ICollection ExcludePatterns { get; set; } + /// + /// Allow any series within this Library to download metadata. + /// + /// This does not exclude the library from being linked to wrt Series Relationships + /// Requires a valid LicenseKey + public bool AllowMetadataMatching { get; set; } = true; } diff --git a/API/DTOs/Recommendation/SeriesStaffDto.cs b/API/DTOs/Recommendation/SeriesStaffDto.cs index 0c1e9759d..e4c6f6423 100644 --- a/API/DTOs/Recommendation/SeriesStaffDto.cs +++ b/API/DTOs/Recommendation/SeriesStaffDto.cs @@ -4,6 +4,8 @@ public class SeriesStaffDto { public required string Name { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } public required string Url { get; set; } public required string Role { get; set; } public string? ImageUrl { get; set; } diff --git a/API/DTOs/Scrobbling/PlusSeriesDto.cs b/API/DTOs/Scrobbling/PlusSeriesDto.cs index 587a21e2c..75e443d2e 100644 --- a/API/DTOs/Scrobbling/PlusSeriesDto.cs +++ b/API/DTOs/Scrobbling/PlusSeriesDto.cs @@ -1,6 +1,9 @@ namespace API.DTOs.Scrobbling; -public record PlusSeriesDto +/// +/// Represents information about a potential Series for Kavita+ +/// +public record PlusSeriesRequestDto { public int? AniListId { get; set; } public long? MalId { get; set; } diff --git a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs index 59ce47bf6..afebbaca4 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs @@ -12,4 +12,5 @@ public class SeriesDetailPlusDto public RecommendationDto? Recommendations { get; set; } public IEnumerable Reviews { get; set; } public IEnumerable? Ratings { get; set; } + public ExternalSeriesDetailDto? Series { get; set; } } diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index 465782bd1..de02f304d 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -26,6 +26,8 @@ public class UpdateLibraryDto public bool ManageReadingLists { get; init; } [Required] public bool AllowScrobbling { get; init; } + [Required] + public bool AllowMetadataMatching { get; init; } /// /// What types of files to allow the scanner to pickup /// diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index d1cddf280..577253ada 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -175,5 +175,12 @@ public class UserPreferencesDto [Required] public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; - + /// + /// Kavita+: Should this account have Scrobbling enabled for AniList + /// + public bool AniListScrobblingEnabled { get; set; } + /// + /// Kavita+: Should this account have Want to Read Sync enabled + /// + public bool WantToReadSync { get; set; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 58f74bee5..b6bd53a15 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using API.Entities; @@ -13,6 +15,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace API.Data; @@ -70,7 +73,8 @@ public sealed class DataContext : IdentityDbContext ChapterPeople { get; set; } = null!; public DbSet SeriesMetadataPeople { get; set; } = null!; public DbSet EmailHistory { get; set; } = null!; - + public DbSet MetadataSettings { get; set; } = null!; + public DbSet MetadataFieldMapping { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { @@ -120,10 +124,19 @@ public sealed class DataContext : IdentityDbContext b.Locale) .IsRequired(true) .HasDefaultValue("en"); + builder.Entity() + .Property(b => b.AniListScrobblingEnabled) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.WantToReadSync) + .HasDefaultValue(true); builder.Entity() .Property(b => b.AllowScrobbling) .HasDefaultValue(true); + builder.Entity() + .Property(b => b.AllowMetadataMatching) + .HasDefaultValue(true); builder.Entity() .Property(b => b.WebLinks) @@ -189,6 +202,31 @@ public sealed class DataContext : IdentityDbContext p.SeriesMetadataPeople) .HasForeignKey(smp => smp.PersonId) .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(x => x.AgeRatingMappings) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) + ); + + // Ensure blacklist is stored as a JSON array + builder.Entity() + .Property(x => x.Blacklist) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) + ); + + // Configure one-to-many relationship + builder.Entity() + .HasMany(x => x.FieldMappings) + .WithOne(x => x.MetadataSettings) + .HasForeignKey(x => x.MetadataSettingsId) + .OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .Property(b => b.Enabled) + .HasDefaultValue(true); } #nullable enable diff --git a/API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs b/API/Data/ManualMigrations/v0.7.11/MigrateLibrariesToHaveAllFileTypes.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs rename to API/Data/ManualMigrations/v0.7.11/MigrateLibrariesToHaveAllFileTypes.cs diff --git a/API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs b/API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs rename to API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs diff --git a/API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs b/API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs diff --git a/API/Data/ManualMigrations/MigrateEmailTemplates.cs b/API/Data/ManualMigrations/v0.7.14/MigrateEmailTemplates.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateEmailTemplates.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateEmailTemplates.cs diff --git a/API/Data/ManualMigrations/MigrateManualHistory.cs b/API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateManualHistory.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs diff --git a/API/Data/ManualMigrations/MigrateVolumeLookupName.cs b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateVolumeLookupName.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs diff --git a/API/Data/ManualMigrations/MigrateVolumeNumber.cs b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeNumber.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateVolumeNumber.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateVolumeNumber.cs diff --git a/API/Data/ManualMigrations/MigrateWantToReadExport.cs b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadExport.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateWantToReadExport.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateWantToReadExport.cs diff --git a/API/Data/ManualMigrations/MigrateWantToReadImport.cs b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadImport.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateWantToReadImport.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateWantToReadImport.cs diff --git a/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs b/API/Data/ManualMigrations/v0.7.9/MigrateUserLibrarySideNavStream.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs rename to API/Data/ManualMigrations/v0.7.9/MigrateUserLibrarySideNavStream.cs diff --git a/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs b/API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs similarity index 100% rename from API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs rename to API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs diff --git a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs b/API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs similarity index 100% rename from API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs rename to API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs diff --git a/API/Data/ManualMigrations/MigrateChapterFields.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateChapterFields.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs diff --git a/API/Data/ManualMigrations/MigrateChapterNumber.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateChapterNumber.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs diff --git a/API/Data/ManualMigrations/MigrateChapterRange.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateChapterRange.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs diff --git a/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs b/API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs diff --git a/API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs b/API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs diff --git a/API/Data/ManualMigrations/MigrateMangaFilePath.cs b/API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateMangaFilePath.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs diff --git a/API/Data/ManualMigrations/MigrateProgressExport.cs b/API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateProgressExport.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs diff --git a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs b/API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs rename to API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs diff --git a/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs b/API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs similarity index 100% rename from API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs rename to API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs diff --git a/API/Data/ManualMigrations/ManualMigrateThemeDescription.cs b/API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs similarity index 100% rename from API/Data/ManualMigrations/ManualMigrateThemeDescription.cs rename to API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs diff --git a/API/Data/ManualMigrations/MigrateInitialInstallData.cs b/API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateInitialInstallData.cs rename to API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs diff --git a/API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs b/API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs rename to API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs diff --git a/API/Data/ManualMigrations/ManualMigrateEncodeSettings.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs similarity index 100% rename from API/Data/ManualMigrations/ManualMigrateEncodeSettings.cs rename to API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs diff --git a/API/Data/ManualMigrations/ManualMigrateRemovePeople.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs similarity index 100% rename from API/Data/ManualMigrations/ManualMigrateRemovePeople.cs rename to API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs diff --git a/API/Data/ManualMigrations/ManualMigrateUnscrobbleBookLibraries.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs similarity index 100% rename from API/Data/ManualMigrations/ManualMigrateUnscrobbleBookLibraries.cs rename to API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs diff --git a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs b/API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs rename to API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs diff --git a/API/Data/ManualMigrations/ManualMigrateBlacklistTableToSeries.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs similarity index 100% rename from API/Data/ManualMigrations/ManualMigrateBlacklistTableToSeries.cs rename to API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs new file mode 100644 index 000000000..c6516acc9 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Entities.Metadata; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - Migrating Kavita+ Series that are Blacklisted but have valid ExternalSeries row +/// +public static class ManualMigrateInvalidBlacklistSeries +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateInvalidBlacklistSeries")) + { + return; + } + + logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var blacklistedSeries = await context.Series + .Include(s => s.ExternalSeriesMetadata) + .Where(s => s.IsBlacklisted && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue) + .ToListAsync(); + foreach (var series in blacklistedSeries) + { + series.IsBlacklisted = false; + context.Series.Entry(series).State = EntityState.Modified; + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateInvalidBlacklistSeries", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs new file mode 100644 index 000000000..835510a1e --- /dev/null +++ b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs @@ -0,0 +1,3382 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250202163454_KavitaPlusUserAndMetadataSettings")] + partial class KavitaPlusUserAndMetadataSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.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("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("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("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + 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("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("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("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("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("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.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("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.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("API.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("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.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("API.Entities.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + 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.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.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("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + 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("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("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("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"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("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("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + 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("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("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("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("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs new file mode 100644 index 000000000..b23d7896b --- /dev/null +++ b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs @@ -0,0 +1,112 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class KavitaPlusUserAndMetadataSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowMetadataMatching", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "AniListScrobblingEnabled", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "WantToReadSync", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.CreateTable( + name: "MetadataSettings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + EnableSummary = table.Column(type: "INTEGER", nullable: false), + EnablePublicationStatus = table.Column(type: "INTEGER", nullable: false), + EnableRelationships = table.Column(type: "INTEGER", nullable: false), + EnablePeople = table.Column(type: "INTEGER", nullable: false), + EnableStartDate = table.Column(type: "INTEGER", nullable: false), + EnableLocalizedName = table.Column(type: "INTEGER", nullable: false), + EnableGenres = table.Column(type: "INTEGER", nullable: false), + EnableTags = table.Column(type: "INTEGER", nullable: false), + FirstLastPeopleNaming = table.Column(type: "INTEGER", nullable: false), + AgeRatingMappings = table.Column(type: "TEXT", nullable: true), + Blacklist = table.Column(type: "TEXT", nullable: true), + Whitelist = table.Column(type: "TEXT", nullable: true), + PersonRoles = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MetadataSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MetadataFieldMapping", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SourceType = table.Column(type: "INTEGER", nullable: false), + DestinationType = table.Column(type: "INTEGER", nullable: false), + SourceValue = table.Column(type: "TEXT", nullable: true), + DestinationValue = table.Column(type: "TEXT", nullable: true), + ExcludeFromSource = table.Column(type: "INTEGER", nullable: false), + MetadataSettingsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MetadataFieldMapping", x => x.Id); + table.ForeignKey( + name: "FK_MetadataFieldMapping_MetadataSettings_MetadataSettingsId", + column: x => x.MetadataSettingsId, + principalTable: "MetadataSettings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MetadataFieldMapping_MetadataSettingsId", + table: "MetadataFieldMapping", + column: "MetadataSettingsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MetadataFieldMapping"); + + migrationBuilder.DropTable( + name: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "AllowMetadataMatching", + table: "Library"); + + migrationBuilder.DropColumn( + name: "AniListScrobblingEnabled", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "WantToReadSync", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index cf8fafc2d..d969df273 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -353,6 +353,11 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("AppUserId") .HasColumnType("INTEGER"); @@ -460,6 +465,11 @@ namespace API.Data.Migrations b.Property("ThemeId") .HasColumnType("INTEGER"); + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.HasKey("Id"); b.HasIndex("AppUserId") @@ -1093,12 +1103,37 @@ namespace API.Data.Migrations b.ToTable("Genre"); }); + modelBuilder.Entity("API.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("API.Entities.Library", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("AllowScrobbling") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -1247,26 +1282,6 @@ namespace API.Data.Migrations b.ToTable("MangaFile"); }); - modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Name") - .HasColumnType("TEXT"); - - b.Property("ProductVersion") - .HasColumnType("TEXT"); - - b.Property("RanAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("ManualMigrationHistory"); - }); - modelBuilder.Entity("API.Entities.MediaError", b => { b.Property("Id") @@ -1594,6 +1609,92 @@ namespace API.Data.Migrations b.ToTable("SeriesRelation"); }); + modelBuilder.Entity("API.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("API.Entities.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + 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.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + modelBuilder.Entity("API.Entities.Person", b => { b.Property("Id") @@ -2824,6 +2925,17 @@ namespace API.Data.Migrations b.Navigation("TargetSeries"); }); + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -3223,6 +3335,11 @@ namespace API.Data.Migrations b.Navigation("People"); }); + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + modelBuilder.Entity("API.Entities.Person", b => { b.Navigation("ChapterPeople"); diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index ee911ccba..aaab361db 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -43,6 +43,7 @@ public interface IPersonRepository Task> GetSeriesKnownFor(int personId); Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); Task> GetPeopleByNames(List normalizedNames); + Task GetPersonByAniListId(int aniListId); } public class PersonRepository : IPersonRepository @@ -263,6 +264,13 @@ public class PersonRepository : IPersonRepository .ToListAsync(); } + public async Task GetPersonByAniListId(int aniListId) + { + return await _context.Person + .Where(p => p.AniListId == aniListId) + .FirstOrDefaultAsync(); + } + public async Task> GetAllPeople() { return await _context.Person diff --git a/API/Data/Repositories/ScrobbleEventRepository.cs b/API/Data/Repositories/ScrobbleEventRepository.cs index 848e0ca38..fdbe84e67 100644 --- a/API/Data/Repositories/ScrobbleEventRepository.cs +++ b/API/Data/Repositories/ScrobbleEventRepository.cs @@ -79,6 +79,7 @@ public class ScrobbleRepository : IScrobbleRepository .Include(s => s.Series) .ThenInclude(s => s.Metadata) .Include(s => s.AppUser) + .ThenInclude(u => u.UserPreferences) .Where(s => s.ScrobbleEventType == type) .Where(s => s.IsProcessed == isProcessed) .AsSplitQuery() diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 19d2a1337..479b5cd19 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -146,7 +146,7 @@ public interface ISeriesRepository Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); - Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId); + Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None); public Task> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format); Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); @@ -164,7 +164,7 @@ public interface ISeriesRepository Task RemoveFromOnDeck(int seriesId, int userId); Task ClearOnDeckRemoval(int seriesId, int userId); Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None); - Task GetPlusSeriesDto(int seriesId); + Task GetPlusSeriesDto(int seriesId); Task GetCountAsync(); Task MatchSeries(ExternalSeriesDetailDto externalSeries); } @@ -699,17 +699,16 @@ public class SeriesRepository : ISeriesRepository var retSeries = query .ProjectTo(_mapper.ConfigurationProvider) - //.AsSplitQuery() .AsNoTracking(); return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); } - public async Task GetPlusSeriesDto(int seriesId) + public async Task GetPlusSeriesDto(int seriesId) { return await _context.Series .Where(s => s.Id == seriesId) - .Select(series => new PlusSeriesDto() + .Select(series => new PlusSeriesRequestDto() { MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), SeriesName = series.Name, @@ -1725,24 +1724,36 @@ public class SeriesRepository : ISeriesRepository #nullable enable } - public async Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId) + public async Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) { var libraryIds = GetLibraryIdsForUser(userId); var normalizedSeries = seriesName.ToNormalized(); var normalizedLocalized = localizedName.ToNormalized(); - return await _context.Series + var query = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) - .Where(s => formats.Contains(s.Format)) - .Where(s => + .Where(s => formats.Contains(s.Format)); + + if (aniListId.HasValue && aniListId.Value > 0) + { + // If AniList ID is provided, override name checks + query = query.Where(s => s.ExternalSeriesMetadata.AniListId == aniListId.Value); + } + else + { + // Otherwise, use name checks + query = query.Where(s => s.NormalizedName.Equals(normalizedSeries) || s.NormalizedName.Equals(normalizedLocalized) - || s.NormalizedLocalizedName.Equals(normalizedSeries) || (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized)) - || (s.OriginalName != null && s.OriginalName.Equals(seriesName)) - ) + ); + } + + return await query + .Includes(includes) .FirstOrDefaultAsync(); } diff --git a/API/Data/Repositories/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs index 6d67b36b5..4ffe59a00 100644 --- a/API/Data/Repositories/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -1,12 +1,14 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; using AutoMapper; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; @@ -14,11 +16,15 @@ namespace API.Data.Repositories; public interface ISettingsRepository { void Update(ServerSetting settings); + void Update(MetadataSettings settings); + void RemoveRange(List fieldMappings); Task GetSettingsDtoAsync(); Task GetSettingAsync(ServerSettingKey key); Task> GetSettingsAsync(); void Remove(ServerSetting setting); Task GetExternalSeriesMetadata(int seriesId); + Task GetMetadataSettings(); + Task GetMetadataSettingDto(); } public class SettingsRepository : ISettingsRepository { @@ -36,6 +42,16 @@ public class SettingsRepository : ISettingsRepository _context.Entry(settings).State = EntityState.Modified; } + public void Update(MetadataSettings settings) + { + _context.Entry(settings).State = EntityState.Modified; + } + + public void RemoveRange(List fieldMappings) + { + _context.MetadataFieldMapping.RemoveRange(fieldMappings); + } + public void Remove(ServerSetting setting) { _context.Remove(setting); @@ -48,6 +64,21 @@ public class SettingsRepository : ISettingsRepository .FirstOrDefaultAsync(); } + public async Task GetMetadataSettings() + { + return await _context.MetadataSettings + .Include(m => m.FieldMappings) + .FirstAsync(); + } + + public async Task GetMetadataSettingDto() + { + return await _context.MetadataSettings + .Include(m => m.FieldMappings) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstAsync(); + } + public async Task GetSettingsDtoAsync() { var settings = await _context.ServerSetting diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 85971558c..bd54f39f6 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -262,12 +262,11 @@ public static class Seed new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"}, new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()}, new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString()}, - }.ToArray()); foreach (var defaultSetting in DefaultSettings) { - var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); + var existing = await context.ServerSetting.FirstOrDefaultAsync(s => s.Key == defaultSetting.Key); if (existing == null) { await context.ServerSetting.AddAsync(defaultSetting); @@ -291,6 +290,35 @@ public static class Seed } + public static async Task SeedMetadataSettings(DataContext context) + { + await context.Database.EnsureCreatedAsync(); + + var existing = await context.MetadataSettings.FirstOrDefaultAsync(); + if (existing == null) + { + existing = new MetadataSettings() + { + Enabled = true, + EnablePeople = true, + EnableRelationships = true, + EnableSummary = true, + EnablePublicationStatus = true, + EnableStartDate = true, + EnableTags = false, + EnableGenres = true, + EnableLocalizedName = false, + FirstLastPeopleNaming = false, + PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character] + }; + await context.MetadataSettings.AddAsync(existing); + } + + + await context.SaveChangesAsync(); + + } + public static async Task SeedUserApiKeys(DataContext context) { await context.Database.EnsureCreatedAsync(); diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 006d9037c..2489c6688 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -160,7 +160,17 @@ public class AppUserPreferences /// UI Site Global Setting: The language locale that should be used for the user /// public string Locale { get; set; } + #endregion + #region KavitaPlus + /// + /// Should this account have Scrobbling enabled for AniList + /// + public bool AniListScrobblingEnabled { get; set; } + /// + /// Should this account have Want to Read Sync enabled + /// + public bool WantToReadSync { get; set; } #endregion public AppUser AppUser { get; set; } = null!; diff --git a/API/Entities/Enums/MetadataFieldType.cs b/API/Entities/Enums/MetadataFieldType.cs new file mode 100644 index 000000000..0052b6599 --- /dev/null +++ b/API/Entities/Enums/MetadataFieldType.cs @@ -0,0 +1,7 @@ +namespace API.Entities.Enums; + +public enum MetadataFieldType +{ + Genre = 0, + Tag = 1, +} diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index 097c382d5..abab81378 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -40,8 +40,14 @@ public class Library : IEntityDate, IHasCoverImage /// /// Should this library allow Scrobble events to emit from it /// - /// Scrobbling requires a valid LicenseKey + /// Requires a valid LicenseKey public bool AllowScrobbling { get; set; } = true; + /// + /// Allow any series within this Library to download metadata. + /// + /// This does not exclude the library from being linked to wrt Series Relationships + /// Requires a valid LicenseKey + public bool AllowMetadataMatching { get; set; } = true; public DateTime Created { get; set; } diff --git a/API/Entities/MetadataMatching/MetadataFieldMapping.cs b/API/Entities/MetadataMatching/MetadataFieldMapping.cs new file mode 100644 index 000000000..309135f1d --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataFieldMapping.cs @@ -0,0 +1,25 @@ +using API.Entities.Enums; + +namespace API.Entities; + +public class MetadataFieldMapping +{ + public int Id { get; set; } + public MetadataFieldType SourceType { get; set; } + public MetadataFieldType DestinationType { get; set; } + /// + /// The string in the source + /// + public string SourceValue { get; set; } + /// + /// Write the string as this in the Destination (can also just be the Source) + /// + public string DestinationValue { get; set; } + /// + /// If true, the tag will be Moved over vs Copied over + /// + public bool ExcludeFromSource { get; set; } + + public int MetadataSettingsId { get; set; } + public virtual MetadataSettings MetadataSettings { get; set; } +} diff --git a/API/Entities/MetadataMatching/MetadataSettings.cs b/API/Entities/MetadataMatching/MetadataSettings.cs new file mode 100644 index 000000000..76de505c6 --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataSettings.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.Entities; + +/// +/// Handles the metadata settings for Kavita+ +/// +public class MetadataSettings +{ + public int Id { get; set; } + /// + /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed + /// + public bool Enabled { get; set; } + + /// + /// Allow the Summary to be written + /// + public bool EnableSummary { get; set; } + /// + /// Allow Publication status to be derived and updated + /// + public bool EnablePublicationStatus { get; set; } + /// + /// Allow Relationships between series to be set + /// + public bool EnableRelationships { get; set; } + /// + /// Allow People to be created (including downloading images) + /// + public bool EnablePeople { get; set; } + /// + /// Allow Start date to be set within the Series + /// + public bool EnableStartDate { get; set; } + /// + /// Allow setting the Localized name + /// + public bool EnableLocalizedName { get; set; } + + // Need to handle the Genre/tags stuff + public bool EnableGenres { get; set; } = true; + public bool EnableTags { get; set; } = true; + + /// + /// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names. + /// + public bool FirstLastPeopleNaming { get; set; } + + /// + /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. + /// + public Dictionary AgeRatingMappings { get; set; } + + /// + /// A list of rules that allow mapping a genre/tag to another genre/tag + /// + public List FieldMappings { get; set; } + + /// + /// Do not allow any Genre/Tag in this list to be written to Kavita + /// + public List Blacklist { get; set; } + + /// + /// Only allow these Tags to be written to Kavita + /// + public List Whitelist { get; set; } + + /// + /// Which Roles to allow metadata downloading for + /// + public List PersonRoles { get; set; } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index e1155a3e7..d775c7cd5 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -12,6 +12,7 @@ using API.SignalR.Presence; using Kavita.Common; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -113,6 +114,8 @@ public static class ApplicationServiceExtensions }); options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warnings => + warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); }); } } diff --git a/API/Extensions/PlusMediaFormatExtensions.cs b/API/Extensions/PlusMediaFormatExtensions.cs index 74d3fe531..bb2b8c426 100644 --- a/API/Extensions/PlusMediaFormatExtensions.cs +++ b/API/Extensions/PlusMediaFormatExtensions.cs @@ -33,18 +33,22 @@ public static class PlusMediaFormatExtensions }; } - public static IList GetMangaFormats(this PlusMediaFormat? mediaFormat) { - if (mediaFormat == null) return [MangaFormat.Archive]; + return mediaFormat.HasValue ? mediaFormat.Value.GetMangaFormats() : [MangaFormat.Archive]; + } + + public static IList GetMangaFormats(this PlusMediaFormat mediaFormat) + { return mediaFormat switch { PlusMediaFormat.Manga => [MangaFormat.Archive, MangaFormat.Image], PlusMediaFormat.Comic => [MangaFormat.Archive], PlusMediaFormat.LightNovel => [MangaFormat.Epub, MangaFormat.Pdf], PlusMediaFormat.Book => [MangaFormat.Epub, MangaFormat.Pdf], - PlusMediaFormat.Unknown => [MangaFormat.Archive], _ => [MangaFormat.Archive] }; } + + } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index fc2b9b059..9f8ed71a6 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -12,6 +12,7 @@ using API.DTOs.Email; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.KavitaPlus.Manage; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.MediaErrors; using API.DTOs.Metadata; using API.DTOs.Progress; @@ -359,5 +360,11 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.VolumeTitle, opt => opt.MapFrom(src => src.Volume.Name)) .ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId)) .ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type)); + + CreateMap() + .ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List())) + .ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List())); + CreateMap(); + } } diff --git a/API/Helpers/Builders/LibraryBuilder.cs b/API/Helpers/Builders/LibraryBuilder.cs index 5550cfd51..30e6136a5 100644 --- a/API/Helpers/Builders/LibraryBuilder.cs +++ b/API/Helpers/Builders/LibraryBuilder.cs @@ -104,7 +104,13 @@ public class LibraryBuilder : IEntityBuilder return this; } - public LibraryBuilder WIthAllowScrobbling(bool allowScrobbling) + public LibraryBuilder WithAllowMetadataMatching(bool allow) + { + _library.AllowMetadataMatching = allow; + return this; + } + + public LibraryBuilder WithAllowScrobbling(bool allowScrobbling) { _library.AllowScrobbling = allowScrobbling; return this; diff --git a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs index 9ef9ad115..3da217b9f 100644 --- a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs +++ b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs @@ -7,10 +7,10 @@ using API.Services.Plus; namespace API.Helpers.Builders; -public class PlusSeriesDtoBuilder : IEntityBuilder +public class PlusSeriesDtoBuilder : IEntityBuilder { - private readonly PlusSeriesDto _seriesDto; - public PlusSeriesDto Build() => _seriesDto; + private readonly PlusSeriesRequestDto _seriesRequestDto; + public PlusSeriesRequestDto Build() => _seriesRequestDto; /// /// This must be a FULL Series @@ -18,7 +18,7 @@ public class PlusSeriesDtoBuilder : IEntityBuilder /// public PlusSeriesDtoBuilder(Series series) { - _seriesDto = new PlusSeriesDto() + _seriesRequestDto = new PlusSeriesRequestDto() { MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), SeriesName = series.Name, diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index db56f73fd..1f7ca53d6 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -73,13 +73,19 @@ public static class GenreHelper public static void UpdateGenreList(ICollection? existingGenres, Series series, IReadOnlyCollection newGenres, Action handleAdd, Action onModified) + { + UpdateGenreList(existingGenres.DefaultIfEmpty().Select(t => t.Title).ToList(), series, newGenres, handleAdd, onModified); + } + + public static void UpdateGenreList(ICollection? existingGenres, Series series, + IReadOnlyCollection newGenres, Action handleAdd, Action onModified) { if (existingGenres == null) return; var isModified = false; // Convert tags and existing genres to hash sets for quick lookups by normalized title - var tagSet = new HashSet(existingGenres.Select(t => t.Title.ToNormalized())); + var tagSet = new HashSet(existingGenres.Select(t => t.ToNormalized())); var genreSet = new HashSet(series.Metadata.Genres.Select(g => g.NormalizedTitle)); // Remove tags that are no longer present in the input tags @@ -99,7 +105,7 @@ public static class GenreHelper // Add new tags from the input list foreach (var tagDto in existingGenres) { - var normalizedTitle = tagDto.Title.ToNormalized(); + var normalizedTitle = tagDto.ToNormalized(); if (genreSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres @@ -109,7 +115,7 @@ public static class GenreHelper } else { - handleAdd(new GenreBuilder(tagDto.Title).Build()); // Add new genre if not found + handleAdd(new GenreBuilder(tagDto).Build()); // Add new genre if not found } isModified = true; } diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index cceecc826..009994bc9 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -103,13 +103,18 @@ public static class TagHelper public static void UpdateTagList(ICollection? existingDbTags, Series series, IReadOnlyCollection newTags, Action handleAdd, Action onModified) + { + UpdateTagList(existingDbTags.Select(t => t.Title).ToList(), series, newTags, handleAdd, onModified); + } + + public static void UpdateTagList(ICollection? existingDbTags, Series series, IReadOnlyCollection newTags, Action handleAdd, Action onModified) { if (existingDbTags == null) return; var isModified = false; // Convert tags and existing genres to hash sets for quick lookups by normalized title - var existingTagSet = new HashSet(existingDbTags.Select(t => t.Title.ToNormalized())); + var existingTagSet = new HashSet(existingDbTags.Select(t => t.ToNormalized())); var dbTagSet = new HashSet(series.Metadata.Tags.Select(g => g.NormalizedTitle)); // Remove tags that are no longer present in the input tags @@ -129,7 +134,7 @@ public static class TagHelper // Add new tags from the input list foreach (var tagDto in existingDbTags) { - var normalizedTitle = tagDto.Title.ToNormalized(); + var normalizedTitle = tagDto.ToNormalized(); if (dbTagSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres @@ -139,7 +144,7 @@ public static class TagHelper } else { - handleAdd(new TagBuilder(tagDto.Title).Build()); // Add new genre if not found + handleAdd(new TagBuilder(tagDto).Build()); // Add new genre if not found } isModified = true; } diff --git a/API/Program.cs b/API/Program.cs index 196f09045..fde52a2f3 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -58,7 +58,7 @@ public class Program } Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "http://localhost:5020" : "https://plus.kavitareader.com"; + ? "http://localhost:5020" : "https://plus-next.kavitareader.com"; try { @@ -129,6 +129,7 @@ public class Program await Seed.SeedDefaultStreams(unitOfWork); await Seed.SeedDefaultSideNavStreams(unitOfWork); await Seed.SeedUserApiKeys(context); + await Seed.SeedMetadataSettings(context); } catch (Exception ex) { diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index c1c3ea71f..fc39c426d 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -5,17 +5,10 @@ using System.IO; using System.Linq; using System.Numerics; using System.Threading.Tasks; -using API.Constants; using API.DTOs; -using API.Entities; using API.Entities.Enums; using API.Entities.Interfaces; using API.Extensions; -using EasyCaching.Core; -using Flurl; -using Flurl.Http; -using HtmlAgilityPack; -using Kavita.Common; using Microsoft.Extensions.Logging; using NetVips; using SixLabors.ImageSharp.PixelFormats; @@ -58,6 +51,7 @@ public interface IImageService /// /// string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); + /// /// Converts the passed image to encoding and outputs it in the same directory /// @@ -601,6 +595,7 @@ public class ImageService : IImageService return string.Empty; } + /// /// Returns the name format for a chapter cover image /// diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 798caef9b..fc66e3592 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Collection; using API.DTOs.KavitaPlus.ExternalMetadata; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Metadata.Matching; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; @@ -16,6 +18,8 @@ using API.Entities.Enums; using API.Entities.Metadata; using API.Extensions; using API.Helpers; +using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using AutoMapper; using Flurl.Http; @@ -33,7 +37,6 @@ public interface IExternalMetadataService { Task GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId); Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType); - //Task ForceKavitaPlusRefresh(int seriesId); Task FetchExternalDataTask(); /// /// This is an entry point and provides a level of protection against calling upstream API. Will only allow 100 new @@ -46,7 +49,7 @@ public interface IExternalMetadataService Task> GetStacksForUser(int userId); Task> MatchSeries(MatchSeriesDto dto); - Task FixSeriesMatch(int seriesId, ExternalSeriesDetailDto dto); + Task FixSeriesMatch(int seriesId, int anilistId); Task UpdateSeriesDontMatch(int seriesId, bool dontMatch); } @@ -58,6 +61,7 @@ public class ExternalMetadataService : IExternalMetadataService private readonly ILicenseService _licenseService; private readonly IScrobblingService _scrobblingService; private readonly IEventHub _eventHub; + private readonly ICoverDbService _coverDbService; private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30); public static readonly HashSet NonEligibleLibraryTypes = [LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine]; @@ -71,7 +75,7 @@ public class ExternalMetadataService : IExternalMetadataService private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, - ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub) + ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService) { _unitOfWork = unitOfWork; _logger = logger; @@ -79,6 +83,7 @@ public class ExternalMetadataService : IExternalMetadataService _licenseService = licenseService; _scrobblingService = scrobblingService; _eventHub = eventHub; + _coverDbService = coverDbService; FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } @@ -120,29 +125,6 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count); } - /// - /// Removes from Blacklist and Invalidates the cache - /// - /// - /// - // public async Task ForceKavitaPlusRefresh(int seriesId) - // { - // // TODO: I think we can remove this now - // if (!await _licenseService.HasActiveLicense()) return; - // var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId); - // if (!IsPlusEligible(libraryType)) return; - // - // // Remove from Blacklist if applicable - // var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - // series!.IsBlacklisted = false; - // _unitOfWork.SeriesRepository.Update(series); - // - // var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId); - // if (metadata == null) return; - // - // metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache); - // await _unitOfWork.CommitAsync(); - // } /// /// Fetches data from Kavita+ @@ -165,9 +147,7 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId); // Prefetch SeriesDetail data - await GetSeriesDetailPlus(seriesId, libraryType); - - // TODO: Fetch Series Metadata (Summary, etc) + var metadata = await GetSeriesDetailPlus(seriesId, libraryType); } @@ -266,7 +246,13 @@ public class ExternalMetadataService : IExternalMetadataService return string.Empty; // Return as is if null, empty, or whitespace. } - return summary.Replace("
", string.Empty); + // Remove all variations of
tags (case-insensitive) + summary = Regex.Replace(summary, @"", " ", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // Normalize whitespace (replace multiple spaces with a single space) + summary = Regex.Replace(summary, @"\s+", " ").Trim(); + + return summary; } @@ -329,8 +315,8 @@ public class ExternalMetadataService : IExternalMetadataService /// This will override any sort of matching that was done prior and force it to be what the user Selected ///
/// - /// - public async Task FixSeriesMatch(int seriesId, ExternalSeriesDetailDto dto) + /// + public async Task FixSeriesMatch(int seriesId, int anilistId) { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); if (series == null) return; @@ -341,14 +327,18 @@ public class ExternalMetadataService : IExternalMetadataService _unitOfWork.SeriesRepository.Update(series); // Refetch metadata with a Direct lookup - await FetchExternalMetadataForSeries(seriesId, series.Library.Type, new PlusSeriesDto() + var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type, new PlusSeriesRequestDto() { - SeriesName = dto.Name, - AniListId = dto.AniListId, - MalId = dto.MALId, - MediaFormat = dto.PlusMediaFormat, + AniListId = anilistId, + SeriesName = string.Empty // Required field }); + if (metadata.Series == null) + { + _logger.LogError("Unable to Match {SeriesName} with Kavita+ Series AniList Id: {AniListId}", series.Name, anilistId); + return; + } + // Find all scrobble events and rewrite them to be the correct var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); _unitOfWork.ScrobbleRepository.Remove(events); @@ -356,11 +346,12 @@ public class ExternalMetadataService : IExternalMetadataService // Regenerate all events for the series for all users BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistoryForSeries(seriesId)); - await _eventHub.SendMessageAsync(MessageFactory.Info, - MessageFactory.InfoEvent($"Fix Match: {series.Name}", "Scrobble Events are regenerating with the new match")); + // await _eventHub.SendMessageAsync(MessageFactory.Info, + // MessageFactory.InfoEvent($"Fix Match: {series.Name}", "Scrobble Events are regenerating with the new match")); - _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, dto.Name); + // Name can be null on Series even with a direct match + _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, metadata.Series.Name); } /// @@ -398,15 +389,15 @@ public class ExternalMetadataService : IExternalMetadataService /// /// /// - private async Task FetchExternalMetadataForSeries(int seriesId, LibraryType libraryType, PlusSeriesDto data) + private async Task FetchExternalMetadataForSeries(int seriesId, LibraryType libraryType, PlusSeriesRequestDto data) { - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); if (series == null) return _defaultReturn; try { - _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", data.SeriesName); + _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName); var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") .WithKavitaPlusHeaders(license) @@ -415,7 +406,6 @@ public class ExternalMetadataService : IExternalMetadataService // Clear out existing results - var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series!); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings); @@ -450,13 +440,33 @@ public class ExternalMetadataService : IExternalMetadataService if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value; if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value; + + // If there is metadata and the user has metadata download turned on + var madeMetadataModification = false; + if (result.Series != null && series.Library.AllowMetadataMatching) + { + madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId); + if (madeMetadataModification) + { + _unitOfWork.SeriesRepository.Update(series); + } + } + + await _unitOfWork.CommitAsync(); + if (madeMetadataModification) + { + // Inform the UI of the update + await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(series.LibraryId, series.Id, series.Name), false); + } + return new SeriesDetailPlusDto() { Recommendations = recs, Ratings = result.Ratings, - Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map(r)) + Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map(r)), + Series = result.Series }; } catch (FlurlHttpException ex) @@ -478,6 +488,413 @@ public class ExternalMetadataService : IExternalMetadataService return _defaultReturn; } + /// + /// Given external metadata from Kavita+, write as much as possible to the Kavita series as possible + /// + /// + /// + /// + private async Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId) + { + var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + if (!settings.Enabled) return false; + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Related); + if (series == null) return false; + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(); + + _logger.LogInformation("Writing External metadata to Series {SeriesName}", series.Name); + + var madeModification = false; + + if (!series.Metadata.SummaryLocked && string.IsNullOrEmpty(series.Metadata.Summary) && settings.EnableSummary) + { + series.Metadata.Summary = CleanSummary(externalMetadata.Summary); + madeModification = true; + } + + if (settings.EnableStartDate && externalMetadata.StartDate.HasValue) + { + series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; + madeModification = true; + } + + var processedGenres = new List(); + var processedTags = new List(); + + #region Genres and Tags + + // Process Genres + if (externalMetadata.Genres != null) + { + foreach (var genre in externalMetadata.Genres.Where(g => !settings.Blacklist.Contains(g))) + { + // Apply field mappings + var mappedGenre = ApplyFieldMapping(genre, MetadataFieldType.Genre, settings.FieldMappings); + if (mappedGenre != null) + { + processedGenres.Add(mappedGenre); + } + } + + // Strip blacklisted items from processedGenres + processedGenres = processedGenres.Distinct().Where(g => !settings.Blacklist.Contains(g)).ToList(); + + if (settings.EnableGenres && processedGenres.Count > 0) + { + _logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name); + var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); + series.Metadata.Genres ??= []; + GenreHelper.UpdateGenreList(processedGenres, series, allGenres, genre => + { + series.Metadata.Genres.Add(genre); + madeModification = true; + }, () => series.Metadata.GenresLocked = true); + } + + } + + // Process Tags + if (externalMetadata.Tags != null) + { + foreach (var tag in externalMetadata.Tags.Select(t => t.Name)) + { + // Apply field mappings + var mappedTag = ApplyFieldMapping(tag, MetadataFieldType.Tag, settings.FieldMappings); + if (mappedTag != null) + { + processedTags.Add(mappedTag); + } + } + + // Strip blacklisted items from processedTags + processedTags = processedTags.Distinct() + .Where(g => !settings.Blacklist.Contains(g)) + .Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g)) + .ToList(); + + // Set the tags for the series and ensure they are in the DB + if (settings.EnableTags && processedTags.Count > 0) + { + _logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name); + var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize))) + .ToList(); + series.Metadata.Tags ??= []; + TagHelper.UpdateTagList(processedTags, series, allTags, tag => + { + series.Metadata.Tags.Add(tag); + madeModification = true; + }, () => series.Metadata.TagsLocked = true); + } + } + + #endregion + + #region Age Rating + + // Determine Age Rating + var ageRating = DetermineAgeRating(processedGenres.Concat(processedTags), settings.AgeRatingMappings); + if (!series.Metadata.AgeRatingLocked && series.Metadata.AgeRating <= ageRating) + { + series.Metadata.AgeRating = ageRating; + _unitOfWork.SeriesRepository.Update(series); + madeModification = true; + } + + #endregion + + #region People + + if (settings.EnablePeople) + { + series.Metadata.People ??= new List(); + + // Ensure all people are named correctly + externalMetadata.Staff = externalMetadata.Staff.Select(s => + { + if (settings.FirstLastPeopleNaming) + { + s.Name = s.FirstName + " " + s.LastName; + } + else + { + s.Name = s.LastName + " " + s.FirstName; + } + + return s; + }).ToList(); + + // Roles: Character Design, Story, Art + + var allWriters = externalMetadata.Staff + .Where(s => s.Role is "Story" or "Story & Art") + .ToList(); + + var writers = allWriters + .Select(w => new PersonDto() + { + Name = w.Name, + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = CleanSummary(w.Description), + }).ToList(); + + + // NOTE: PersonRoles can be a hashset + if (!series.Metadata.WriterLocked && writers.Count > 0 && settings.PersonRoles.Contains(PersonRole.Writer)) + { + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork); + + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetCovers(allWriters); + + madeModification = true; + } + + var allArtists = externalMetadata.Staff + .Where(s => s.Role is "Art" or "Story & Art") + .ToList(); + + var artists = allArtists + .Select(w => new PersonDto() + { + Name = w.Name, + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = CleanSummary(w.Description), + }).ToList(); + + if (!series.Metadata.CoverArtistLocked && artists.Count > 0 && settings.PersonRoles.Contains(PersonRole.CoverArtist)) + { + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork); + + // Download the image and save it + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetCovers(allArtists); + + madeModification = true; + } + + if (externalMetadata.Characters != null && settings.PersonRoles.Contains(PersonRole.Character)) + { + var characters = externalMetadata.Characters + .Select(w => new PersonDto() + { + Name = w.Name, + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), + Description = CleanSummary(w.Description), + }).ToList(); + + + if (!series.Metadata.CharacterLocked && characters.Count > 0) + { + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); + + // Download the image and save it + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + foreach (var character in externalMetadata.Characters) + { + var aniListId = ScrobblingService.ExtractId(character.Url, ScrobblingService.AniListCharacterWebsite); + if (aniListId <= 0) continue; + var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId); + if (person != null && !string.IsNullOrEmpty(character.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) + { + await _coverDbService.SetPersonCoverImage(person, character.ImageUrl, false); + } + } + + madeModification = true; + } + } + } + + #endregion + + if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus) + { + var chapters = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes.SelectMany(v => v.Chapters).ToList(); + var wasChanged = DeterminePublicationStatus(series, chapters, externalMetadata); + _unitOfWork.SeriesRepository.Update(series); + madeModification = madeModification || wasChanged; + } + + if (settings.EnableRelationships && externalMetadata.Relations != null && defaultAdmin != null) + { + + foreach (var relation in externalMetadata.Relations) + { + var relatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName( + relation.SeriesName.NativeTitle, + relation.SeriesName.PreferredTitle, + relation.PlusMediaFormat.GetMangaFormats(), + defaultAdmin.Id, + relation.AniListId, + SeriesIncludes.Related); + + // Skip if no related series found or series is the parent + if (relatedSeries == null || relatedSeries.Id == series.Id || relation.Relation == RelationKind.Parent) continue; + + // Check if the relationship already exists + var relationshipExists = series.Relations.Any(r => + r.TargetSeriesId == relatedSeries.Id && r.RelationKind == relation.Relation); + + if (relationshipExists) continue; + + series.Relations.Add(new SeriesRelation + { + RelationKind = relation.Relation, + TargetSeries = relatedSeries, + TargetSeriesId = relatedSeries.Id, + Series = series, + SeriesId = series.Id + }); + + // Handle sequel/prequel: add reverse relationship + if (relation.Relation is RelationKind.Prequel or RelationKind.Sequel) + { + var reverseExists = relatedSeries.Relations.Any(r => + r.TargetSeriesId == series.Id && r.RelationKind == GetReverseRelation(relation.Relation)); + + if (reverseExists) continue; + + relatedSeries.Relations.Add(new SeriesRelation + { + RelationKind = GetReverseRelation(relation.Relation), + TargetSeries = series, + TargetSeriesId = series.Id, + Series = relatedSeries, + SeriesId = relatedSeries.Id + }); + } + + madeModification = true; + } + } + + return madeModification; + } + + private static RelationKind GetReverseRelation(RelationKind relation) + { + return relation switch + { + RelationKind.Prequel => RelationKind.Sequel, + RelationKind.Sequel => RelationKind.Prequel, + _ => relation // For other relationships, no reverse needed + }; + } + + private async Task DownloadAndSetCovers(List people) + { + foreach (var staff in people) + { + var aniListId = ScrobblingService.ExtractId(staff.Url, ScrobblingService.AniListStaffWebsite); + if (aniListId is null or <= 0) continue; + var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value); + if (person != null && !string.IsNullOrEmpty(staff.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) + { + await _coverDbService.SetPersonCoverImage(person, staff.ImageUrl, false); + } + } + } + + private bool DeterminePublicationStatus(Series series, List chapters, ExternalSeriesDetailDto externalMetadata) + { + var madeModification = false; + try + { + // Determine the expected total count based on local metadata + series.Metadata.TotalCount = Math.Max( + chapters.Max(chapter => chapter.TotalCount), + externalMetadata.Volumes > 0 ? externalMetadata.Volumes : externalMetadata.Chapters + ); + + // The actual number of count's defined across all chapter's metadata + series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count); + + var nonSpecialVolumes = series.Volumes + .Where(v => v.MaxNumber.IsNot(Parser.SpecialVolumeNumber)) + .ToList(); + + var maxVolume = (int)(nonSpecialVolumes.Count != 0 ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); + var maxChapter = (int)chapters.Max(c => c.MaxNumber); + + if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1) + { + series.Metadata.MaxCount = 1; + } + else if (series.Metadata.TotalCount <= 1 && chapters.Count == 1 && chapters[0].IsSpecial) + { + series.Metadata.MaxCount = series.Metadata.TotalCount; + } + else if ((maxChapter == Parser.DefaultChapterNumber || maxChapter > series.Metadata.TotalCount) && + maxVolume <= series.Metadata.TotalCount) + { + series.Metadata.MaxCount = maxVolume; + } + else if (maxVolume == series.Metadata.TotalCount) + { + series.Metadata.MaxCount = maxVolume; + } + else + { + series.Metadata.MaxCount = maxChapter; + } + + var status = PublicationStatus.OnGoing; + + var hasExternalCounts = externalMetadata.Volumes > 0 || externalMetadata.Chapters > 0; + + if (hasExternalCounts) + { + status = PublicationStatus.Ended; + + // Check if all volumes/chapters match the total count + if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + { + status = PublicationStatus.Completed; + } + + madeModification = true; + } + + series.Metadata.PublicationStatus = status; + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an issue determining Publication Status"); + series.Metadata.PublicationStatus = PublicationStatus.OnGoing; + } + + return madeModification; + } + + private static string? ApplyFieldMapping(string value, MetadataFieldType sourceType, List mappings) + { + // Find matching mapping + var mapping = mappings + .FirstOrDefault(m => + m.SourceType == sourceType && + m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase)); + + if (mapping == null) return value; + + // If mapping exists, return destination or source value + return mapping.DestinationValue ?? (mapping.ExcludeFromSource ? null : value); + } + + private static AgeRating DetermineAgeRating(IEnumerable values, Dictionary mappings) + { + // Find highest age rating from mappings + return values + .Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown) + .DefaultIfEmpty(AgeRating.Unknown) + .Max(); + } + /// /// Gets from DB or creates a new one with just SeriesId diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 87d3c89ad..dae9ecab0 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -56,7 +56,7 @@ public interface IScrobblingService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ProcessUpdatesSinceLastSync(); Task CreateEventsFromExistingHistory(int userId = 0); - Task CreateEventsFromExistingHistoryForSeries(int seriesId = 0); + Task CreateEventsFromExistingHistoryForSeries(int seriesId); Task ClearEventsForSeries(int userId, int seriesId); } @@ -73,6 +73,9 @@ public class ScrobblingService : IScrobblingService public const string MalWeblinkWebsite = "https://myanimelist.net/manga/"; public const string GoogleBooksWeblinkWebsite = "https://books.google.com/books?id="; public const string MangaDexWeblinkWebsite = "https://mangadex.org/title/"; + public const string AniListStaffWebsite = "https://anilist.co/staff/"; + public const string AniListCharacterWebsite = "https://anilist.co/character/"; + private static readonly IDictionary WeblinkExtractionMap = new Dictionary() { @@ -80,6 +83,8 @@ public class ScrobblingService : IScrobblingService {MalWeblinkWebsite, 0}, {GoogleBooksWeblinkWebsite, 0}, {MangaDexWeblinkWebsite, 0}, + {AniListStaffWebsite, 0}, + {AniListCharacterWebsite, 0}, }; private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90) @@ -314,6 +319,9 @@ public class ScrobblingService : IScrobblingService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + _logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; @@ -365,6 +373,9 @@ public class ScrobblingService : IScrobblingService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + _logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; @@ -419,7 +430,10 @@ public class ScrobblingService : IScrobblingService if (!await _licenseService.HasActiveLicense()) return; var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); - if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); + if (series == null || !series.Library.AllowScrobbling) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; if (await CheckIfCannotScrobble(userId, seriesId, series)) return; _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); @@ -639,55 +653,57 @@ public class ScrobblingService : IScrobblingService } } - public async Task CreateEventsFromExistingHistoryForSeries(int seriesId = 0) + public async Task CreateEventsFromExistingHistoryForSeries(int seriesId) { if (!await _licenseService.HasActiveLicense()) return; - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - if (series == null) return; + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null || !series.Library.AllowScrobbling) return; _logger.LogInformation("Creating Scrobbling events for Series {SeriesName}", series.Name); - var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) - .ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling); - var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()) .Select(u => u.Id); foreach (var uId in userIds) { + // Handle "Want to Read" updates specific to the series var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId); - foreach (var wtr in wantToRead) + foreach (var wtr in wantToRead.Where(wtr => wtr.Id == seriesId)) { - if (!libAllowsScrobbling[wtr.LibraryId]) continue; await ScrobbleWantToReadUpdate(uId, wtr.Id, true); } + // Handle ratings specific to the series var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(uId); - foreach (var rating in ratings) + foreach (var rating in ratings.Where(rating => rating.SeriesId == seriesId)) { - if (!libAllowsScrobbling[rating.Series.LibraryId]) continue; await ScrobbleRatingUpdate(uId, rating.SeriesId, rating.Rating); } - var seriesWithProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, uId, - new UserParams(), new FilterDto() + // Handle progress updates for the specific series + var seriesProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync( + series.LibraryId, + uId, + new UserParams(), + new FilterDto { - ReadStatus = new ReadStatus() + ReadStatus = new ReadStatus { Read = true, InProgress = true, NotRead = false }, - Libraries = libAllowsScrobbling.Keys.Where(k => libAllowsScrobbling[k]).ToList(), + Libraries = new List { series.LibraryId }, SeriesNameQuery = series.Name }); - foreach (var seriesProgress in seriesWithProgress) + foreach (var progress in seriesProgress.Where(progress => progress.Id == seriesId)) { - if (!libAllowsScrobbling[seriesProgress.LibraryId]) continue; - if (seriesProgress.PagesRead <= 0) continue; // Since we only scrobble when things are higher, we can - await ScrobbleReadingUpdate(uId, seriesProgress.Id); + if (progress.PagesRead > 0) + { + await ScrobbleReadingUpdate(uId, progress.Id); + } } } } @@ -784,6 +800,7 @@ public class ScrobblingService : IScrobblingService .Concat(removeWantToRead.Select(r => r.AppUser)) .Concat(ratingEvents.Select(r => r.AppUser)) .Where(user => !string.IsNullOrEmpty(user.AniListAccessToken)) + .Where(user => user.UserPreferences.AniListScrobblingEnabled) // TODO: Add more as we add more support .DistinctBy(u => u.Id) .ToList(); foreach (var user in usersToScrobble) @@ -891,8 +908,9 @@ public class ScrobblingService : IScrobblingService { _logger.LogDebug("Processing Reading Events: {Count} / {Total}", progressCounter, totalProgress); progressCounter++; + // Check if this media item can even be processed for this user - if (!DoesUserHaveProviderAndValid(evt)) + if (!CanProcessScrobbleEvent(evt)) { continue; } @@ -997,7 +1015,7 @@ public class ScrobblingService : IScrobblingService } - private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent) + private static bool CanProcessScrobbleEvent(ScrobbleEvent readEvent) { var userProviders = GetUserProviders(readEvent.AppUser); if (readEvent.Series.Library.Type == LibraryType.Manga && MangaProviders.Intersect(userProviders).Any()) @@ -1052,6 +1070,12 @@ public class ScrobblingService : IScrobblingService if (int.TryParse(value, out var intValue)) return (T)(object)intValue; } + else if (typeof(T) == typeof(int)) + { + if (int.TryParse(value, out var intValue)) + return (T)(object)intValue; + return default; + } else if (typeof(T) == typeof(long?)) { if (long.TryParse(value, out var longValue)) diff --git a/API/Services/Plus/WantToReadSyncService.cs b/API/Services/Plus/WantToReadSyncService.cs index 0ef84ec19..07861710c 100644 --- a/API/Services/Plus/WantToReadSyncService.cs +++ b/API/Services/Plus/WantToReadSyncService.cs @@ -45,8 +45,8 @@ public class WantToReadSyncService : IWantToReadSyncService var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var users = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead); - foreach (var user in users) + var users = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead | AppUserIncludes.UserPreferences); + foreach (var user in users.Where(u => u.UserPreferences.WantToReadSync)) { if (string.IsNullOrEmpty(user.MalUserName) && string.IsNullOrEmpty(user.AniListAccessToken)) continue; diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 7d4a4b95a..3ee151c38 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -14,6 +14,7 @@ using API.DTOs.CollectionTags; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Entities.Metadata; using API.Extensions; using API.Helpers; @@ -44,6 +45,7 @@ public interface ISeriesService bool withHash); Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false); Task GetEstimatedChapterCreationDate(int seriesId, int userId); + } public class SeriesService : ISeriesService @@ -54,6 +56,7 @@ public class SeriesService : ISeriesService private readonly ILogger _logger; private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; + private readonly IImageService _imageService; private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto { @@ -63,7 +66,7 @@ public class SeriesService : ISeriesService }; public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, - ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService) + ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService, IImageService imageService) { _unitOfWork = unitOfWork; _eventHub = eventHub; @@ -71,6 +74,7 @@ public class SeriesService : ISeriesService _logger = logger; _scrobblingService = scrobblingService; _localizationService = localizationService; + _imageService = imageService; } /// @@ -206,73 +210,73 @@ public class SeriesService : ISeriesService // Writers if (!series.Metadata.WriterLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer, _unitOfWork); } // Cover Artists if (!series.Metadata.CoverArtistLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist, _unitOfWork); } // Colorists if (!series.Metadata.ColoristLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist, _unitOfWork); } // Editors if (!series.Metadata.EditorLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor, _unitOfWork); } // Inkers if (!series.Metadata.InkerLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker, _unitOfWork); } // Letterers if (!series.Metadata.LettererLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer, _unitOfWork); } // Pencillers if (!series.Metadata.PencillerLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller, _unitOfWork); } // Publishers if (!series.Metadata.PublisherLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher, _unitOfWork); } // Imprints if (!series.Metadata.ImprintLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint, _unitOfWork); } // Teams if (!series.Metadata.TeamLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team, _unitOfWork); } // Locations if (!series.Metadata.LocationLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location, _unitOfWork); } // Translators if (!series.Metadata.TranslatorLocked) { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator, _unitOfWork); } } @@ -331,8 +335,10 @@ public class SeriesService : ISeriesService /// /// /// - private async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection peopleDtos, PersonRole role) + public static async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection peopleDtos, PersonRole role, IUnitOfWork unitOfWork) { + // TODO: Cleanup this code so we aren't using UnitOfWork like this + // Normalize all names from the DTOs var normalizedNames = peopleDtos .Select(p => Parser.Normalize(p.Name)) @@ -340,7 +346,7 @@ public class SeriesService : ISeriesService .ToList(); // Bulk select people who already exist in the database - var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); + var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); // Use a dictionary for quick lookups var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName).ToDictionary(p => p.NormalizedName, p => p); @@ -353,13 +359,26 @@ public class SeriesService : ISeriesService var normalizedPersonName = Parser.Normalize(personDto.Name); // Check if the person exists in the dictionary - if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out _)) continue; + if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p)) + { + if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId) + { + p.AniListId = personDto.AniListId; + } + continue; // If we ever want to update metadata for existing people, we'd do it here + } // Person doesn't exist, so create a new one var newPerson = new Person { Name = personDto.Name, - NormalizedName = normalizedPersonName + NormalizedName = normalizedPersonName, + AniListId = personDto.AniListId, + Description = personDto.Description, + Asin = personDto.Asin, + CoverImage = personDto.CoverImage, + MalId = personDto.MalId, + HardcoverId = personDto.HardcoverId, }; peopleToAdd.Add(newPerson); @@ -369,7 +388,7 @@ public class SeriesService : ISeriesService // Add any new people to the database in bulk if (peopleToAdd.Count != 0) { - _unitOfWork.PersonRepository.Attach(peopleToAdd); + unitOfWork.PersonRepository.Attach(peopleToAdd); } // Now that we have all the people (new and existing), update the SeriesMetadataPeople diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index 8b1db435a..b3c5a6220 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -4,10 +4,12 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Constants; +using API.Data; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.SignalR; using EasyCaching.Core; using Flurl; using Flurl.Http; @@ -25,6 +27,8 @@ public interface ICoverDbService Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat); + Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url); + Task SetPersonCoverImage(Person person, string url, bool fromBase64 = true); } @@ -34,6 +38,9 @@ public class CoverDbService : ICoverDbService private readonly IDirectoryService _directoryService; private readonly IEasyCachingProviderFactory _cacheFactory; private readonly IHostEnvironment _env; + private readonly IImageService _imageService; + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; private const string NewHost = "https://www.kavitareader.com/CoversDB/"; @@ -57,12 +64,16 @@ public class CoverDbService : ICoverDbService private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(1); public CoverDbService(ILogger logger, IDirectoryService directoryService, - IEasyCachingProviderFactory cacheFactory, IHostEnvironment env) + IEasyCachingProviderFactory cacheFactory, IHostEnvironment env, IImageService imageService, + IUnitOfWork unitOfWork, IEventHub eventHub) { _logger = logger; _directoryService = directoryService; _cacheFactory = cacheFactory; _env = env; + _imageService = imageService; + _unitOfWork = unitOfWork; + _eventHub = eventHub; } public async Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat) @@ -225,36 +236,37 @@ public class CoverDbService : ICoverDbService { throw new KavitaException($"Could not grab person image for {person.Name}"); } + return await DownloadPersonImageAsync(person, encodeFormat, personImageLink); + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PersonName}", person.Name); + } - // Create the destination file path - var filename = ImageService.GetPersonFormat(person.Id) + encodeFormat.GetExtension(); - var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename); + return null; + } - // Ensure if file exists, we delete to overwrite - - - _logger.LogTrace("Fetching publisher image from {Url}", personImageLink.Sanitize()); - // Download the file using Flurl - var personStream = await personImageLink - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - - using var image = Image.NewFromStream(personStream); - switch (encodeFormat) + /// + /// Attempts to download the Person cover image from a Url + /// + /// + /// + /// + /// + /// + /// + public async Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url) + { + try + { + var personImageLink = await GetCoverPersonImagePath(person); + if (string.IsNullOrEmpty(personImageLink)) { - case EncodeFormat.PNG: - image.Pngsave(targetFile); - break; - case EncodeFormat.WEBP: - image.Webpsave(targetFile); - break; - case EncodeFormat.AVIF: - image.Heifsave(targetFile); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + throw new KavitaException($"Could not grab person image for {person.Name}"); } + + var filename = await DownloadImageFromUrl(ImageService.GetPersonFormat(person.Id), encodeFormat, personImageLink); + _logger.LogDebug("Person image for {PersonName} downloaded and saved successfully", person.Name); return filename; @@ -266,6 +278,39 @@ public class CoverDbService : ICoverDbService return null; } + private async Task DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url) + { + // Create the destination file path + var filename = filenameWithoutExtension + encodeFormat.GetExtension(); + var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename); + + // Ensure if file exists, we delete to overwrite + + _logger.LogTrace("Fetching person image from {Url}", url.Sanitize()); + // Download the file using Flurl + var personStream = await url + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + using var image = Image.NewFromStream(personStream); + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(targetFile); + break; + case EncodeFormat.WEBP: + image.Webpsave(targetFile); + break; + case EncodeFormat.AVIF: + image.Heifsave(targetFile); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + + return filename; + } + private async Task GetCoverPersonImagePath(Person person) { var tempFile = Path.Join(_directoryService.LongTermCacheDirectory, "people.yml"); @@ -414,4 +459,49 @@ public class CoverDbService : ICoverDbService return null; } + + public async Task SetPersonCoverImage(Person person, string url, bool fromBase64 = true) + { + if (!string.IsNullOrEmpty(url)) + { + var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64); + + if (!string.IsNullOrEmpty(filePath)) + { + person.CoverImage = filePath; + person.CoverImageLocked = true; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + } + } + else + { + person.CoverImage = string.Empty; + person.CoverImageLocked = false; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false); + } + } + + private async Task CreateThumbnail(string url, string filename, bool fromBase64 = true) + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var encodeFormat = settings.EncodeMediaAs; + var coverImageSize = settings.CoverImageSize; + + if (fromBase64) + { + return _imageService.CreateThumbnailFromBase64(url, + filename, encodeFormat, coverImageSize.GetDimensions().Width); + } + + return await DownloadImageFromUrl(filename, encodeFormat, url); + } } diff --git a/API/Startup.cs b/API/Startup.cs index efddf2fae..5f8c0a30e 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -279,6 +279,7 @@ public class Startup // v0.8.5 await ManualMigrateBlacklistTableToSeries.Migrate(dataContext, logger); + await ManualMigrateInvalidBlacklistSeries.Migrate(dataContext, logger); // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index de717a238..24c096a76 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -465,6 +465,7 @@ "version": "18.2.9", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz", "integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==", + "dev": true, "dependencies": { "@babel/core": "7.25.2", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -492,6 +493,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -506,6 +508,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -4022,7 +4025,8 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -4529,6 +4533,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -4538,6 +4543,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7479,7 +7485,8 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -7750,7 +7757,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { "version": "1.77.6", @@ -7784,6 +7791,7 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -8338,6 +8346,7 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index f834f23b1..87ffb56c4 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -24,6 +24,7 @@ export interface Library { manageCollections: boolean; manageReadingLists: boolean; allowScrobbling: boolean; + allowMetadataMatching: boolean; collapseSeriesRelationships: boolean; libraryFileTypes: Array; excludePatterns: Array; diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index b6e364056..27241ba5e 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -52,6 +52,10 @@ export interface Preferences { collapseSeriesRelationships: boolean; shareReviews: boolean; locale: string; + + // Kavita+ + aniListScrobblingEnabled: boolean; + wantToReadSync: boolean; } export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 0e6729be3..1af422652 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -242,7 +242,7 @@ export class SeriesService { } updateMatch(seriesId: number, series: ExternalSeriesDetail) { - return this.httpClient.post(this.baseUrl + 'series/update-match?seriesId=' + seriesId, series, TextResonse); + return this.httpClient.post(this.baseUrl + 'series/update-match?seriesId=' + seriesId + '&aniListId=' + series.aniListId, {}, TextResonse); } updateDontMatch(seriesId: number, dontMatch: boolean) { diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html index 6d0e47e9c..977e4e548 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html @@ -1,4 +1,5 @@ +
@if (item.series.coverUrl) { @@ -11,7 +12,7 @@ @for(synm of item.series.synonyms; track synm; let last = $last) { {{synm}} @if (!last) { - , + , } }
@@ -23,16 +24,27 @@
-
- {{t('details')}} - @if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) { - {{t('volume-count', {num: item.series.volumeCount})}} - {{t('chapter-count', {num: item.series.chapterCount})}} - } @else { - {{t('releasing')}} - } + @if (isSelected) { +
+ + {{t('updating-metadata-status')}} +
+ } @else { +
+ {{t('details')}} + @if ((item.series.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) { + {{t('volume-count', {num: item.series.volumeCount})}} + {{t('chapter-count', {num: item.series.chapterCount})}} + } @else { + {{t('releasing')}} + } + + {{item.series.plusMediaFormat | plusMediaFormat}} + ({{item.matchRating | translocoPercent}}) +
+ } + + + - {{item.series.plusMediaFormat | plusMediaFormat}} - ({{item.matchRating | translocoPercent}}) -
diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts index fe482824b..4bb02f72e 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts @@ -15,6 +15,7 @@ import {TranslocoPercentPipe} from "@jsverse/transloco-locale"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {TranslocoDirective} from "@jsverse/transloco"; import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe"; +import {LoadingComponent} from "../../shared/loading/loading.component"; @Component({ selector: 'app-match-series-result-item', @@ -24,7 +25,8 @@ import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe"; TranslocoPercentPipe, ReadMoreComponent, TranslocoDirective, - PlusMediaFormatPipe + PlusMediaFormatPipe, + LoadingComponent ], templateUrl: './match-series-result-item.component.html', styleUrl: './match-series-result-item.component.scss', @@ -37,7 +39,13 @@ export class MatchSeriesResultItemComponent { @Input({required: true}) item!: ExternalSeriesMatch; @Output() selected: EventEmitter = new EventEmitter(); + isSelected = false; + selectItem() { + if (this.isSelected) return; + + this.isSelected = true; + this.cdRef.markForCheck(); this.selected.emit(this.item); } diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index 4eb69ee73..60d930100 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -8,6 +8,8 @@ @if (tokenExpired) {

{{t('token-expired')}}

+ } @else if (!(accountService.currentUser$ | async)!.preferences.aniListScrobblingEnabled) { +

{{t('scrobbling-disabled')}}

}

{{t('description')}}

diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index f5f8bad6b..f107c744e 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -18,6 +18,8 @@ import {ToastrService} from "ngx-toastr"; import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {CardActionablesComponent} from "../card-actionables/card-actionables.component"; +import {AsyncPipe} from "@angular/common"; +import {AccountService} from "../../_services/account.service"; export interface DataTablePage { pageNumber: number, @@ -29,8 +31,8 @@ export interface DataTablePage { @Component({ selector: 'app-user-scrobble-history', standalone: true, - imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule, - DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent], + imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule, + DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent, AsyncPipe], templateUrl: './user-scrobble-history.component.html', styleUrls: ['./user-scrobble-history.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -40,12 +42,14 @@ export class UserScrobbleHistoryComponent implements OnInit { protected readonly SpecialVolumeNumber = SpecialVolumeNumber; protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber; protected readonly ColumnMode = ColumnMode; + protected readonly ScrobbleEventType = ScrobbleEventType; private readonly scrobblingService = inject(ScrobblingService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); private readonly toastr = inject(ToastrService); - protected readonly ScrobbleEventType = ScrobbleEventType; + protected readonly accountService = inject(AccountService); + tokenExpired = false; diff --git a/UI/Web/src/app/admin/_models/metadata-settings.ts b/UI/Web/src/app/admin/_models/metadata-settings.ts new file mode 100644 index 000000000..09ce0ad5a --- /dev/null +++ b/UI/Web/src/app/admin/_models/metadata-settings.ts @@ -0,0 +1,34 @@ +import {AgeRating} from "../../_models/metadata/age-rating"; +import {PersonRole} from "../../_models/metadata/person"; + +export enum MetadataFieldType { + Genre = 0, + Tag = 1 +} + +export interface MetadataFieldMapping { + id: number; + sourceType: MetadataFieldType; + destinationType: MetadataFieldType; + sourceValue: string; + destinationValue: string; + excludeFromSource: boolean; +} + +export interface MetadataSettings { + enabled: boolean; + enableSummary: boolean; + enablePublicationStatus: boolean; + enableRelationships: boolean; + enablePeople: boolean; + enableStartDate: boolean; + enableLocalizedName: boolean; + enableGenres: boolean; + enableTags: boolean; + firstLastPeopleNaming: boolean; + ageRatingMappings: Map; + fieldMappings: Array; + blacklist: Array; + whitelist: Array; + personRoles: Array; +} diff --git a/UI/Web/src/app/admin/license/license.component.html b/UI/Web/src/app/admin/license/license.component.html index 9cfb5a27a..34a4ac196 100644 --- a/UI/Web/src/app/admin/license/license.component.html +++ b/UI/Web/src/app/admin/license/license.component.html @@ -6,166 +6,165 @@

{{t('kavita+-desc-part-1')}} {{t('kavita+-desc-part-2')}} {{t('kavita+-desc-part-3')}}

-
-
-
- - - - - - @if (hasLicense) { - ********* + } + + + + @if (hasLicense) { + ********* - @if (isChecking) { -
- {{t('loading')}} -
- } @else { - @if (licenseInfo?.isActive) { - - {{t('license-valid')}} - + @if (isChecking) { +
+ {{t('loading')}} +
} @else { - - {{t('license-not-valid')}} + @if (licenseInfo?.isActive) { + + {{t('license-valid')}} + + } @else { + + {{t('license-not-valid')}} + + } + } + @if (!isChecking && hasLicense && !licenseInfo) { +
{{t('license-mismatch')}}
+ } + + } @else { + {{t('no-license-key')}} + } +
+ + +
+ + +
+
+ + +
+
+ + + {{t('help-label')}} + + @if (formGroup.dirty || !formGroup.untouched) { +
+ @if (formGroup.get('discordId')?.errors?.pattern) { +
+ {{t('discord-validation')}} +
+ } +
+ } +
+ +
+ + + + + + +
+ +
+ + + @if (hasLicense) { + @if (licenseInfo?.isActive) { + {{t('manage')}} + } @else { + {{t('renew')}} + } + } @else { + {{t('buy')}} + + } + +
+
+ + + + + @if (hasLicense && licenseInfo) { +
+ +
+

{{t('info-title')}}

+
+ + + @if (isChecking) { + {{null | defaultValue}} + } @else { + + {{licenseInfo.isActive ? t('valid') : t('invalid')}] } - } - @if (!isChecking && hasLicense && !licenseInfo) { -
{{t('license-mismatch')}}
- } +
+
+
- } @else { - {{t('no-license-key')}} - } - - - -
- - -
-
- - -
-
- - - {{t('help-label')}} - - @if (formGroup.dirty || !formGroup.untouched) { -
- @if (formGroup.get('discordId')?.errors?.pattern) { -
- {{t('discord-validation')}} -
- } -
- } -
- -
- - - - - - -
- -
- - - @if (hasLicense) { - @if (licenseInfo?.isActive) { - {{t('manage')}} - } @else { - {{t('renew')}} - } - } @else { - {{t('buy')}} - - } - - -
- - - - - @if (hasLicense && licenseInfo) { -
- -
-

{{t('info-title')}}

-
- - - @if (isChecking) { - {{null | defaultValue}} - } @else { - - {{licenseInfo.isActive ? t('valid') : t('invalid')}] +
+ + + + {{isValidVersion ? t('valid') : t('invalid')}] - } - - -
+
+
+
-
- - - - {{isValidVersion ? t('valid') : t('invalid')}] - - - -
+
+ + + {{licenseInfo.expirationDate | utcToLocalTime | defaultValue}} + + +
-
- - - {{licenseInfo.expirationDate | utcToLocalTime | defaultValue}} - - -
+
+ + + {{licenseInfo.totalMonthsSubbed | number}} + + +
-
- - - {{licenseInfo.totalMonthsSubbed | number}} - - -
- -
- - +
+ + @if (showEmail) { {{licenseInfo.registeredEmail}} @@ -173,28 +172,31 @@ *************** } - - + + +
+ +
+ + +

{{t('actions-title')}}

+ +
+ + + +
+ +
+ + {{t('manage')}} + +
+ } +
-
- -

{{t('actions-title')}}

- -
- - - -
- -
- - {{t('manage')}} - -
- - } diff --git a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html new file mode 100644 index 000000000..a9bc9e742 --- /dev/null +++ b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html @@ -0,0 +1,266 @@ + + + +

{{t('description')}}

+ @if (isLoaded) { +
+ +
+ @if(settingsForm.get('enabled'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('enableSummary'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('enablePublicationStatus'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('enableRelationships'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('enableStartDate'); as formControl) { + + +
+ +
+
+
+ } +
+ + @if(settingsForm.get('enablePeople'); as formControl) { +
+ +
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ +
+ + @if (settingsForm.get('personRoles')) { +
{{t('person-roles-label')}}
+
+ @for(role of personRoles; track role; let i = $index) { +
+
+ + +
+
+ } +
+ } + } + + + +
+ + +
+
+ @if(settingsForm.get('enableGenres'); as formControl) { + + +
+ +
+
+
+ } +
+
+ @if(settingsForm.get('enableTags'); as formControl) { + + +
+ +
+
+
+ } +
+
+ +
+ @if(settingsForm.get('blacklist'); as formControl) { + + + @let val = (formControl.value || '').split(','); + + @for(opt of val; track opt) { + {{opt.trim()}} + } @empty { + {{null | defaultValue}} + } + s + + + + + } +
+ +
+ @if(settingsForm.get('whitelist'); as formControl) { + + + @let val = (formControl.value || '').split(','); + + @for(opt of val; track opt) { + {{opt.trim()}} + } @empty { + {{null | defaultValue}} + } + s + + + + + } +
+ +
+ +

{{t('age-rating-mapping-title')}}

+

{{t('age-rating-mapping-description')}}

+ +
+ @for(mapping of ageRatingMappings.controls; track mapping; let i = $index) { +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ } + + +
+ +
+ + +

{{t('field-mapping-title')}}

+

{{t('field-mapping-description')}}

+
+ @for (mapping of fieldMappings.controls; track mapping; let i = $index) { +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+ +
+
+ } + + +
+ +
+ } +
diff --git a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.scss b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts new file mode 100644 index 000000000..409d4bc5a --- /dev/null +++ b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts @@ -0,0 +1,219 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; +import {TranslocoDirective} from "@jsverse/transloco"; +import {FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; +import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; +import {SettingsService} from "../settings.service"; +import {debounceTime, switchMap} from "rxjs"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {filter, map} from "rxjs/operators"; +import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; +import {AgeRating} from "../../_models/metadata/age-rating"; +import {MetadataService} from "../../_services/metadata.service"; +import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; +import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-settings"; +import {PersonRole} from "../../_models/metadata/person"; +import {PersonRolePipe} from "../../_pipes/person-role.pipe"; +import {NgClass} from "@angular/common"; + + +@Component({ + selector: 'app-manage-metadata-settings', + standalone: true, + imports: [ + TranslocoDirective, + ReactiveFormsModule, + SettingSwitchComponent, + SettingItemComponent, + DefaultValuePipe, + TagBadgeComponent, + AgeRatingPipe, + PersonRolePipe, + ], + templateUrl: './manage-metadata-settings.component.html', + styleUrl: './manage-metadata-settings.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ManageMetadataSettingsComponent implements OnInit { + + protected readonly MetadataFieldType = MetadataFieldType; + + private readonly settingService = inject(SettingsService); + private readonly metadataService = inject(MetadataService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); + private readonly fb = inject(FormBuilder); + + settingsForm: FormGroup = new FormGroup({}); + ageRatings: Array = []; + ageRatingMappings = this.fb.array([]); + fieldMappings = this.fb.array([]); + personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]; + isLoaded = false; + + ngOnInit(): void { + this.metadataService.getAllAgeRatings().subscribe(ratings => { + this.ageRatings = ratings; + this.cdRef.markForCheck(); + }); + + + this.settingsForm.addControl('ageRatingMappings', this.ageRatingMappings); + this.settingsForm.addControl('fieldMappings', this.fieldMappings); + + this.settingService.getMetadataSettings().subscribe(settings => { + this.settingsForm.addControl('enabled', new FormControl(settings.enabled, [])); + this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, [])); + this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, [])); + this.settingsForm.addControl('enableRelations', new FormControl(settings.enableRelationships, [])); + this.settingsForm.addControl('enableGenres', new FormControl(settings.enableGenres, [])); + this.settingsForm.addControl('enableTags', new FormControl(settings.enableTags, [])); + this.settingsForm.addControl('enableRelationships', new FormControl(settings.enableRelationships, [])); + this.settingsForm.addControl('enablePeople', new FormControl(settings.enablePeople, [])); + this.settingsForm.addControl('enableStartDate', new FormControl(settings.enableStartDate, [])); + + this.settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), [])); + this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), [])); + this.settingsForm.addControl('firstLastPeopleNaming', new FormControl((settings.firstLastPeopleNaming), [])); + this.settingsForm.addControl('personRoles', this.fb.group( + Object.fromEntries( + this.personRoles.map((role, index) => [ + `personRole_${index}`, + this.fb.control((settings.personRoles || this.personRoles).includes(role)), + ]) + ) + )); + + + if (settings.ageRatingMappings) { + Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => { + this.addAgeRatingMapping(str, rating); + }); + } + + if (settings.fieldMappings) { + settings.fieldMappings.forEach(mapping => { + this.addFieldMapping(mapping); + }); + } + + this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => { + const firstLastControl = this.settingsForm.get('firstLastPeopleNaming'); + if (enabled) { + firstLastControl?.enable(); + } else { + firstLastControl?.disable(); + } + }); + + this.settingsForm.get('enablePeople')?.updateValueAndValidity(); + + // Disable personRoles checkboxes based on enablePeople state + this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => { + const personRolesArray = this.settingsForm.get('personRoles') as FormArray; + if (enabled) { + personRolesArray.enable(); + } else { + personRolesArray.disable(); + } + }); + + this.isLoaded = true; + this.cdRef.markForCheck(); + + + this.settingsForm.valueChanges.pipe( + debounceTime(300), + takeUntilDestroyed(this.destroyRef), + map(_ => this.packData()), + switchMap((data) => this.settingService.updateMetadataSettings(data)), + ).subscribe(); + + }); + + } + + packData(withFieldMappings: boolean = true) { + const model = this.settingsForm.value; + + // Convert FormArray to dictionary + const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc, control) => { + // @ts-ignore + const { str, rating } = control.value; + if (str && rating) { + // @ts-ignore + acc[str] = parseInt(rating + '', 10) as AgeRating; + } + return acc; + }, {}); + + const fieldMappings = this.fieldMappings.controls.map((control) => { + const value = control.value as MetadataFieldMapping; + + return { + id: value.id, + sourceType: parseInt(value.sourceType + '', 10), + destinationType: parseInt(value.destinationType + '', 10), + sourceValue: value.sourceValue, + destinationValue: value.destinationValue, + excludeFromSource: value.excludeFromSource + } + }).filter(m => m.sourceValue.length > 0); + + // Translate blacklist string -> Array + return { + ...model, + ageRatingMappings, + fieldMappings: withFieldMappings ? fieldMappings : [], + blacklist: (model.blacklist || '').split(',').map((item: string) => item.trim()), + whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()), + personRoles: Object.entries(this.settingsForm.get('personRoles')!.value) + .filter(([_, value]) => value) + .map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]) + } + } + + addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) { + const mappingGroup = this.fb.group({ + str: [str, Validators.required], + rating: [rating, Validators.required] + }); + // @ts-ignore + this.ageRatingMappings.push(mappingGroup); + } + + removeAgeRatingMappingRow(index: number) { + this.ageRatingMappings.removeAt(index); + } + + addFieldMapping(mapping: MetadataFieldMapping | null = null) { + const mappingGroup = this.fb.group({ + id: [mapping?.id || 0], + sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required], + destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required], + sourceValue: [mapping?.sourceValue || '', Validators.required], + destinationValue: [mapping?.destinationValue || ''], + excludeFromSource: [mapping?.excludeFromSource || false] + }); + + // Autofill destination value if empty when source value loses focus + mappingGroup.get('sourceValue')?.valueChanges + .pipe( + filter(() => !mappingGroup.get('destinationValue')?.value) + ) + .subscribe(sourceValue => { + mappingGroup.get('destinationValue')?.setValue(sourceValue); + }); + + //@ts-ignore + this.fieldMappings.push(mappingGroup); + } + + removeFieldMappingRow(index: number) { + this.fieldMappings.removeAt(index); + } + + +} diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index 4b0d30b8d..dc490beb7 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -4,6 +4,7 @@ import {map, of} from 'rxjs'; import { environment } from 'src/environments/environment'; import { TextResonse } from '../_types/text-response'; import { ServerSettings } from './_models/server-settings'; +import {MetadataSettings} from "./_models/metadata-settings"; /** * Used only for the Test Email Service call @@ -27,6 +28,13 @@ export class SettingsService { return this.http.get(this.baseUrl + 'settings'); } + getMetadataSettings() { + return this.http.get(this.baseUrl + 'settings/metadata-settings'); + } + updateMetadataSettings(model: MetadataSettings) { + return this.http.post(this.baseUrl + 'settings/metadata-settings', model); + } + updateServerSettings(model: ServerSettings) { return this.http.post(this.baseUrl + 'settings', model); } diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index 2d653a82b..68e6f9225 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -72,8 +72,8 @@ function deepClone(obj: any): any { @Component({ selector: 'app-series-card', standalone: true, - imports: [CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent, - EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective, + imports: [RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent, + FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective, SeriesFormatComponent, DecimalPipe], templateUrl: './series-card.component.html', styleUrls: ['./series-card.component.scss'], @@ -245,6 +245,13 @@ export class SeriesCardComponent implements OnInit, OnChanges { case(Action.Edit): this.openEditModal(series); break; + case Action.Match: + this.actionService.matchSeries(this.series, (refreshNeeded) => { + if (refreshNeeded) { + this.reload.emit(series.id); + } + }); + break; case(Action.AddToReadingList): this.actionService.addSeriesToReadingList(series); break; 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 251fb495d..8ad9a82d0 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 @@ -480,7 +480,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { } else if (event.event === EVENTS.ScanSeries) { const seriesScanEvent = event.payload as ScanSeriesEvent; if (seriesScanEvent.seriesId === this.seriesId) { - //this.loadSeries(this.seriesId); this.loadPageSource.next(false); } } else if (event.event === EVENTS.CoverUpdate) { @@ -491,7 +490,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { } else if (event.event === EVENTS.ChapterRemoved) { const removedEvent = event.payload as ChapterRemovedEvent; if (removedEvent.seriesId !== this.seriesId) return; - //this.loadSeries(this.seriesId, false); this.loadPageSource.next(false); } }); @@ -751,6 +749,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this)) .filter(action => action.action !== Action.Edit); + this.licenseService.hasValidLicense$.subscribe(hasLic => { + if (!hasLic) { + this.seriesActions = this.seriesActions.filter(action => action.action !== Action.Match); + this.cdRef.markForCheck(); + } + }); + this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => { this.relationShips = relations; diff --git a/UI/Web/src/app/settings/_components/setting-button/setting-button.component.html b/UI/Web/src/app/settings/_components/setting-button/setting-button.component.html index acc56d318..809bffc94 100644 --- a/UI/Web/src/app/settings/_components/setting-button/setting-button.component.html +++ b/UI/Web/src/app/settings/_components/setting-button/setting-button.component.html @@ -1,5 +1,5 @@ -
+
@if (subtitle) { diff --git a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html index 2fd979c62..b6d32d413 100644 --- a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html +++ b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html @@ -1,5 +1,5 @@ -
+
diff --git a/UI/Web/src/app/settings/_components/setting-switch/setting-switch.component.html b/UI/Web/src/app/settings/_components/setting-switch/setting-switch.component.html index d686b3312..1426d6832 100644 --- a/UI/Web/src/app/settings/_components/setting-switch/setting-switch.component.html +++ b/UI/Web/src/app/settings/_components/setting-switch/setting-switch.component.html @@ -1,5 +1,5 @@ -
+
{{title}}
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 f16d98b04..ccb75c7ec 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -122,6 +122,14 @@ } } + @defer (when fragment === SettingsTabId.Metadata; prefetch on idle) { + @if(hasActiveLicense && fragment === SettingsTabId.Metadata) { +
+ +
+ } + } + @defer (when fragment === SettingsTabId.ScrobblingHolds; prefetch on idle) { @if(hasActiveLicense && fragment === SettingsTabId.ScrobblingHolds) {
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 b7df9099b..b529882fe 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -49,6 +49,9 @@ import {ManageMatchedMetadataComponent} from "../../../admin/manage-matched-meta 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"; +import { + ManageMetadataSettingsComponent +} from "../../../admin/manage-metadata-settings/manage-metadata-settings.component"; @Component({ selector: 'app-settings', @@ -84,7 +87,8 @@ import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobb ManageMatchedMetadataComponent, ManageUserTokensComponent, EmailHistoryComponent, - ScrobblingHoldsComponent + ScrobblingHoldsComponent, + ManageMetadataSettingsComponent ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index 700f439e5..69ba7c827 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -157,6 +157,16 @@
+
+ + +
+ +
+
+
+
+
diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index 1201abbf1..80b64b258 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -9,9 +9,6 @@ import { } from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import { - NgbAccordionBody, - NgbAccordionButton, NgbAccordionCollapse, - NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem, NgbActiveModal, NgbModal, NgbModalModule, @@ -71,7 +68,7 @@ enum StepID { standalone: true, imports: [CommonModule, NgbModalModule, NgbNavLink, NgbNavItem, NgbNavContent, ReactiveFormsModule, NgbTooltip, SentenceCasePipe, NgbNav, NgbNavOutlet, CoverImageChooserComponent, TranslocoModule, DefaultDatePipe, - FileTypeGroupPipe, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton, NgbAccordionCollapse, NgbAccordionBody, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent], + FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent], templateUrl: './library-settings-modal.component.html', styleUrls: ['./library-settings-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -116,6 +113,7 @@ export class LibrarySettingsModalComponent implements OnInit { manageCollections: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), manageReadingLists: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), allowScrobbling: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), + allowMetadataMatching: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), collapseSeriesRelationships: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), }); @@ -153,7 +151,9 @@ export class LibrarySettingsModalComponent implements OnInit { if (this.library && !(this.library.type === LibraryType.Manga || this.library.type === LibraryType.LightNovel) ) { this.libraryForm.get('allowScrobbling')?.setValue(false); + this.libraryForm.get('allowMetadataMatching')?.setValue(false); this.libraryForm.get('allowScrobbling')?.disable(); + this.libraryForm.get('allowMetadataMatching')?.disable(); } this.libraryForm.get('name')?.valueChanges.pipe( @@ -216,8 +216,10 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible); if (!this.IsKavitaPlusEligible) { this.libraryForm.get('allowScrobbling')?.disable(); + this.libraryForm.get('allowMetadataMatching')?.disable(); } else { this.libraryForm.get('allowScrobbling')?.enable(); + this.libraryForm.get('allowMetadataMatching')?.enable(); } this.cdRef.markForCheck(); }), @@ -237,6 +239,7 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('manageReadingLists')?.setValue(this.library.manageReadingLists); this.libraryForm.get('collapseSeriesRelationships')?.setValue(this.library.collapseSeriesRelationships); this.libraryForm.get('allowScrobbling')?.setValue(this.library.allowScrobbling); + this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching); this.selectedFolders = this.library.folders; this.madeChanges = false; for(let fileTypeGroup of allFileTypeGroup) { 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 4767281aa..fd0d3cb45 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 @@ -36,6 +36,7 @@ export enum SettingsTabId { MALStackImport = 'mal-stack-import', MatchedMetadata = 'admin-matched-metadata', ManageUserTokens = 'admin-manage-tokens', + Metadata = 'admin-metadata', // Non-Admin Account = 'account', @@ -233,6 +234,7 @@ export class PreferenceNavComponent implements AfterViewInit { this.matchedMetadataBadgeCount$ )); kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ManageUserTokens, [Role.Admin])); + kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.Metadata, [Role.Admin])); // Scrobbling History needs to be per-user and allow admin to view all kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ScrobblingHolds, [])); diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html index 0e0337aeb..f199fb93a 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html @@ -87,6 +87,37 @@
+ + @if (licenseService.hasValidLicense$ | async) { +

{{t('kavitaplus-settings-title')}}

+ +
+ @if(settingsForm.get('aniListScrobblingEnabled'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('wantToReadSync'); as formControl) { + + +
+ +
+
+
+ } +
+
+ } + +

{{t('image-reader-settings-title')}}

diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts index 546fdde45..fe5556823 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts @@ -35,7 +35,7 @@ import { NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem, NgbTooltip } from "@ng-bootstrap/ng-bootstrap"; -import {NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common"; +import {AsyncPipe, NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common"; import {ColorPickerModule} from "ngx-color-picker"; import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; @@ -53,26 +53,17 @@ import {PdfSpreadModePipe} from "../../_pipes/pdf-spread-mode.pipe"; import {PdfThemePipe} from "../../_pipes/pdf-theme.pipe"; import {PdfScrollModeTypePipe} from "../../pdf-reader/_pipe/pdf-scroll-mode.pipe"; import {PdfScrollModePipe} from "../../_pipes/pdf-scroll-mode.pipe"; +import {LicenseService} from "../../_services/license.service"; @Component({ selector: 'app-manga-user-preferences', standalone: true, imports: [ TranslocoDirective, - NgbAccordionDirective, ReactiveFormsModule, - NgbAccordionItem, - NgbAccordionCollapse, - NgbAccordionBody, - NgbAccordionHeader, - NgbAccordionButton, - NgbTooltip, - NgTemplateOutlet, TitleCasePipe, ColorPickerModule, - SettingTitleComponent, SettingItemComponent, - PageLayoutModePipe, SettingSwitchComponent, ReadingDirectionPipe, ScalingOptionPipe, @@ -82,12 +73,10 @@ import {PdfScrollModePipe} from "../../_pipes/pdf-scroll-mode.pipe"; NgStyle, WritingStylePipe, BookPageLayoutModePipe, - PdfSpreadTypePipe, - PdfSpreadTypePipe, PdfSpreadModePipe, PdfThemePipe, - PdfScrollModeTypePipe, - PdfScrollModePipe + PdfScrollModePipe, + AsyncPipe ], templateUrl: './manage-user-preferences.component.html', styleUrl: './manage-user-preferences.component.scss', @@ -102,6 +91,7 @@ export class ManageUserPreferencesComponent implements OnInit { private readonly router = inject(Router); private readonly cdRef = inject(ChangeDetectorRef); private readonly localizationService = inject(LocalizationService); + protected readonly licenseService = inject(LicenseService); protected readonly readingDirections = readingDirections; protected readonly scalingOptions = scalingOptions; @@ -199,6 +189,9 @@ export class ManageUserPreferencesComponent implements OnInit { this.settingsForm.addControl('shareReviews', new FormControl(this.user.preferences.shareReviews, [])); this.settingsForm.addControl('locale', new FormControl(this.user.preferences.locale || 'en', [])); + this.settingsForm.addControl('aniListScrobblingEnabled', new FormControl(this.user.preferences.aniListScrobblingEnabled || false, [])); + this.settingsForm.addControl('wantToReadSync', new FormControl(this.user.preferences.wantToReadSync || false, [])); + // Automatically save settings as we edit them this.settingsForm.valueChanges.pipe( @@ -267,6 +260,9 @@ export class ManageUserPreferencesComponent implements OnInit { this.settingsForm.get('collapseSeriesRelationships')?.setValue(this.user.preferences.collapseSeriesRelationships, {onlySelf: true, emitEvent: false}); this.settingsForm.get('shareReviews')?.setValue(this.user.preferences.shareReviews, {onlySelf: true, emitEvent: false}); this.settingsForm.get('locale')?.setValue(this.user.preferences.locale || 'en', {onlySelf: true, emitEvent: false}); + + this.settingsForm.get('aniListScrobblingEnabled')?.setValue(this.user.preferences.aniListScrobblingEnabled || false, {onlySelf: true, emitEvent: false}); + this.settingsForm.get('wantToReadSync')?.setValue(this.user.preferences.wantToReadSync || false, {onlySelf: true, emitEvent: false}); } packSettings(): Preferences { @@ -303,6 +299,8 @@ export class ManageUserPreferencesComponent implements OnInit { pdfTheme: parseInt(modelSettings.pdfTheme, 10), pdfScrollMode: parseInt(modelSettings.pdfScrollMode, 10), pdfSpreadMode: parseInt(modelSettings.pdfSpreadMode, 10), + aniListScrobblingEnabled: modelSettings.aniListScrobblingEnabled, + wantToReadSync: modelSettings.wantToReadSync }; } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index c264153bf..cf2feec71 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -52,7 +52,8 @@ "not-processed": "Not Processed", "special": "{{entity-title.special}}", "generate-scrobble-events": "Backfill Events", - "token-expired": "Your AniList token is Expired! Scrobbling events will not process until you renew on Accounts page." + "token-expired": "Your AniList token is Expired! Scrobbling events will not process until you renew on Accounts page.", + "scrobbling-disabled": "Scrobbling is disabled on your Account Settings." }, "scrobble-event-type-pipe": { @@ -128,6 +129,12 @@ "share-series-reviews-label": "Share Series Reviews", "share-series-reviews-tooltip": "Should Kavita include your reviews of Series for other users", + "kavitaplus-settings-title": "Kavita+", + "anilist-scrobbling-label": "AniList Scrobbling", + "anilist-scrobbling-tooltip": "Allow Kavita to Scrobble (one-way sync) reading progress and ratings to AniList", + "want-to-read-sync-label": "Want To Read Sync", + "want-to-read-sync-tooltip": "Allow Kavita to add items to your Want to Read list based on AniList and MAL series in Pending readlist", + "image-reader-settings-title": "Image Reader", "reading-direction-label": "Reading Direction", "reading-direction-tooltip": "Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.", @@ -771,6 +778,42 @@ "expires-label": "Expires: {{date}}" }, + "manage-metadata-settings": { + "description": "Kavita+ has the ability to download and write some limited metadata to the Database. This page allows for you to toggle what is in scope.", + "enabled-label": "Enable Metadata Download", + "enabled-tooltip": "Allow Kavita to download metadata and write to it's database.", + "summary-label": "Summary", + "summary-tooltip": "Allow Summary to be written when the field is unlocked.", + "derive-publication-status-label": "Publication Status", + "derive-publication-status-tooltip": "Allow Publication Status to be derived from Total Chapter/Volume counts.", + "enable-relations-label": "Relationships", + "enable-relations-tooltip": "Allow Series Relationships to be added.", + "enable-people-label": "People", + "enable-people-tooltip": "Allow People (Characters, Writers, etc) to be added. All people include images.", + "enable-start-date-label": "Start Date", + "enable-start-date-tooltip": "Allow Start Date of Series to be written to the Series", + "enable-genres-label": "Genres", + "enable-genres-tooltip": "Allow Series Genres to be written.", + "enable-tags-label": "Tags", + "enable-tags-tooltip": "Allow Series Tags to be written.", + "blacklist-label": "Blacklist Genres/Tags", + "blacklist-tooltip": "Anything in this list will be removed from both Genre and Tag processing. This is a place to add genres/tags you do not want written. Ensure they are comma-separated.", + "whitelist-label": "Whitelist Tags", + "whitelist-tooltip": "Only allow a string in this list from being written for Tags. Ensure they are comma-separated.", + "age-rating-mapping-title": "Age Rating Mapping", + "age-rating-mapping-description": "Any strings on the left if found in either Genre or Tags will set the Age Rating on the Series.", + "genre": "Genre", + "tag": "Tag", + "remove-source-tag-label": "Remove Source Tag", + "add-field-mapping-label": "Add Field Mapping", + "add-age-rating-mapping-label": "Add Age Rating Mapping", + "field-mapping-title": "Field Mapping", + "field-mapping-description": "Setup rules for certain strings found in Genre/Tag field and map it to a new string in Genre/Tag and optionally remove it from the Source list. Only applicable when Genre/Tag are enabled to be written.", + "first-last-name-label": "First Last Naming", + "first-last-name-tooltip": "Ensure People's names are written First then Last", + "person-roles-label": "Roles" + }, + "book-line-overlay": { "copy": "Copy", "bookmark": "Bookmark", @@ -938,7 +981,8 @@ "volume-count": "{{server-stats.volume-count}}", "chapter-count": "{{common.chapter-count}}", "releasing": "Releasing", - "details": "View page" + "details": "View page", + "updating-metadata-status": "Updating Metadata" }, "metadata-fields": { @@ -1062,6 +1106,8 @@ "manage-reading-list-tooltip": "Should Kavita create Reading Lists from StoryArc/StoryArcNumber and AlternativeSeries/AlternativeCount tags found within ComicInfo.xml/opf files", "allow-scrobbling-label": "Allow Scrobbling", "allow-scrobbling-tooltip": "Should Kavita scrobble reading events, want to read status, ratings, and reviews to configured providers. This will only occur if the server has an active Kavita+ Subscription.", + "allow-metadata-matching-label": "Allow Metadata Matching", + "allow-metadata-matching-tooltip": "Should Kavita download metadata for Series within this Library. This will only occur if the server has an active Kavita+ Subscription.", "folder-watching-label": "Folder Watching", "folder-watching-tooltip": "Override Server folder watching for this library. If off, folder watching won't run on the folders this library contains. If libraries share folders, then folders may still be ran against. Will always wait 10 minutes before triggering scan.", "include-in-dashboard-label": "Include in Dashboard", @@ -1616,6 +1662,7 @@ "admin-kavitaplus": "License", "admin-matched-metadata": "Matched Metadata", "admin-manage-tokens": "Manage User Tokens", + "admin-metadata": "Manage Metadata", "scrobble-holds": "Scrobble Holds", "account": "Account", "preferences": "Preferences", diff --git a/openapi.json b/openapi.json index 99e71ec35..3bab561af 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.6", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.9", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" @@ -11093,28 +11093,17 @@ "type": "integer", "format": "int32" } - } - ], - "requestBody": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalSeriesDetailDto" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/ExternalSeriesDetailDto" - } - }, - "application/*+json": { - "schema": { - "$ref": "#/components/schemas/ExternalSeriesDetailDto" - } + }, + { + "name": "aniListId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" } } - }, + ], "responses": { "200": { "description": "OK" @@ -11959,6 +11948,84 @@ } } }, + "/api/Settings/metadata-settings": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get the metadata settings for Kavita+ users.", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsDto" + } + } + } + } + } + }, + "post": { + "tags": [ + "Settings" + ], + "summary": "Update the metadata settings for Kavita+ users", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MetadataSettingsDto" + } + } + } + } + } + } + }, "/api/Stats/user/{userId}/read": { "get": { "tags": [ @@ -14544,6 +14611,28 @@ }, "components": { "schemas": { + "ALMediaTitle": { + "type": "object", + "properties": { + "englishTitle": { + "type": "string", + "nullable": true + }, + "romajiTitle": { + "type": "string", + "nullable": true + }, + "nativeTitle": { + "type": "string", + "nullable": true + }, + "preferredTitle": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "AgeRatingDto": { "required": [ "title" @@ -15487,6 +15576,14 @@ "description": "UI Site Global Setting: The language locale that should be used for the user", "nullable": true }, + "aniListScrobblingEnabled": { + "type": "boolean", + "description": "Should this account have Scrobbling enabled for AniList" + }, + "wantToReadSync": { + "type": "boolean", + "description": "Should this account have Want to Read Sync enabled" + }, "appUser": { "$ref": "#/components/schemas/AppUser" }, @@ -18143,16 +18240,6 @@ "type": "string", "nullable": true }, - "volumeCount": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "chapterCount": { - "type": "integer", - "format": "int32", - "nullable": true - }, "provider": { "enum": [ 0, @@ -18162,9 +18249,46 @@ "type": "integer", "description": "Misleading name but is the source of data (like a review coming from AniList)", "format": "int32" + }, + "startDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "endDate": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "averageScore": { + "type": "integer", + "format": "int32" + }, + "chapters": { + "type": "integer", + "format": "int32" + }, + "volumes": { + "type": "integer", + "format": "int32" + }, + "relations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesRelationship" + }, + "nullable": true + }, + "characters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesCharacter" + }, + "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "description": "This is AniListSeries" }, "ExternalSeriesDto": { "required": [ @@ -19091,6 +19215,10 @@ "type": "boolean", "description": "Should this library allow Scrobble events to emit from it" }, + "allowMetadataMatching": { + "type": "boolean", + "description": "Allow any series within this Library to download metadata." + }, "created": { "type": "string", "format": "date-time" @@ -19245,6 +19373,10 @@ }, "description": "A set of globs that will exclude matching content from being scanned", "nullable": true + }, + "allowMetadataMatching": { + "type": "boolean", + "description": "Allow any series within this Library to download metadata." } }, "additionalProperties": false @@ -19799,6 +19931,167 @@ "additionalProperties": false, "description": "Represents a member of a Kavita server." }, + "MetadataFieldMappingDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "sourceType": { + "enum": [ + 0, + 1 + ], + "type": "integer", + "format": "int32" + }, + "destinationType": { + "enum": [ + 0, + 1 + ], + "type": "integer", + "format": "int32" + }, + "sourceValue": { + "type": "string", + "description": "The string in the source", + "nullable": true + }, + "destinationValue": { + "type": "string", + "description": "Write the string as this in the Destination (can also just be the Source)", + "nullable": true + }, + "excludeFromSource": { + "type": "boolean", + "description": "If true, the tag will be Moved over vs Copied over" + } + }, + "additionalProperties": false + }, + "MetadataSettingsDto": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed" + }, + "enableSummary": { + "type": "boolean", + "description": "Allow the Summary to be written" + }, + "enablePublicationStatus": { + "type": "boolean", + "description": "Allow Publication status to be derived and updated" + }, + "enableRelationships": { + "type": "boolean", + "description": "Allow Relationships between series to be set" + }, + "enablePeople": { + "type": "boolean", + "description": "Allow People to be created (including downloading images)" + }, + "enableStartDate": { + "type": "boolean", + "description": "Allow Start date to be set within the Series" + }, + "enableLocalizedName": { + "type": "boolean", + "description": "Allow setting the Localized name" + }, + "enableGenres": { + "type": "boolean" + }, + "enableTags": { + "type": "boolean" + }, + "firstLastPeopleNaming": { + "type": "boolean", + "description": "For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names." + }, + "ageRatingMappings": { + "type": "object", + "additionalProperties": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + -1 + ], + "type": "integer", + "description": "Represents Age Rating for content.", + "format": "int32" + }, + "description": "Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.", + "nullable": true + }, + "fieldMappings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetadataFieldMappingDto" + }, + "description": "A list of rules that allow mapping a genre/tag to another genre/tag", + "nullable": true + }, + "blacklist": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Do not allow any Genre/Tag in this list to be written to Kavita", + "nullable": true + }, + "whitelist": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only allow these Tags to be written to Kavita", + "nullable": true + }, + "personRoles": { + "type": "array", + "items": { + "enum": [ + 1, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "type": "integer", + "format": "int32" + }, + "description": "Which Roles to allow metadata downloading for", + "nullable": true + } + }, + "additionalProperties": false + }, "MetadataTagDto": { "type": "object", "properties": { @@ -21413,6 +21706,32 @@ }, "additionalProperties": false }, + "SeriesCharacter": { + "required": [ + "description", + "url" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "url": { + "type": "string", + "nullable": true + }, + "imageUrl": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "SeriesDetailDto": { "type": "object", "properties": { @@ -21481,6 +21800,9 @@ "$ref": "#/components/schemas/RatingDto" }, "nullable": true + }, + "series": { + "$ref": "#/components/schemas/ExternalSeriesDetailDto" } }, "additionalProperties": false, @@ -22155,6 +22477,67 @@ "additionalProperties": false, "description": "A relation flows between one series and another.\r\nSeries ---kind---> target" }, + "SeriesRelationship": { + "type": "object", + "properties": { + "aniListId": { + "type": "integer", + "format": "int32" + }, + "malId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "seriesName": { + "$ref": "#/components/schemas/ALMediaTitle" + }, + "relation": { + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "type": "integer", + "description": "Represents a relationship between Series", + "format": "int32" + }, + "provider": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "description": "Misleading name but is the source of data (like a review coming from AniList)", + "format": "int32" + }, + "plusMediaFormat": { + "enum": [ + 1, + 2, + 3, + 4, + 5 + ], + "type": "integer", + "description": "Represents PlusMediaFormat", + "format": "int32" + } + }, + "additionalProperties": false + }, "SeriesStaffDto": { "required": [ "name", @@ -22167,6 +22550,14 @@ "type": "string", "nullable": true }, + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + }, "url": { "type": "string", "nullable": true @@ -23595,6 +23986,7 @@ }, "UpdateLibraryDto": { "required": [ + "allowMetadataMatching", "allowScrobbling", "excludePatterns", "fileGroupTypes", @@ -23654,6 +24046,9 @@ "allowScrobbling": { "type": "boolean" }, + "allowMetadataMatching": { + "type": "boolean" + }, "fileGroupTypes": { "type": "array", "items": { @@ -24675,6 +25070,14 @@ "type": "integer", "description": "PDF Reader: Spread Mode of the reader", "format": "int32" + }, + "aniListScrobblingEnabled": { + "type": "boolean", + "description": "Kavita+: Should this account have Scrobbling enabled for AniList" + }, + "wantToReadSync": { + "type": "boolean", + "description": "Kavita+: Should this account have Want to Read Sync enabled" } }, "additionalProperties": false