diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs index 4872342ef..475cafc71 100644 --- a/API/Controllers/ChapterController.cs +++ b/API/Controllers/ChapterController.cs @@ -1,20 +1,40 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using API.SignalR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Nager.ArticleNumber; namespace API.Controllers; public class ChapterController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IEventHub _eventHub; - public ChapterController(IUnitOfWork unitOfWork) + public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub) { _unitOfWork = unitOfWork; + _localizationService = localizationService; + _eventHub = eventHub; } + /// + /// Gets a single chapter + /// + /// + /// [HttpGet] public async Task> GetChapter(int chapterId) { @@ -25,5 +45,234 @@ public class ChapterController : BaseApiController return Ok(chapter); } + /// + /// Removes a Chapter + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete] + public async Task> DeleteChapter(int chapterId) + { + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + if (chapter == null) + return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + + var vol = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!; + _unitOfWork.ChapterRepository.Remove(chapter); + + if (await _unitOfWork.CommitAsync()) + { + await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, vol.SeriesId), false); + return Ok(true); + } + + return Ok(false); + } + + /// + /// Update chapter metadata + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update")] + public async Task UpdateChapterMetadata(UpdateChapterDto dto) + { + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.Id, ChapterIncludes.People | ChapterIncludes.Genres | ChapterIncludes.Tags); + if (chapter == null) + return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + + if (chapter.AgeRating != dto.AgeRating) + { + chapter.AgeRating = dto.AgeRating; + } + + + if (chapter.Summary != dto.Summary.Trim()) + { + chapter.Summary = dto.Summary.Trim(); + } + + if (chapter.Language != dto.Language) + { + chapter.Language = dto.Language ?? string.Empty; + } + + if (chapter.SortOrder.IsNot(dto.SortOrder)) + { + chapter.SortOrder = dto.SortOrder; // TODO: Figure out validation + } + + if (chapter.TitleName != dto.TitleName) + { + chapter.TitleName = dto.TitleName; + } + + if (chapter.ReleaseDate != dto.ReleaseDate) + { + chapter.ReleaseDate = dto.ReleaseDate; + } + + if (!string.IsNullOrEmpty(dto.ISBN) && ArticleNumberHelper.IsValidIsbn10(dto.ISBN) || + ArticleNumberHelper.IsValidIsbn13(dto.ISBN)) + { + chapter.ISBN = dto.ISBN; + } + + if (string.IsNullOrEmpty(dto.WebLinks)) + { + chapter.WebLinks = string.Empty; + } else + { + chapter.WebLinks = string.Join(',', dto.WebLinks + .Split(',') + .Where(s => !string.IsNullOrEmpty(s)) + .Select(s => s.Trim())! + ); + } + + + #region Genres + if (dto.Genres != null && + dto.Genres.Count != 0) + { + var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(dto.Genres.Select(t => Parser.Normalize(t.Title)))).ToList(); + chapter.Genres ??= new List(); + GenreHelper.UpdateGenreList(dto.Genres, chapter, allGenres, genre => + { + chapter.Genres.Add(genre); + }, () => chapter.GenresLocked = true); + } + #endregion + + #region Tags + if (dto.Tags is {Count: > 0}) + { + var allTags = (await _unitOfWork.TagRepository + .GetAllTagsByNameAsync(dto.Tags.Select(t => Parser.Normalize(t.Title)))) + .ToList(); + chapter.Tags ??= new List(); + TagHelper.UpdateTagList(dto.Tags, chapter, allTags, tag => + { + chapter.Tags.Add(tag); + }, () => chapter.TagsLocked = true); + } + #endregion + + #region People + if (PersonHelper.HasAnyPeople(dto)) + { + void HandleAddPerson(Person person) + { + PersonHelper.AddPersonIfNotExists(chapter.People, person); + } + + chapter.People ??= new List(); + var allWriters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Writer, + dto.Writers.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Writer, dto.Writers, chapter, allWriters.AsReadOnly(), + HandleAddPerson, () => chapter.WriterLocked = true); + + var allCharacters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Character, + dto.Characters.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Character, dto.Characters, chapter, allCharacters.AsReadOnly(), + HandleAddPerson, () => chapter.CharacterLocked = true); + + var allColorists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Colorist, + dto.Colorists.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Colorist, dto.Colorists, chapter, allColorists.AsReadOnly(), + HandleAddPerson, () => chapter.ColoristLocked = true); + + var allEditors = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Editor, + dto.Editors.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Editor, dto.Editors, chapter, allEditors.AsReadOnly(), + HandleAddPerson, () => chapter.EditorLocked = true); + + var allInkers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Inker, + dto.Inkers.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Inker, dto.Inkers, chapter, allInkers.AsReadOnly(), + HandleAddPerson, () => chapter.InkerLocked = true); + + var allLetterers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Letterer, + dto.Letterers.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Letterer, dto.Letterers, chapter, allLetterers.AsReadOnly(), + HandleAddPerson, () => chapter.LettererLocked = true); + + var allPencillers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Penciller, + dto.Pencillers.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Penciller, dto.Pencillers, chapter, allPencillers.AsReadOnly(), + HandleAddPerson, () => chapter.PencillerLocked = true); + + var allPublishers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Publisher, + dto.Publishers.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Publisher, dto.Publishers, chapter, allPublishers.AsReadOnly(), + HandleAddPerson, () => chapter.PublisherLocked = true); + + var allImprints = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Imprint, + dto.Imprints.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Imprint, dto.Imprints, chapter, allImprints.AsReadOnly(), + HandleAddPerson, () => chapter.ImprintLocked = true); + + var allTeams = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Team, + dto.Imprints.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Team, dto.Teams, chapter, allTeams.AsReadOnly(), + HandleAddPerson, () => chapter.TeamLocked = true); + + var allLocations = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Location, + dto.Imprints.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Location, dto.Locations, chapter, allLocations.AsReadOnly(), + HandleAddPerson, () => chapter.LocationLocked = true); + + var allTranslators = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Translator, + dto.Translators.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.Translator, dto.Translators, chapter, allTranslators.AsReadOnly(), + HandleAddPerson, () => chapter.TranslatorLocked = true); + + var allCoverArtists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.CoverArtist, + dto.CoverArtists.Select(p => Parser.Normalize(p.Name))); + PersonHelper.UpdatePeopleList(PersonRole.CoverArtist, dto.CoverArtists, chapter, allCoverArtists.AsReadOnly(), + HandleAddPerson, () => chapter.CoverArtistLocked = true); + } + #endregion + + #region Locks + chapter.AgeRatingLocked = dto.AgeRatingLocked; + chapter.LanguageLocked = dto.LanguageLocked; + chapter.TitleNameLocked = dto.TitleNameLocked; + chapter.SortOrderLocked = dto.SortOrderLocked; + chapter.GenresLocked = dto.GenresLocked; + chapter.TagsLocked = dto.TagsLocked; + chapter.CharacterLocked = dto.CharacterLocked; + chapter.ColoristLocked = dto.ColoristLocked; + chapter.EditorLocked = dto.EditorLocked; + chapter.InkerLocked = dto.InkerLocked; + chapter.ImprintLocked = dto.ImprintLocked; + chapter.LettererLocked = dto.LettererLocked; + chapter.PencillerLocked = dto.PencillerLocked; + chapter.PublisherLocked = dto.PublisherLocked; + chapter.TranslatorLocked = dto.TranslatorLocked; + chapter.CoverArtistLocked = dto.CoverArtistLocked; + chapter.WriterLocked = dto.WriterLocked; + chapter.SummaryLocked = dto.SummaryLocked; + chapter.ISBNLocked = dto.ISBNLocked; + chapter.ReleaseDateLocked = dto.ReleaseDateLocked; + #endregion + + + if (!_unitOfWork.HasChanges()) + { + return Ok(); + } + + // TODO: Emit a ChapterMetadataUpdate out + + await _unitOfWork.CommitAsync(); + + + return Ok(); + } + + } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index db0b134ef..6c23f5652 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -63,7 +63,7 @@ public class ReadingListController : BaseApiController } /// - /// Returns all Reading Lists the user has access to that have a series within it. + /// Returns all Reading Lists the user has access to that the given series within it. /// /// /// @@ -74,6 +74,18 @@ public class ReadingListController : BaseApiController seriesId, true)); } + /// + /// Returns all Reading Lists the user has access to that has the given chapter within it. + /// + /// + /// + [HttpGet("lists-for-chapter")] + public async Task>> GetListsForChapter(int chapterId) + { + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtosForChapterAndUserAsync(User.GetUserId(), + chapterId, true)); + } + /// /// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress /// diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 03841f26d..bfa958546 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; @@ -92,28 +93,36 @@ public class UploadController : BaseApiController { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - try { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id); if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}"); + + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) + { + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}"); + lockState = uploadFileDto.LockCover; + } if (!string.IsNullOrEmpty(filePath)) { series.CoverImage = filePath; - series.CoverImageLocked = true; + series.CoverImageLocked = lockState; _imageService.UpdateColorScape(series); _unitOfWork.SeriesRepository.Update(series); } if (_unitOfWork.HasChanges()) { + // Refresh covers + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); + } + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); await _unitOfWork.CommitAsync(); @@ -142,25 +151,24 @@ public class UploadController : BaseApiController { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - try { var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadFileDto.Id); if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); - if (!string.IsNullOrEmpty(filePath)) + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) { - tag.CoverImage = filePath; - tag.CoverImageLocked = true; - _imageService.UpdateColorScape(tag); - _unitOfWork.CollectionTagRepository.Update(tag); + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); + lockState = uploadFileDto.LockCover; } + tag.CoverImage = filePath; + tag.CoverImageLocked = lockState; + _imageService.UpdateColorScape(tag); + _unitOfWork.CollectionTagRepository.Update(tag); + if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); @@ -189,30 +197,31 @@ public class UploadController : BaseApiController [HttpPost("reading-list")] public async Task UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) { - // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // Check if Url is non-empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - - if (_readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null) + if (await _readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "access-denied")); try { var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); - if (!string.IsNullOrEmpty(filePath)) + + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) { - readingList.CoverImage = filePath; - readingList.CoverImageLocked = true; - _imageService.UpdateColorScape(readingList); - _unitOfWork.ReadingListRepository.Update(readingList); + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); + lockState = uploadFileDto.LockCover; } + + readingList.CoverImage = filePath; + readingList.CoverImageLocked = lockState; + _imageService.UpdateColorScape(readingList); + _unitOfWork.ReadingListRepository.Update(readingList); + if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); @@ -253,33 +262,42 @@ public class UploadController : BaseApiController { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - try { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); - if (!string.IsNullOrEmpty(filePath)) + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) { - chapter.CoverImage = filePath; - chapter.CoverImageLocked = true; - _unitOfWork.ChapterRepository.Update(chapter); - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); - if (volume != null) - { - volume.CoverImage = chapter.CoverImage; - _unitOfWork.VolumeRepository.Update(volume); - } + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); + lockState = uploadFileDto.LockCover; + } + + chapter.CoverImage = filePath; + chapter.CoverImageLocked = lockState; + _unitOfWork.ChapterRepository.Update(chapter); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); + if (volume != null) + { + volume.CoverImage = chapter.CoverImage; + volume.CoverImageLocked = lockState; + _unitOfWork.VolumeRepository.Update(volume); } if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); + + // Refresh covers + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId))!; + _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); + } + + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, @@ -310,11 +328,6 @@ public class UploadController : BaseApiController { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - try { var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(uploadFileDto.Id, VolumeIncludes.Chapters); @@ -323,24 +336,37 @@ public class UploadController : BaseApiController // Find the first chapter of the volume var chapter = volume.Chapters[0]; - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(chapter.Id, uploadFileDto.Id)}"); - - if (!string.IsNullOrEmpty(filePath)) + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) { - chapter.CoverImage = filePath; - chapter.CoverImageLocked = true; - _imageService.UpdateColorScape(chapter); - _unitOfWork.ChapterRepository.Update(chapter); - - volume.CoverImage = chapter.CoverImage; - _imageService.UpdateColorScape(volume); - _unitOfWork.VolumeRepository.Update(volume); + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(chapter.Id, uploadFileDto.Id)}"); + lockState = uploadFileDto.LockCover; } + + chapter.CoverImage = filePath; + chapter.CoverImageLocked = lockState; + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + + volume.CoverImage = chapter.CoverImage; + volume.CoverImageLocked = lockState; + _imageService.UpdateColorScape(volume); + _unitOfWork.VolumeRepository.Update(volume); + if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); + // Refresh covers + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!; + _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); + } + + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, @@ -426,6 +452,7 @@ public class UploadController : BaseApiController /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("reset-chapter-lock")] + [Obsolete("Use LockCover in UploadFileDto")] public async Task ResetChapterLock(UploadFileDto uploadFileDto) { try @@ -461,4 +488,6 @@ public class UploadController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock")); } + + } diff --git a/API/Controllers/VolumeController.cs b/API/Controllers/VolumeController.cs new file mode 100644 index 000000000..5d23336b4 --- /dev/null +++ b/API/Controllers/VolumeController.cs @@ -0,0 +1,54 @@ +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Extensions; +using API.Services; +using API.SignalR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +public class VolumeController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IEventHub _eventHub; + + public VolumeController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub) + { + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _eventHub = eventHub; + } + + [HttpGet] + public async Task> GetVolume(int volumeId) + { + var volume = + await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId()); + + return Ok(volume); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete] + public async Task> DeleteVolume(int volumeId) + { + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, + VolumeIncludes.Chapters | VolumeIncludes.People | VolumeIncludes.Tags); + if (volume == null) + return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + + _unitOfWork.VolumeRepository.Remove(volume); + + if (await _unitOfWork.CommitAsync()) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false); + return Ok(true); + } + + return Ok(false); + } +} diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index a42c418af..067e64ad4 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -158,6 +158,33 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage /// public int TotalCount { get; set; } + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override AgeRating + /// + public bool AgeRatingLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override PublicationStatus + /// + public bool PublicationStatusLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WriterLocked { get; set; } + public bool CharacterLocked { get; set; } + public bool ColoristLocked { get; set; } + public bool EditorLocked { get; set; } + public bool InkerLocked { get; set; } + public bool ImprintLocked { get; set; } + public bool LettererLocked { get; set; } + public bool PencillerLocked { get; set; } + public bool PublisherLocked { get; set; } + public bool TranslatorLocked { get; set; } + public bool TeamLocked { get; set; } + public bool LocationLocked { get; set; } + public bool CoverArtistLocked { get; set; } + public bool ReleaseYearLocked { get; set; } + #endregion public string CoverImage { get; set; } diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index c5c56d15f..bbd93d618 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities.Enums; namespace API.DTOs.Metadata; @@ -7,6 +8,7 @@ namespace API.DTOs.Metadata; /// /// Exclusively metadata about a given chapter /// +[Obsolete("Will not be maintained as of v0.8.1")] public class ChapterMetadataDto { public int Id { get; set; } diff --git a/API/DTOs/UpdateChapterDto.cs b/API/DTOs/UpdateChapterDto.cs new file mode 100644 index 000000000..2ca0a12a9 --- /dev/null +++ b/API/DTOs/UpdateChapterDto.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using API.DTOs.Metadata; +using API.Entities.Enums; + +namespace API.DTOs; + +public class UpdateChapterDto +{ + public int Id { get; init; } + public string Summary { get; set; } = string.Empty; + + /// + /// Genres for the Chapter + /// + public ICollection Genres { get; set; } = new List(); + /// + /// Collection of all Tags from underlying chapters for a Chapter + /// + public ICollection Tags { get; set; } = new List(); + + public ICollection Writers { get; set; } = new List(); + public ICollection CoverArtists { get; set; } = new List(); + public ICollection Publishers { get; set; } = new List(); + public ICollection Characters { get; set; } = new List(); + public ICollection Pencillers { get; set; } = new List(); + public ICollection Inkers { get; set; } = new List(); + public ICollection Imprints { get; set; } = new List(); + public ICollection Colorists { get; set; } = new List(); + public ICollection Letterers { get; set; } = new List(); + public ICollection Editors { get; set; } = new List(); + public ICollection Translators { get; set; } = new List(); + public ICollection Teams { get; set; } = new List(); + public ICollection Locations { get; set; } = new List(); + + /// + /// Highest Age Rating from all Chapters + /// + public AgeRating AgeRating { get; set; } = AgeRating.Unknown; + /// + /// Language of the content (BCP-47 code) + /// + public string Language { get; set; } = string.Empty; + + + /// + /// Locked by user so metadata updates from scan loop will not override AgeRating + /// + public bool AgeRatingLocked { get; set; } + public bool TitleNameLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WriterLocked { get; set; } + public bool CharacterLocked { get; set; } + public bool ColoristLocked { get; set; } + public bool EditorLocked { get; set; } + public bool InkerLocked { get; set; } + public bool ImprintLocked { get; set; } + public bool LettererLocked { get; set; } + public bool PencillerLocked { get; set; } + public bool PublisherLocked { get; set; } + public bool TranslatorLocked { get; set; } + public bool TeamLocked { get; set; } + public bool LocationLocked { get; set; } + public bool CoverArtistLocked { get; set; } + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + public bool ISBNLocked { get; set; } + public bool ReleaseDateLocked { get; set; } + + /// + /// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden. + /// + public float SortOrder { get; set; } + /// + /// Can the sort order be updated on scan or is it locked from UI + /// + public bool SortOrderLocked { get; set; } + + /// + /// Comma-separated link of urls to external services that have some relation to the Chapter + /// + public string WebLinks { get; set; } = string.Empty; + public string ISBN { get; set; } = string.Empty; + /// + /// Date which chapter was released + /// + public DateTime ReleaseDate { get; set; } + /// + /// Chapter title + /// + /// This should not be confused with Title which is used for special filenames. + public string TitleName { get; set; } = string.Empty; +} diff --git a/API/DTOs/Uploads/UploadFileDto.cs b/API/DTOs/Uploads/UploadFileDto.cs index 236a554b8..72fe7da9b 100644 --- a/API/DTOs/Uploads/UploadFileDto.cs +++ b/API/DTOs/Uploads/UploadFileDto.cs @@ -10,4 +10,9 @@ public class UploadFileDto /// Base Url encoding of the file to upload from (can be null) /// public required string Url { get; set; } + + /// + /// Lock the cover or not + /// + public bool LockCover { get; set; } = true; } diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index bceccd43a..fd3202481 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -44,6 +44,7 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage public int MaxHoursToRead { get; set; } /// public int AvgHoursToRead { get; set; } + public long WordCount { get; set; } /// /// Is this a loose leaf volume @@ -64,6 +65,7 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage } public string CoverImage { get; set; } + private bool CoverImageLocked { get; set; } public string PrimaryColor { get; set; } public string SecondaryColor { get; set; } diff --git a/API/Data/Migrations/20240811154857_ChapterMetadataLocks.Designer.cs b/API/Data/Migrations/20240811154857_ChapterMetadataLocks.Designer.cs new file mode 100644 index 000000000..07723e833 --- /dev/null +++ b/API/Data/Migrations/20240811154857_ChapterMetadataLocks.Designer.cs @@ -0,0 +1,3142 @@ +// +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("20240811154857_ChapterMetadataLocks")] + partial class ChapterMetadataLocks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + + 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("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.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("INTEGER"); + + 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.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("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.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("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.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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.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.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("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("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("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("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20240811154857_ChapterMetadataLocks.cs b/API/Data/Migrations/20240811154857_ChapterMetadataLocks.cs new file mode 100644 index 000000000..b0b58b3b3 --- /dev/null +++ b/API/Data/Migrations/20240811154857_ChapterMetadataLocks.cs @@ -0,0 +1,249 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ChapterMetadataLocks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AgeRatingLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CharacterLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ColoristLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CoverArtistLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EditorLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "GenresLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ISBNLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ImprintLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InkerLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LanguageLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LettererLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LocationLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PencillerLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PublisherLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ReleaseDateLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SummaryLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TagsLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TeamLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TitleNameLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TranslatorLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "WriterLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AgeRatingLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "CharacterLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "ColoristLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "CoverArtistLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "EditorLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "GenresLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "ISBNLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "ImprintLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "InkerLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "LanguageLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "LettererLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "LocationLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "PencillerLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "PublisherLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "ReleaseDateLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "SummaryLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "TagsLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "TeamLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "TitleNameLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "TranslatorLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "WriterLocked", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20240813194728_VolumeCoverLocked.Designer.cs b/API/Data/Migrations/20240813194728_VolumeCoverLocked.Designer.cs new file mode 100644 index 000000000..1471c1de7 --- /dev/null +++ b/API/Data/Migrations/20240813194728_VolumeCoverLocked.Designer.cs @@ -0,0 +1,3145 @@ +// +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("20240813194728_VolumeCoverLocked")] + partial class VolumeCoverLocked + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + + 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("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.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("INTEGER"); + + 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.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("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.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("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.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("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("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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.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.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("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("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("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("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20240813194728_VolumeCoverLocked.cs b/API/Data/Migrations/20240813194728_VolumeCoverLocked.cs new file mode 100644 index 000000000..c9127ae6a --- /dev/null +++ b/API/Data/Migrations/20240813194728_VolumeCoverLocked.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class VolumeCoverLocked : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverImageLocked", + table: "Volume", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoverImageLocked", + table: "Volume"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 8dd522104..e5a087bad 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -719,6 +719,9 @@ namespace API.Data.Migrations b.Property("AgeRating") .HasColumnType("INTEGER"); + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + b.Property("AlternateCount") .HasColumnType("INTEGER"); @@ -731,9 +734,18 @@ namespace API.Data.Migrations b.Property("AvgHoursToRead") .HasColumnType("INTEGER"); + 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"); @@ -746,23 +758,47 @@ namespace API.Data.Migrations 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"); @@ -781,15 +817,24 @@ namespace API.Data.Migrations 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"); @@ -811,15 +856,30 @@ namespace API.Data.Migrations 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"); @@ -831,6 +891,9 @@ namespace API.Data.Migrations b.Property("WordCount") .HasColumnType("INTEGER"); + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("VolumeId"); @@ -1983,6 +2046,9 @@ namespace API.Data.Migrations b.Property("CoverImage") .HasColumnType("TEXT"); + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + b.Property("Created") .HasColumnType("TEXT"); diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 059eeb2e9..21eee7d31 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -22,12 +22,15 @@ public enum ChapterIncludes None = 1, Volumes = 2, Files = 4, - People = 8 + People = 8, + Genres = 16, + Tags = 32 } public interface IChapterRepository { void Update(Chapter chapter); + void Remove(Chapter chapter); Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None); Task GetChapterInfoDtoAsync(int chapterId); Task GetChapterTotalPagesAsync(int chapterId); @@ -60,6 +63,11 @@ public class ChapterRepository : IChapterRepository _context.Entry(chapter).State = EntityState.Modified; } + public void Remove(Chapter chapter) + { + _context.Chapter.Remove(chapter); + } + public async Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None) { return await _context.Chapter diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 965bf343e..af064a75e 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -36,6 +36,8 @@ public interface IReadingListRepository Task> GetReadingListItemsByIdAsync(int readingListId); Task> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, bool includePromoted); + Task> GetReadingListDtosForChapterAndUserAsync(int userId, int chapterId, + bool includePromoted); void Remove(ReadingListItem item); void Add(ReadingList list); void BulkRemove(IEnumerable items); @@ -166,6 +168,8 @@ public class ReadingListRepository : IReadingListRepository .ToListAsync(); } + + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); @@ -204,6 +208,19 @@ public class ReadingListRepository : IReadingListRepository return await query.ToListAsync(); } + public async Task> GetReadingListDtosForChapterAndUserAsync(int userId, int chapterId, bool includePromoted) + { + var query = _context.ReadingList + .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .Where(l => l.Items.Any(i => i.ChapterId == chapterId)) + .AsSplitQuery() + .OrderBy(l => l.Title) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking(); + + return await query.ToListAsync(); + } + public async Task GetReadingListByIdAsync(int readingListId, ReadingListIncludes includes = ReadingListIncludes.None) { return await _context.ReadingList diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index fb894121c..8e9c6cb0b 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using API.Entities.Enums; using API.Entities.Interfaces; using API.Extensions; @@ -125,6 +124,32 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage public string WebLinks { get; set; } = string.Empty; public string ISBN { get; set; } = string.Empty; + #region Locks + + public bool AgeRatingLocked { get; set; } + public bool TitleNameLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WriterLocked { get; set; } + public bool CharacterLocked { get; set; } + public bool ColoristLocked { get; set; } + public bool EditorLocked { get; set; } + public bool InkerLocked { get; set; } + public bool ImprintLocked { get; set; } + public bool LettererLocked { get; set; } + public bool PencillerLocked { get; set; } + public bool PublisherLocked { get; set; } + public bool TranslatorLocked { get; set; } + public bool TeamLocked { get; set; } + public bool LocationLocked { get; set; } + public bool CoverArtistLocked { get; set; } + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + public bool ISBNLocked { get; set; } + public bool ReleaseDateLocked { get; set; } + + #endregion + /// /// All people attached at a Chapter level. Usually Comics will have different people per issue. /// diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 17c15b978..4c2d7ea61 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -32,13 +32,13 @@ public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage /// The maximum number in the Name field (same as Minimum if Name isn't a range) /// public required float MaxNumber { get; set; } - public IList Chapters { get; set; } = null!; public DateTime Created { get; set; } public DateTime LastModified { get; set; } public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } public string? CoverImage { get; set; } + public bool CoverImageLocked { get; set; } public string PrimaryColor { get; set; } public string SecondaryColor { get; set; } @@ -57,6 +57,7 @@ public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage // Relationships + public IList Chapters { get; set; } = null!; public Series Series { get; set; } = null!; public int SeriesId { get; set; } diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index be26a1762..bcd242bcc 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -59,6 +59,18 @@ public static class IncludesExtensions .Include(c => c.People); } + if (includes.HasFlag(ChapterIncludes.Genres)) + { + queryable = queryable + .Include(c => c.Genres); + } + + if (includes.HasFlag(ChapterIncludes.Tags)) + { + queryable = queryable + .Include(c => c.Tags); + } + return queryable.AsSplitQuery(); } diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index e9e953bd1..e13a1783c 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -108,4 +108,49 @@ public static class GenreHelper onModified(); } } + + public static void UpdateGenreList(ICollection? tags, Chapter chapter, + IReadOnlyCollection allTags, Action handleAdd, Action onModified) + { + // TODO: Write some unit tests + if (tags == null) return; + var isModified = false; + // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different + var existingTags = chapter.Genres.ToList(); + foreach (var existing in existingTags) + { + if (tags.SingleOrDefault(t => t.Title.ToNormalized().Equals(existing.NormalizedTitle)) == null) + { + // Remove tag + chapter.Genres.Remove(existing); + isModified = true; + } + } + + // At this point, all tags that aren't in dto have been removed. + foreach (var tagTitle in tags.Select(t => t.Title)) + { + var normalizedTitle = tagTitle.ToNormalized(); + var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle)); + if (existingTag != null) + { + if (chapter.Genres.All(t => !t.NormalizedTitle.Equals(normalizedTitle))) + { + handleAdd(existingTag); + isModified = true; + } + } + else + { + // Add new tag + handleAdd(new GenreBuilder(tagTitle).Build()); + isModified = true; + } + } + + if (isModified) + { + onModified(); + } + } } diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 0874dc3fc..71da50b59 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -165,18 +165,76 @@ public static class PersonHelper } } - public static bool HasAnyPeople(SeriesMetadataDto? seriesMetadata) + public static void UpdatePeopleList(PersonRole role, ICollection? people, Chapter chapter, IReadOnlyCollection allPeople, + Action handleAdd, Action onModified) { - if (seriesMetadata == null) return false; - return seriesMetadata.Writers.Any() || - seriesMetadata.CoverArtists.Any() || - seriesMetadata.Publishers.Any() || - seriesMetadata.Characters.Any() || - seriesMetadata.Pencillers.Any() || - seriesMetadata.Inkers.Any() || - seriesMetadata.Colorists.Any() || - seriesMetadata.Letterers.Any() || - seriesMetadata.Editors.Any() || - seriesMetadata.Translators.Any(); + if (people == null) return; + var isModified = false; + // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different + var existingTags = chapter.People.Where(p => p.Role == role).ToList(); + foreach (var existing in existingTags) + { + if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role + { + // Remove tag + chapter.People.Remove(existing); + isModified = true; + } + } + + // At this point, all tags that aren't in dto have been removed. + foreach (var tag in people) + { + var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); + if (existingTag != null) + { + if (chapter.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name))) + { + handleAdd(existingTag); + isModified = true; + } + } + else + { + // Add new tag + handleAdd(new PersonBuilder(tag.Name, role).Build()); + isModified = true; + } + } + + if (isModified) + { + onModified(); + } + } + + public static bool HasAnyPeople(SeriesMetadataDto? dto) + { + if (dto == null) return false; + return dto.Writers.Count != 0 || + dto.CoverArtists.Count != 0 || + dto.Publishers.Count != 0 || + dto.Characters.Count != 0 || + dto.Pencillers.Count != 0 || + dto.Inkers.Count != 0 || + dto.Colorists.Count != 0 || + dto.Letterers.Count != 0 || + dto.Editors.Count != 0 || + dto.Translators.Count != 0; + } + + public static bool HasAnyPeople(UpdateChapterDto? dto) + { + if (dto == null) return false; + return dto.Writers.Count != 0 || + dto.CoverArtists.Count != 0 || + dto.Publishers.Count != 0 || + dto.Characters.Count != 0 || + dto.Pencillers.Count != 0 || + dto.Inkers.Count != 0 || + dto.Colorists.Count != 0 || + dto.Letterers.Count != 0 || + dto.Editors.Count != 0 || + dto.Translators.Count != 0; } } diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index 91a355dec..91e2506da 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -151,6 +151,48 @@ public static class TagHelper onModified(); } } + + public static void UpdateTagList(ICollection? tags, Chapter chapter, IReadOnlyCollection allTags, Action handleAdd, Action onModified) + { + if (tags == null) return; + + var isModified = false; + // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different + var existingTags = chapter.Tags.ToList(); + foreach (var existing in existingTags.Where(existing => tags.SingleOrDefault(t => t.Id == existing.Id) == null)) + { + // Remove tag + chapter.Tags.Remove(existing); + isModified = true; + } + + // At this point, all tags that aren't in dto have been removed. + foreach (var tagTitle in tags.Select(t => t.Title)) + { + var normalizedTitle = tagTitle.ToNormalized(); + var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle)); + if (existingTag != null) + { + if (chapter.Tags.All(t => t.NormalizedTitle != normalizedTitle)) + { + + handleAdd(existingTag); + isModified = true; + } + } + else + { + // Add new tag + handleAdd(new TagBuilder(tagTitle).Build()); + isModified = true; + } + } + + if (isModified) + { + onModified(); + } + } } #nullable disable diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index ad6829b7d..22b1349d5 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -580,8 +580,8 @@ public class ImageService : IImageService { return colors.OrderByDescending(c => { - float max = Math.Max(c.X, Math.Max(c.Y, c.Z)); - float min = Math.Min(c.X, Math.Min(c.Y, c.Z)); + var max = Math.Max(c.X, Math.Max(c.Y, c.Z)); + var min = Math.Min(c.X, Math.Min(c.Y, c.Z)); return (max - min) / max; }).ToList(); } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 8e8ba4cf0..e92f9f77b 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -75,8 +75,10 @@ public class MetadataService : IMetadataService /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image /// Convert image to Encoding Format when extracting the cover - private Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize) + private Task UpdateChapterCoverImage(Chapter? chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize) { + if (chapter == null) return Task.FromResult(false); + var firstFile = chapter.Files.MinBy(x => x.Chapter); if (firstFile == null) return Task.FromResult(false); @@ -133,7 +135,9 @@ public class MetadataService : IMetadataService private Task UpdateVolumeCoverImage(Volume? volume, bool forceUpdate) { // We need to check if Volume coverImage matches first chapters if forceUpdate is false - if (volume == null || !_cacheHelper.ShouldUpdateCoverImage( + if (volume == null) return Task.FromResult(false); + + if (!_cacheHelper.ShouldUpdateCoverImage( _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage), null, volume.Created, forceUpdate)) { @@ -146,18 +150,20 @@ public class MetadataService : IMetadataService return Task.FromResult(false); } - // For cover selection, chapters need to try for issue 1 first, then fallback to first sort order - volume.Chapters ??= new List(); - - var firstChapter = volume.Chapters.FirstOrDefault(x => x.MinNumber.Is(1f)); - if (firstChapter == null) + if (!volume.CoverImageLocked) { - firstChapter = volume.Chapters.MinBy(x => x.SortOrder, ChapterSortComparerDefaultFirst.Default); - if (firstChapter == null) return Task.FromResult(false); + // For cover selection, chapters need to try for issue 1 first, then fallback to first sort order + volume.Chapters ??= new List(); + + var firstChapter = volume.Chapters.FirstOrDefault(x => x.MinNumber.Is(1f)); + if (firstChapter == null) + { + firstChapter = volume.Chapters.MinBy(x => x.SortOrder, ChapterSortComparerDefaultFirst.Default); + if (firstChapter == null) return Task.FromResult(false); + } + + volume.CoverImage = firstChapter.CoverImage; } - - - volume.CoverImage = firstChapter.CoverImage; _imageService.UpdateColorScape(volume); _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 3c347e39d..5c74272c1 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -129,62 +129,81 @@ public class ParseScannedFiles var result = new List(); + // Not to self: this whole thing can be parallelized because we don't deal with any DB or global state if (scanDirectoryByDirectory) { - var directories = _directoryService.GetDirectories(folderPath, matcher).Select(Parser.Parser.NormalizePath); - foreach (var directory in directories) - { - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated)); - - - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, directory, forceCheck)) - { - if (result.Exists(r => r.Folder == directory)) - { - _logger.LogDebug("[ProcessFiles] Skipping adding {Directory} as it's already added", directory); - continue; - } - result.Add(CreateScanResult(directory, folderPath, false, ArraySegment.Empty)); - } - else if (!forceCheck && seriesPaths.TryGetValue(directory, out var series) && series.Count > 1 && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath))) - { - // If there are multiple series inside this path, let's check each of them to see which was modified and only scan those - // This is very helpful for ComicVine libraries by Publisher - _logger.LogDebug("[ProcessFiles] {Directory} is dirty and has multiple series folders, checking if we can avoid a full scan", directory); - foreach (var seriesModified in series) - { - var hasFolderChangedSinceLastScan = seriesModified.LastScanned.Truncate(TimeSpan.TicksPerSecond) < - _directoryService - .GetLastWriteTime(seriesModified.LowestFolderPath!) - .Truncate(TimeSpan.TicksPerSecond); - - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.FileScanProgressEvent(seriesModified.LowestFolderPath!, library.Name, ProgressEventType.Updated)); - - if (!hasFolderChangedSinceLastScan) - { - _logger.LogTrace("[ProcessFiles] {Directory} subfolder {Folder} did not change since last scan, adding entry to skip", directory, seriesModified.LowestFolderPath); - result.Add(CreateScanResult(seriesModified.LowestFolderPath!, folderPath, false, ArraySegment.Empty)); - } - else - { - _logger.LogTrace("[ProcessFiles] {Directory} subfolder {Folder} changed for Series {SeriesName}", directory, seriesModified.LowestFolderPath, seriesModified.SeriesName); - result.Add(CreateScanResult(directory, folderPath, true, - _directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher))); - } - } - } - else - { - result.Add(CreateScanResult(directory, folderPath, true, - _directoryService.ScanFiles(directory, fileExtensions, matcher))); - } - } - - return result; + return await ScanDirectories(folderPath, seriesPaths, library, forceCheck, matcher, result, fileExtensions); } + return await ScanSingleDirectory(folderPath, seriesPaths, library, forceCheck, result, fileExtensions, matcher); + } + + private async Task> ScanDirectories(string folderPath, IDictionary> seriesPaths, Library library, bool forceCheck, + GlobMatcher matcher, List result, string fileExtensions) + { + var directories = _directoryService.GetDirectories(folderPath, matcher).Select(Parser.Parser.NormalizePath); + foreach (var directory in directories) + { + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated)); + + + if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, directory, forceCheck)) + { + if (result.Exists(r => r.Folder == directory)) + { + _logger.LogDebug("[ProcessFiles] Skipping adding {Directory} as it's already added", directory); + continue; + } + _logger.LogDebug("[ProcessFiles] Skipping {Directory} as it hasn't changed since last scan", directory); + result.Add(CreateScanResult(directory, folderPath, false, ArraySegment.Empty)); + } + else if (!forceCheck && seriesPaths.TryGetValue(directory, out var series) + && series.Count > 1 && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath))) + { + // If there are multiple series inside this path, let's check each of them to see which was modified and only scan those + // This is very helpful for ComicVine libraries by Publisher + + // TODO: BUG: We might miss new folders this way. Likely need to get all folder names and see if there are any that aren't in known series list + + _logger.LogDebug("[ProcessFiles] {Directory} is dirty and has multiple series folders, checking if we can avoid a full scan", directory); + foreach (var seriesModified in series) + { + var hasFolderChangedSinceLastScan = seriesModified.LastScanned.Truncate(TimeSpan.TicksPerSecond) < + _directoryService + .GetLastWriteTime(seriesModified.LowestFolderPath!) + .Truncate(TimeSpan.TicksPerSecond); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent(seriesModified.LowestFolderPath!, library.Name, ProgressEventType.Updated)); + + if (!hasFolderChangedSinceLastScan) + { + _logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} did not change since last scan, adding entry to skip", directory, seriesModified.LowestFolderPath); + result.Add(CreateScanResult(seriesModified.LowestFolderPath!, folderPath, false, ArraySegment.Empty)); + } + else + { + _logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} changed for Series {SeriesName}", directory, seriesModified.LowestFolderPath, seriesModified.SeriesName); + result.Add(CreateScanResult(directory, folderPath, true, + _directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher))); + } + } + } + else + { + _logger.LogDebug("[ProcessFiles] Performing file scan on {Directory}", directory); + var files = _directoryService.ScanFiles(directory, fileExtensions, matcher); + result.Add(CreateScanResult(directory, folderPath, true, files)); + } + } + + return result; + } + + private async Task> ScanSingleDirectory(string folderPath, IDictionary> seriesPaths, Library library, bool forceCheck, List result, + string fileExtensions, GlobMatcher matcher) + { var normalizedPath = Parser.Parser.NormalizePath(folderPath); var libraryRoot = library.Folders.FirstOrDefault(f => @@ -204,7 +223,6 @@ public class ParseScannedFiles _directoryService.ScanFiles(folderPath, fileExtensions, matcher))); } - return result; } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 5aa8faba0..0f1340d4e 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -760,12 +760,14 @@ public class ProcessSeries : IProcessSeries chapter.Number = Parser.Parser.MinNumberFromRange(info.Chapters).ToString(CultureInfo.InvariantCulture); chapter.MinNumber = Parser.Parser.MinNumberFromRange(info.Chapters); chapter.MaxNumber = Parser.Parser.MaxNumberFromRange(info.Chapters); + chapter.Range = chapter.GetNumberTitle(); + if (!chapter.SortOrderLocked) { chapter.SortOrder = info.IssueOrder; } - chapter.Range = chapter.GetNumberTitle(); - if (float.TryParse(chapter.Title, out var _)) + + if (float.TryParse(chapter.Title, out _)) { // If we have float based chapters, first scan can have the chapter formatted as Chapter 0.2 - .2 as the title is wrong. chapter.Title = chapter.GetNumberTitle(); @@ -832,19 +834,22 @@ public class ProcessSeries : IProcessSeries _logger.LogTrace("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath); - chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating); + if (!chapter.AgeRatingLocked) + { + chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating); + } - if (!string.IsNullOrEmpty(comicInfo.Title)) + if (!chapter.TitleNameLocked && !string.IsNullOrEmpty(comicInfo.Title)) { chapter.TitleName = comicInfo.Title.Trim(); } - if (!string.IsNullOrEmpty(comicInfo.Summary)) + if (!chapter.SummaryLocked && !string.IsNullOrEmpty(comicInfo.Summary)) { chapter.Summary = comicInfo.Summary; } - if (!string.IsNullOrEmpty(comicInfo.LanguageISO)) + if (!chapter.LanguageLocked && !string.IsNullOrEmpty(comicInfo.LanguageISO)) { chapter.Language = comicInfo.LanguageISO; } @@ -888,7 +893,7 @@ public class ProcessSeries : IProcessSeries // For each weblink, try to parse out some MetadataIds and store in the Chapter directly for matching (CBL) } - if (!string.IsNullOrEmpty(comicInfo.Isbn)) + if (!chapter.ISBNLocked && !string.IsNullOrEmpty(comicInfo.Isbn)) { chapter.ISBN = comicInfo.Isbn; } @@ -902,84 +907,129 @@ public class ProcessSeries : IProcessSeries chapter.Count = comicInfo.CalculatedCount(); - if (comicInfo.Year > 0) + if (!chapter.ReleaseDateLocked && comicInfo.Year > 0) { var day = Math.Max(comicInfo.Day, 1); var month = Math.Max(comicInfo.Month, 1); chapter.ReleaseDate = new DateTime(comicInfo.Year, month, day); } - var people = TagHelper.GetTagValues(comicInfo.Colorist); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist); - await UpdatePeople(chapter, people, PersonRole.Colorist); - - people = TagHelper.GetTagValues(comicInfo.Characters); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character); - await UpdatePeople(chapter, people, PersonRole.Character); - - - people = TagHelper.GetTagValues(comicInfo.Translator); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator); - await UpdatePeople(chapter, people, PersonRole.Translator); - - - people = TagHelper.GetTagValues(comicInfo.Writer); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer); - await UpdatePeople(chapter, people, PersonRole.Writer); - - people = TagHelper.GetTagValues(comicInfo.Editor); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor); - await UpdatePeople(chapter, people, PersonRole.Editor); - - people = TagHelper.GetTagValues(comicInfo.Inker); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker); - await UpdatePeople(chapter, people, PersonRole.Inker); - - people = TagHelper.GetTagValues(comicInfo.Letterer); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer); - await UpdatePeople(chapter, people, PersonRole.Letterer); - - people = TagHelper.GetTagValues(comicInfo.Penciller); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller); - await UpdatePeople(chapter, people, PersonRole.Penciller); - - people = TagHelper.GetTagValues(comicInfo.CoverArtist); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist); - await UpdatePeople(chapter, people, PersonRole.CoverArtist); - - people = TagHelper.GetTagValues(comicInfo.Publisher); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher); - await UpdatePeople(chapter, people, PersonRole.Publisher); - - people = TagHelper.GetTagValues(comicInfo.Imprint); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Imprint); - await UpdatePeople(chapter, people, PersonRole.Imprint); - - people = TagHelper.GetTagValues(comicInfo.Teams); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Team); - await UpdatePeople(chapter, people, PersonRole.Team); - - people = TagHelper.GetTagValues(comicInfo.Locations); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Location); - await UpdatePeople(chapter, people, PersonRole.Location); - - var genres = TagHelper.GetTagValues(comicInfo.Genre); - GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, - genres.Select(g => new GenreBuilder(g).Build()).ToList()); - foreach (var genre in genres) + if (!chapter.ColoristLocked) { - var g = await _tagManagerService.GetGenre(genre); - if (g == null) continue; - chapter.Genres.Add(g); + var people = TagHelper.GetTagValues(comicInfo.Colorist); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist); + await UpdatePeople(chapter, people, PersonRole.Colorist); } - var tags = TagHelper.GetTagValues(comicInfo.Tags); - TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => new TagBuilder(t).Build()).ToList()); - foreach (var tag in tags) + if (!chapter.CharacterLocked) { - var t = await _tagManagerService.GetTag(tag); - if (t == null) continue; - chapter.Tags.Add(t); + var people = TagHelper.GetTagValues(comicInfo.Characters); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character); + await UpdatePeople(chapter, people, PersonRole.Character); + } + + + if (!chapter.TranslatorLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Translator); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator); + await UpdatePeople(chapter, people, PersonRole.Translator); + } + + if (!chapter.WriterLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Writer); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer); + await UpdatePeople(chapter, people, PersonRole.Writer); + } + + if (!chapter.EditorLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Editor); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor); + await UpdatePeople(chapter, people, PersonRole.Editor); + } + + if (!chapter.InkerLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Inker); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker); + await UpdatePeople(chapter, people, PersonRole.Inker); + } + + if (!chapter.LettererLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Letterer); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer); + await UpdatePeople(chapter, people, PersonRole.Letterer); + } + + if (!chapter.PencillerLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Penciller); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller); + await UpdatePeople(chapter, people, PersonRole.Penciller); + } + + if (!chapter.CoverArtistLocked) + { + var people = TagHelper.GetTagValues(comicInfo.CoverArtist); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist); + await UpdatePeople(chapter, people, PersonRole.CoverArtist); + } + + if (!chapter.PublisherLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Publisher); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher); + await UpdatePeople(chapter, people, PersonRole.Publisher); + } + + if (!chapter.ImprintLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Imprint); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Imprint); + await UpdatePeople(chapter, people, PersonRole.Imprint); + } + + if (!chapter.TeamLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Teams); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Team); + await UpdatePeople(chapter, people, PersonRole.Team); + } + + if (!chapter.LocationLocked) + { + var people = TagHelper.GetTagValues(comicInfo.Locations); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Location); + await UpdatePeople(chapter, people, PersonRole.Location); + } + + + if (!chapter.GenresLocked) + { + var genres = TagHelper.GetTagValues(comicInfo.Genre); + GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, + genres.Select(g => new GenreBuilder(g).Build()).ToList()); + foreach (var genre in genres) + { + var g = await _tagManagerService.GetGenre(genre); + if (g == null) continue; + chapter.Genres.Add(g); + } + } + + if (!chapter.TagsLocked) + { + var tags = TagHelper.GetTagValues(comicInfo.Tags); + TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => new TagBuilder(t).Build()).ToList()); + foreach (var tag in tags) + { + var t = await _tagManagerService.GetTag(tag); + if (t == null) continue; + chapter.Tags.Add(t); + } } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 27bca2a80..49f7d2275 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -138,6 +138,14 @@ public static class MessageFactory /// A Progress event when a smart collection is synchronizing /// public const string SmartCollectionSync = "SmartCollectionSync"; + /// + /// Chapter is removed from server + /// + public const string ChapterRemoved = "ChapterRemoved"; + /// + /// Volume is removed from server + /// + public const string VolumeRemoved = "VolumeRemoved"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -213,6 +221,32 @@ public static class MessageFactory }; } + public static SignalRMessage ChapterRemovedEvent(int chapterId, int seriesId) + { + return new SignalRMessage() + { + Name = ChapterRemoved, + Body = new + { + SeriesId = seriesId, + ChapterId = chapterId + } + }; + } + + public static SignalRMessage VolumeRemovedEvent(int volumeId, int seriesId) + { + return new SignalRMessage() + { + Name = VolumeRemoved, + Body = new + { + SeriesId = seriesId, + VolumeId = volumeId + } + }; + } + public static SignalRMessage WordCountAnalyzerProgressEvent(int libraryId, float progress, string eventType, string subtitle = "") { diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 5c4a5c95b..09af58f19 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -504,6 +504,7 @@ "version": "17.3.4", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz", "integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==", + "dev": true, "dependencies": { "@babel/core": "7.23.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -531,6 +532,7 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -559,12 +561,14 @@ "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -745,6 +749,7 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -773,12 +778,14 @@ "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -5622,6 +5629,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5634,6 +5642,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -5905,6 +5914,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "engines": { "node": ">=8" }, @@ -6216,6 +6226,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6507,7 +6518,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/cookie": { "version": "0.6.0", @@ -7409,6 +7421,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" @@ -7418,6 +7431,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" @@ -8526,6 +8540,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -9207,6 +9222,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -11047,6 +11063,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -12436,6 +12453,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -12447,6 +12465,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -12457,7 +12476,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/regenerate": { "version": "1.4.2", @@ -12925,7 +12945,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.71.1", @@ -13044,6 +13064,7 @@ "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -13058,6 +13079,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -13068,7 +13090,8 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/send": { "version": "0.18.0", @@ -14199,6 +14222,7 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss new file mode 100644 index 000000000..486f2b61d --- /dev/null +++ b/UI/Web/src/_card-item-common.scss @@ -0,0 +1,203 @@ + + +$image-height: 230px; +$image-width: 160px; + +.error-banner { + width: $image-width; + height: 18px; + background-color: var(--toast-error-bg-color); + font-size: 12px; + color: white; + text-transform: uppercase; + text-align: center; + + position: absolute; + top: 0px; + right: 0px; +} + +.selected-highlight { + outline: 2px solid var(--primary-color); +} + + +.progress-banner { + width: $image-width; + height: 5px; + + .progress { + color: var(--card-progress-bar-color); + background-color: transparent; + } +} + +.download { + width: 80px; + height: 80px; + position: absolute; + top: 25%; + right: 30%; +} + +.badge-container { + border-radius: 4px; + display: block; + height: $image-height; + left: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + top: 0; + width: 158px; +} + +.not-read-badge { + position: absolute; + top: calc(-1 * (var(--card-progress-triangle-size) / 2)); + right: -14px; + z-index: 1000; + height: var(--card-progress-triangle-size); + width: var(--card-progress-triangle-size); + background-color: var(--primary-color); + transform: rotate(45deg); +} + + +.bulk-mode { + position: absolute; + top: 5px; + left: 5px; + visibility: hidden; + + &.always-show { + visibility: visible !important; + width: $image-width; + height: $image-height; + } + + input[type="checkbox"] { + width: 20px; + height: 20px; + color: var(--checkbox-bg-color); + } +} + +.meta-title { + display: none; + visibility: hidden; + pointer-events: none; + border-width: 0; +} + + +.overlay { + &:hover { + .bulk-mode { + visibility: visible; + z-index: 110; + } + + &:hover { + visibility: visible; + + .overlay-information { + visibility: visible; + display: block; + } + + & + .meta-title { + display: -webkit-box; + visibility: visible; + pointer-events: none; + } + } + + .overlay-information { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 230px; + transition: all 0.2s; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + + &:hover { + background-color: var(--card-overlay-hover-bg-color); + cursor: pointer; + } + + .overlay-information--centered { + position: absolute; + border-radius: 15px; + background-color: rgba(0, 0, 0, .7); + border-radius: 50px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 115; + + &:hover { + background-color: var(--primary-color) !important; + cursor: pointer; + } + } + } + } + + .count { + top: 5px; + right: 10px; + position: absolute; + } +} + +.card-actions { + position: absolute; + bottom: 0; + right: 0; + z-index: 115; + visibility: hidden; +} + +.library { + font-size: 13px; + text-decoration: none; + margin-top: 0px; +} + +.card-title { + font-size: 0.8rem; +} + +.card-body > div:nth-child(2) { + height: 40px; + overflow: hidden; + -webkit-line-clamp: 2; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + font-size: 0.8rem; +} + +.overlay-information { + visibility: hidden; + display: none; +} + +.chapter, +.volume, +.series, +.expected { + .overlay-information--centered { + div { + height: 32px; + width: 32px; + i { + font-size: 1.4rem; + line-height: 32px; + } + } + } +} \ No newline at end of file diff --git a/UI/Web/src/_series-detail-common.scss b/UI/Web/src/_series-detail-common.scss new file mode 100644 index 000000000..ca4f7c849 --- /dev/null +++ b/UI/Web/src/_series-detail-common.scss @@ -0,0 +1,121 @@ +.title { + color: white; + font-weight: bold; + font-size: 1.75rem; +} + +.image-container { + align-self: flex-start; + max-height: 400px; + max-width: 280px; +} + +.subtitle { + color: lightgrey; + font-weight: bold; + font-size: 0.8rem; +} + +.btn { + //padding: 4px 8px !important; + //font-size: 0.8rem !important; +} + +.btn-group > .btn.dropdown-toggle-split:not(first-child){ + border-top-right-radius: var(--bs-border-radius) !important; + border-bottom-right-radius: var(--bs-border-radius) !important; + border-width: 1px 1px 1px 0 !important; +} + +.btn-group > .btn:not(:last-child):not(.dropdown-toggle) { + border-width: 1px 0 1px 1px !important; +} + +.card-body > div:nth-child(2) { + height: 50px; + overflow: hidden; + -webkit-line-clamp: 2; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; +} + +.under-image ~ .overlay-information { + top: -404px; + height: 364px; +} + +.overlay-information { + position: relative; + top: -364px; + height: 364px; + transition: all 0.2s; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + + &:hover { + cursor: pointer; + background-color: var(--card-overlay-hover-bg-color) !important; + + .overlay-information--centered { + visibility: visible; + } + } + + .overlay-information--centered { + position: absolute; + border-radius: 15px; + background-color: rgba(0, 0, 0, .7); + border-radius: 50px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 115; + visibility: hidden; + + &:hover { + background-color: var(--primary-color) !important; + cursor: pointer; + } + + div { + width: 60px; + height: 60px; + i { + font-size: 1.6rem; + line-height: 60px; + width: 100%; + } + } + } +} + +.progress { + border-radius: 0; +} + +.progress-banner.series { + position: relative; +} + +::ng-deep .progress-banner.series span { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + color: white; + top: 50%; +} + +.carousel-tabs-container { + overflow-x: auto; + white-space: nowrap; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + scrollbar-width: none; +} +.carousel-tabs-container::-webkit-scrollbar { + display: none; +} +.nav-tabs { + flex-wrap: nowrap; +} diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index db846e911..3e21b37a6 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -4,6 +4,9 @@ import {PublicationStatus} from "./metadata/publication-status"; import {Genre} from "./metadata/genre"; import {Tag} from "./tag"; import {Person} from "./metadata/person"; +import {IHasCast} from "./common/i-has-cast"; +import {IHasReadingTime} from "./common/i-has-reading-time"; +import {IHasCover} from "./common/i-has-cover"; export const LooseLeafOrDefaultNumber = -100000; export const SpecialVolumeNumber = 100000; @@ -11,7 +14,7 @@ export const SpecialVolumeNumber = 100000; /** * Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields. */ -export interface Chapter { +export interface Chapter extends IHasCast, IHasReadingTime, IHasCover { id: number; range: string; /** @@ -56,30 +59,51 @@ export interface Chapter { lastReadingProgress: string; sortOrder: number; - // originally in ChapterMetadata but now inlined with Chapter data + primaryColor: string; + secondaryColor: string; - year: string; - language: string; - publicationStatus: PublicationStatus; - count: number; - totalCount: number; + year: string; + language: string; + publicationStatus: PublicationStatus; + count: number; + totalCount: number; - genres: Array; - tags: Array; - writers: Array; - coverArtists: Array; - publishers: Array; - characters: Array; - pencillers: Array; - inkers: Array; - imprints: Array; - colorists: Array; - letterers: Array; - editors: Array; - translators: Array; - teams: Array; - locations: Array; + genres: Array; + tags: Array; + writers: Array; + coverArtists: Array; + publishers: Array; + characters: Array; + pencillers: Array; + inkers: Array; + imprints: Array; + colorists: Array; + letterers: Array; + editors: Array; + translators: Array; + teams: Array; + locations: Array; - primaryColor: string; - secondaryColor: string; + summaryLocked: boolean; + genresLocked: boolean; + tagsLocked: boolean; + writerLocked: boolean; + coverArtistLocked: boolean; + publisherLocked: boolean; + characterLocked: boolean; + pencillerLocked: boolean; + inkerLocked: boolean; + imprintLocked: boolean; + coloristLocked: boolean; + lettererLocked: boolean; + editorLocked: boolean; + translatorLocked: boolean; + teamLocked: boolean; + locationLocked: boolean; + ageRatingLocked: boolean; + languageLocked: boolean; + isbnLocked: boolean; + titleNameLocked: boolean; + sortOrderLocked: boolean; + releaseDateLocked: boolean; } diff --git a/UI/Web/src/app/_models/common/i-has-cast.ts b/UI/Web/src/app/_models/common/i-has-cast.ts new file mode 100644 index 000000000..351352cb2 --- /dev/null +++ b/UI/Web/src/app/_models/common/i-has-cast.ts @@ -0,0 +1,50 @@ +import {Person} from "../metadata/person"; + +export interface IHasCast { + writerLocked: boolean; + coverArtistLocked: boolean; + publisherLocked: boolean; + characterLocked: boolean; + pencillerLocked: boolean; + inkerLocked: boolean; + imprintLocked: boolean; + coloristLocked: boolean; + lettererLocked: boolean; + editorLocked: boolean; + translatorLocked: boolean; + teamLocked: boolean; + locationLocked: boolean; + languageLocked: boolean; + + writers: Array; + coverArtists: Array; + publishers: Array; + characters: Array; + pencillers: Array; + inkers: Array; + imprints: Array; + colorists: Array; + letterers: Array; + editors: Array; + translators: Array; + teams: Array; + locations: Array; +} + +export function hasAnyCast(entity: IHasCast | null | undefined): boolean { + if (entity === null || entity === undefined) return false; + + return entity.writers.length > 0 || + entity.coverArtists.length > 0 || + entity.publishers.length > 0 || + entity.characters.length > 0 || + entity.pencillers.length > 0 || + entity.inkers.length > 0 || + entity.imprints.length > 0 || + entity.colorists.length > 0 || + entity.letterers.length > 0 || + entity.editors.length > 0 || + entity.translators.length > 0 || + entity.teams.length > 0 || + entity.locations.length > 0; +} diff --git a/UI/Web/src/app/_models/common/i-has-cover.ts b/UI/Web/src/app/_models/common/i-has-cover.ts new file mode 100644 index 000000000..7e58bbcbb --- /dev/null +++ b/UI/Web/src/app/_models/common/i-has-cover.ts @@ -0,0 +1,5 @@ +export interface IHasCover { + coverImage?: string; + primaryColor: string; + secondaryColor: string; +} diff --git a/UI/Web/src/app/_models/common/i-has-reading-time.ts b/UI/Web/src/app/_models/common/i-has-reading-time.ts new file mode 100644 index 000000000..41753d1fd --- /dev/null +++ b/UI/Web/src/app/_models/common/i-has-reading-time.ts @@ -0,0 +1,8 @@ +export interface IHasReadingTime { + minHoursToRead: number; + maxHoursToRead: number; + avgHoursToRead: number; + pages: number; + wordCount: number; + +} diff --git a/UI/Web/src/app/_models/events/chapter-removed-event.ts b/UI/Web/src/app/_models/events/chapter-removed-event.ts new file mode 100644 index 000000000..5413a1923 --- /dev/null +++ b/UI/Web/src/app/_models/events/chapter-removed-event.ts @@ -0,0 +1,4 @@ +export interface ChapterRemovedEvent { + chapterId: number; + seriesId: number; +} diff --git a/UI/Web/src/app/_models/events/volume-removed-event.ts b/UI/Web/src/app/_models/events/volume-removed-event.ts new file mode 100644 index 000000000..1ce2dc2ed --- /dev/null +++ b/UI/Web/src/app/_models/events/volume-removed-event.ts @@ -0,0 +1,4 @@ +export interface VolumeRemovedEvent { + volumeId: number; + seriesId: number; +} diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index c8b55bd1d..f834f23b1 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -1,4 +1,5 @@ import {FileTypeGroup} from "./file-type-group.enum"; +import {IHasCover} from "../common/i-has-cover"; export enum LibraryType { Manga = 0, diff --git a/UI/Web/src/app/_models/metadata/series-metadata.ts b/UI/Web/src/app/_models/metadata/series-metadata.ts index 7e104c390..fc691ee93 100644 --- a/UI/Web/src/app/_models/metadata/series-metadata.ts +++ b/UI/Web/src/app/_models/metadata/series-metadata.ts @@ -3,8 +3,9 @@ import { AgeRating } from "./age-rating"; import { PublicationStatus } from "./publication-status"; import { Person } from "./person"; import { Tag } from "../tag"; +import {IHasCast} from "../common/i-has-cast"; -export interface SeriesMetadata { +export interface SeriesMetadata extends IHasCast { seriesId: number; summary: string; diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index 2e958e398..123fb3b85 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -1,5 +1,6 @@ import { LibraryType } from "./library/library"; import { MangaFormat } from "./manga-format"; +import {IHasCover} from "./common/i-has-cover"; export interface ReadingListItem { pagesRead: number; @@ -20,7 +21,7 @@ export interface ReadingListItem { summary?: string; } -export interface ReadingList { +export interface ReadingList extends IHasCover { id: number; title: string; summary: string; @@ -30,9 +31,9 @@ export interface ReadingList { /** * If this is empty or null, the cover image isn't set. Do not use this externally. */ - coverImage: string; - primaryColor?: string; - secondaryColor?: string; + coverImage?: string; + primaryColor: string; + secondaryColor: string; startingYear: number; startingMonth: number; endingYear: number; diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index 0ffc5563f..49930b891 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -1,7 +1,9 @@ import { MangaFormat } from './manga-format'; import { Volume } from './volume'; +import {IHasCover} from "./common/i-has-cover"; +import {IHasReadingTime} from "./common/i-has-reading-time"; -export interface Series { +export interface Series extends IHasCover, IHasReadingTime { id: number; name: string; /** @@ -60,11 +62,11 @@ export interface Series { * Highest level folder containing this series */ folderPath: string; - /** - * This is currently only used on Series detail page for recommendations - */ - summary?: string; - coverImage?: string; - primaryColor: string; - secondaryColor: string; + /** + * This is currently only used on Series detail page for recommendations + */ + summary?: string; + coverImage?: string; + primaryColor: string; + secondaryColor: string; } diff --git a/UI/Web/src/app/_models/volume.ts b/UI/Web/src/app/_models/volume.ts index ffe333b7b..56ca68ceb 100644 --- a/UI/Web/src/app/_models/volume.ts +++ b/UI/Web/src/app/_models/volume.ts @@ -1,7 +1,9 @@ import { Chapter } from './chapter'; import { HourEstimateRange } from './series-detail/hour-estimate-range'; +import {IHasCover} from "./common/i-has-cover"; +import {IHasReadingTime} from "./common/i-has-reading-time"; -export interface Volume { +export interface Volume extends IHasCover, IHasReadingTime { id: number; minNumber: number; maxNumber: number; @@ -10,6 +12,7 @@ export interface Volume { lastModifiedUtc: string; pages: number; pagesRead: number; + wordCount: number; chapters: Array; /** * This is only available on the object when fetched for SeriesDetail @@ -20,6 +23,7 @@ export interface Volume { avgHoursToRead: number; coverImage?: string; + coverImageLocked: boolean; primaryColor: string; secondaryColor: string; } diff --git a/UI/Web/src/app/_pipes/read-time-left.pipe.ts b/UI/Web/src/app/_pipes/read-time-left.pipe.ts new file mode 100644 index 000000000..7ac093dd7 --- /dev/null +++ b/UI/Web/src/app/_pipes/read-time-left.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {TranslocoService} from "@jsverse/transloco"; +import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; + +@Pipe({ + name: 'readTimeLeft', + standalone: true +}) +export class ReadTimeLeftPipe implements PipeTransform { + + constructor(private translocoService: TranslocoService) {} + + transform(readingTimeLeft: HourEstimateRange): string { + return `~${readingTimeLeft.avgHours} ${readingTimeLeft.avgHours > 1 ? this.translocoService.translate('read-time-pipe.hours') : this.translocoService.translate('read-time-pipe.hour')}`; + } +} diff --git a/UI/Web/src/app/_pipes/read-time.pipe.ts b/UI/Web/src/app/_pipes/read-time.pipe.ts new file mode 100644 index 000000000..1970b2812 --- /dev/null +++ b/UI/Web/src/app/_pipes/read-time.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {IHasReadingTime} from "../_models/common/i-has-reading-time"; +import {TranslocoService} from "@jsverse/transloco"; + +@Pipe({ + name: 'readTime', + standalone: true +}) +export class ReadTimePipe implements PipeTransform { + constructor(private translocoService: TranslocoService) {} + + transform(readingTime: IHasReadingTime): string { + if (readingTime.maxHoursToRead === 0 || readingTime.minHoursToRead === 0) { + return this.translocoService.translate('read-time-pipe.less-than-hour'); + } else { + return `${readingTime.minHoursToRead}${readingTime.maxHoursToRead !== readingTime.minHoursToRead ? ('-' + readingTime.maxHoursToRead) : ''}` + + ` ${readingTime.minHoursToRead > 1 ? this.translocoService.translate('read-time-pipe.hours') : this.translocoService.translate('read-time-pipe.hour')}`; + } + } + +} diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 3a231d347..1bc5cf9e1 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -239,6 +239,7 @@ export class ActionFactoryService { } for (let child of parent.children) { + if (child.action === Action.SendTo) continue; actionItem = {...child}; actionItem.title = translate('actionable.' + actionItem.title); if (actionItem.description !== '') { @@ -413,6 +414,22 @@ export class ActionFactoryService { requiresAdmin: false, children: [], }, + // { + // action: Action.AddToScrobbleHold, + // title: 'add-to-scrobble-hold', + // description: 'add-to-scrobble-hold-tooltip', + // callback: this.dummyCallback, + // requiresAdmin: true, + // children: [], + // }, + // { + // action: Action.RemoveFromScrobbleHold, + // title: 'remove-from-scrobble-hold', + // description: 'remove-from-scrobble-hold-tooltip', + // callback: this.dummyCallback, + // requiresAdmin: true, + // children: [], + // }, ], }, { @@ -558,12 +575,29 @@ export class ActionFactoryService { ], }, { - action: Action.Download, - title: 'download', - description: 'download-tooltip', + action: Action.Submenu, + title: 'others', + description: '', callback: this.dummyCallback, requiresAdmin: false, - children: [], + children: [ + { + action: Action.Delete, + title: 'delete', + description: 'delete-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, + { + action: Action.Download, + title: 'download', + description: 'download-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + ] }, { action: Action.Edit, @@ -639,16 +673,33 @@ export class ActionFactoryService { }, // RBS will handle rendering this, so non-admins with download are applicable { - action: Action.Download, - title: 'download', - description: 'download-tooltip', + action: Action.Submenu, + title: 'others', + description: '', callback: this.dummyCallback, requiresAdmin: false, - children: [], + children: [ + { + action: Action.Delete, + title: 'delete', + description: 'delete-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, + { + action: Action.Download, + title: 'download', + description: 'download-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + ] }, { action: Action.Edit, - title: 'details', + title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, requiresAdmin: false, diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 149751929..222c46a45 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnDestroy } from '@angular/core'; +import {inject, Injectable, OnDestroy} from '@angular/core'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import {Subject, tap} from 'rxjs'; @@ -25,6 +25,8 @@ import {CollectionTagService} from "./collection-tag.service"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {FilterService} from "./filter.service"; import {ReadingListService} from "./reading-list.service"; +import {ChapterService} from "./chapter.service"; +import {VolumeService} from "./volume.service"; export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -40,22 +42,26 @@ export type BooleanActionCallback = (result: boolean) => void; @Injectable({ providedIn: 'root' }) -export class ActionService implements OnDestroy { +export class ActionService { + + private readonly chapterService = inject(ChapterService); + private readonly volumeService = inject(VolumeService); + private readonly libraryService = inject(LibraryService); + private readonly seriesService = inject(SeriesService); + private readonly readerService = inject(ReaderService); + private readonly toastr = inject(ToastrService); + private readonly modalService = inject(NgbModal); + private readonly confirmService = inject(ConfirmService); + private readonly memberService = inject(MemberService); + private readonly deviceService = inject(DeviceService); + private readonly collectionTagService = inject(CollectionTagService); + private readonly filterService = inject(FilterService); + private readonly readingListService = inject(ReadingListService); + - private readonly onDestroy = new Subject(); private readingListModalRef: NgbModalRef | null = null; private collectionModalRef: NgbModalRef | null = null; - constructor(private libraryService: LibraryService, private seriesService: SeriesService, - private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, - private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService, - private readonly collectionTagService: CollectionTagService, private filterService: FilterService, - private readonly readingListService: ReadingListService) { } - - ngOnDestroy() { - this.onDestroy.next(); - this.onDestroy.complete(); - } /** * Request a file scan for a given Library @@ -290,6 +296,7 @@ export class ActionService implements OnDestroy { /** * Mark a chapter as read + * @param libraryId Library Id * @param seriesId Series Id * @param chapter Chapter, should have id, pages, volumeId populated * @param callback Optional callback to perform actions after API completes @@ -306,6 +313,7 @@ export class ActionService implements OnDestroy { /** * Mark a chapter as unread + * @param libraryId Library Id * @param seriesId Series Id * @param chapter Chapter, should have id, pages, volumeId populated * @param callback Optional callback to perform actions after API completes @@ -324,7 +332,7 @@ export class ActionService implements OnDestroy { * Mark all chapters and the volumes as Read. All volumes and chapters must belong to a series * @param seriesId Series Id * @param volumes Volumes, should have id, chapters and pagesRead populated - * @param chapters? Chapters, should have id + * @param chapters Optional Chapters, should have id * @param callback Optional callback to perform actions after API completes */ markMultipleAsRead(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { @@ -346,6 +354,7 @@ export class ActionService implements OnDestroy { * Mark all chapters and the volumes as Unread. All volumes must belong to a series * @param seriesId Series Id * @param volumes Volumes, should have id, chapters and pagesRead populated + * @param chapters Optional Chapters, should have id * @param callback Optional callback to perform actions after API completes */ markMultipleAsUnread(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { @@ -690,6 +699,48 @@ export class ActionService implements OnDestroy { }); } + async deleteChapter(chapterId: number, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-chapter'))) { + if (callback) { + callback(false); + } + return; + } + + this.chapterService.deleteChapter(chapterId).subscribe((res: boolean) => { + if (callback) { + if (res) { + this.toastr.success(translate('toasts.chapter-deleted')); + } else { + this.toastr.error(translate('errors.generic')); + } + + callback(res); + } + }); + } + + async deleteVolume(volumeId: number, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-volume'))) { + if (callback) { + callback(false); + } + return; + } + + this.volumeService.deleteVolume(volumeId).subscribe((res: boolean) => { + if (callback) { + if (res) { + this.toastr.success(translate('toasts.volume-deleted')); + } else { + this.toastr.error(translate('errors.generic')); + } + + callback(res); + } + }); + } + sendToDevice(chapterIds: Array, device: Device, callback?: VoidActionCallback) { this.deviceService.sendTo(chapterIds, device.id).subscribe(() => { this.toastr.success(translate('toasts.file-send-to', {name: device.name})); diff --git a/UI/Web/src/app/_services/chapter.service.ts b/UI/Web/src/app/_services/chapter.service.ts index 8588b914f..e373b9096 100644 --- a/UI/Web/src/app/_services/chapter.service.ts +++ b/UI/Web/src/app/_services/chapter.service.ts @@ -1,10 +1,8 @@ import { Injectable } from '@angular/core'; import {environment} from "../../environments/environment"; import {HttpClient} from "@angular/common/http"; -import {AccountService} from "./account.service"; -import {UserCollection} from "../_models/collection-tag"; import {Chapter} from "../_models/chapter"; -import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; +import {TextResonse} from "../_types/text-response"; @Injectable({ providedIn: 'root' @@ -16,7 +14,15 @@ export class ChapterService { constructor(private httpClient: HttpClient) { } getChapterMetadata(chapterId: number) { - return this.httpClient.get(this.baseUrl + 'chapter/?chapterId=' + chapterId); + return this.httpClient.get(this.baseUrl + 'chapter?chapterId=' + chapterId); + } + + deleteChapter(chapterId: number) { + return this.httpClient.delete(this.baseUrl + 'chapter?chapterId=' + chapterId); + } + + updateChapter(chapter: Chapter) { + return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse); } } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 7601ee437..ea1819bd7 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -16,6 +16,8 @@ export enum EVENTS { ScanSeries = 'ScanSeries', SeriesAdded = 'SeriesAdded', SeriesRemoved = 'SeriesRemoved', + VolumeRemoved = 'VolumeRemoved', + ChapterRemoved = 'ChapterRemoved', ScanLibraryProgress = 'ScanLibraryProgress', OnlineUsers = 'OnlineUsers', /** @@ -293,6 +295,20 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.ChapterRemoved, resp => { + this.messagesSource.next({ + event: EVENTS.ChapterRemoved, + payload: resp.body + }); + }); + + this.hubConnection.on(EVENTS.VolumeRemoved, resp => { + this.messagesSource.next({ + event: EVENTS.VolumeRemoved, + payload: resp.body + }); + }); + this.hubConnection.on(EVENTS.CoverUpdate, resp => { this.messagesSource.next({ event: EVENTS.CoverUpdate, diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index dbd4b6a68..1fe171f66 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -11,13 +11,13 @@ import {Person, PersonRole} from '../_models/metadata/person'; import {Tag} from '../_models/tag'; import {FilterComparison} from '../_models/metadata/v2/filter-comparison'; import {FilterField} from '../_models/metadata/v2/filter-field'; -import {Router} from "@angular/router"; import {SortField} from "../_models/metadata/series-filter"; import {FilterCombination} from "../_models/metadata/v2/filter-combination"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {FilterStatement} from "../_models/metadata/v2/filter-statement"; import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus"; import {LibraryType} from "../_models/library/library"; +import {IHasCast} from "../_models/common/i-has-cast"; @Injectable({ providedIn: 'root' @@ -126,4 +126,52 @@ export class MetadataService { arr[index].field = filterStmt.field; arr[index].value = filterStmt.value ? filterStmt.value + '' : ''; } + + updatePerson(entity: IHasCast, persons: Person[], role: PersonRole) { + switch (role) { + case PersonRole.Other: + break; + case PersonRole.Artist: + break; + case PersonRole.CoverArtist: + entity.coverArtists = persons; + break; + case PersonRole.Character: + entity.characters = persons; + break; + case PersonRole.Colorist: + entity.colorists = persons; + break; + case PersonRole.Editor: + entity.editors = persons; + break; + case PersonRole.Inker: + entity.inkers = persons; + break; + case PersonRole.Letterer: + entity.letterers = persons; + break; + case PersonRole.Penciller: + entity.pencillers = persons; + break; + case PersonRole.Publisher: + entity.publishers = persons; + break; + case PersonRole.Imprint: + entity.imprints = persons; + break; + case PersonRole.Team: + entity.teams = persons; + break; + case PersonRole.Location: + entity.locations = persons; + break; + case PersonRole.Writer: + entity.writers = persons; + break; + case PersonRole.Translator: + entity.translators = persons; + break; + } + } } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 9215b7d2b..f4da69c83 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -19,6 +19,10 @@ import {PersonalToC} from "../_models/readers/personal-toc"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import NoSleep from 'nosleep.js'; import {FullProgress} from "../_models/readers/full-progress"; +import {Volume} from "../_models/volume"; +import {UtilityService} from "../shared/_services/utility.service"; +import {translate} from "@jsverse/transloco"; +import {ToastrService} from "ngx-toastr"; export const CHAPTER_ID_DOESNT_EXIST = -1; @@ -30,6 +34,12 @@ export const CHAPTER_ID_NOT_FETCHED = -2; export class ReaderService { private readonly destroyRef = inject(DestroyRef); + private readonly utilityService = inject(UtilityService); + private readonly router = inject(Router); + private readonly location = inject(Location); + private readonly accountService = inject(AccountService); + private readonly toastr = inject(ToastrService); + baseUrl = environment.apiUrl; encodedKey: string = ''; @@ -38,9 +48,7 @@ export class ReaderService { private noSleep = new NoSleep(); - constructor(private httpClient: HttpClient, private router: Router, - private location: Location, private accountService: AccountService, - @Inject(DOCUMENT) private document: Document) { + constructor(private httpClient: HttpClient, @Inject(DOCUMENT) private document: Document) { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { if (user) { this.encodedKey = encodeURIComponent(user.apiKey); @@ -353,4 +361,30 @@ export class ReaderService { } return ''; } + + readVolume(libraryId: number, seriesId: number, volume: Volume, incognitoMode: boolean = false) { + if (volume.pagesRead < volume.pages && volume.pagesRead > 0) { + // Find the continue point chapter and load it + const unreadChapters = volume.chapters.filter(item => item.pagesRead < item.pages); + if (unreadChapters.length > 0) { + this.readChapter(libraryId, seriesId, unreadChapters[0], incognitoMode); + return; + } + this.readChapter(libraryId, seriesId, volume.chapters[0], incognitoMode); + return; + } + + // Sort the chapters, then grab first if no reading progress + this.readChapter(libraryId, seriesId, [...volume.chapters].sort(this.utilityService.sortChapters)[0]); + } + + readChapter(libraryId: number, seriesId: number, chapter: Chapter, incognitoMode: boolean = false) { + if (chapter.pages === 0) { + this.toastr.error(translate('series-detail.no-pages')); + return; + } + + this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format), + {queryParams: {incognitoMode}}); + } } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 7afe5fa3c..a962cdaac 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -39,6 +39,10 @@ export class ReadingListService { return this.httpClient.get(this.baseUrl + 'readinglist/lists-for-series?seriesId=' + seriesId); } + getReadingListsForChapter(chapterId: number) { + return this.httpClient.get(this.baseUrl + 'readinglist/lists-for-chapter?chapterId=' + chapterId); + } + getListItems(readingListId: number) { return this.httpClient.get(this.baseUrl + 'readinglist/items?readingListId=' + readingListId); } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 6991136cb..ab889937f 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -167,10 +167,6 @@ export class ThemeService { })); } - scan() { - return this.httpClient.post(this.baseUrl + 'theme/scan', {}); - } - /** * Sets the book theme on the body tag so css variable overrides can take place * @param selector brtheme- prefixed string diff --git a/UI/Web/src/app/_services/upload.service.ts b/UI/Web/src/app/_services/upload.service.ts index 8c3d6295a..c801c4b88 100644 --- a/UI/Web/src/app/_services/upload.service.ts +++ b/UI/Web/src/app/_services/upload.service.ts @@ -1,7 +1,10 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import { environment } from 'src/environments/environment'; import { TextResonse } from '../_types/text-response'; +import {translate} from "@jsverse/transloco"; +import {ToastrService} from "ngx-toastr"; +import {tap} from "rxjs"; @Injectable({ providedIn: 'root' @@ -9,6 +12,7 @@ import { TextResonse } from '../_types/text-response'; export class UploadService { private baseUrl = environment.apiUrl; + private readonly toastr = inject(ToastrService); constructor(private httpClient: HttpClient) { } @@ -18,29 +22,46 @@ export class UploadService { } /** - * + * * @param seriesId Series to overwrite cover image for * @param url A base64 encoded url - * @returns + * @param lockCover Should the cover be locked or not + * @returns */ - updateSeriesCoverImage(seriesId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/series', {id: seriesId, url: this._cleanBase64Url(url)}); + updateSeriesCoverImage(seriesId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/series', {id: seriesId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } - updateCollectionCoverImage(tagId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url)}); + updateCollectionCoverImage(tagId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } - updateReadingListCoverImage(readingListId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url)}); + updateReadingListCoverImage(readingListId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } - updateChapterCoverImage(chapterId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)}); + updateChapterCoverImage(chapterId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } - updateLibraryCoverImage(libraryId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/library', {id: libraryId, url: this._cleanBase64Url(url)}); + updateVolumeCoverImage(volumeId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/volume', {id: volumeId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); + } + + updateLibraryCoverImage(libraryId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/library', {id: libraryId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } resetChapterCoverLock(chapterId: number, ) { diff --git a/UI/Web/src/app/_services/volume.service.ts b/UI/Web/src/app/_services/volume.service.ts new file mode 100644 index 000000000..c72d45aaa --- /dev/null +++ b/UI/Web/src/app/_services/volume.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {Volume} from "../_models/volume"; +import {TextResonse} from "../_types/text-response"; + +@Injectable({ + providedIn: 'root' +}) +export class VolumeService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + getVolumeMetadata(volumeId: number) { + return this.httpClient.get(this.baseUrl + 'volume?volumeId=' + volumeId); + } + + deleteVolume(volumeId: number) { + return this.httpClient.delete(this.baseUrl + 'volume?volumeId=' + volumeId); + } + + updateVolume(volume: any) { + return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse); + } +} diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html new file mode 100644 index 000000000..ca7f8e75d --- /dev/null +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html @@ -0,0 +1,124 @@ + + +
+ + + + {{item.title}} + + + +
+ +
+ + + + {{item.title}} + + + +
+ + @if (genres.length > 0 || tags.length > 0) { + + } + + +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ + +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+
diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.scss b/UI/Web/src/app/_single-module/details-tab/details-tab.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts new file mode 100644 index 000000000..2c6d7348c --- /dev/null +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts @@ -0,0 +1,52 @@ +import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; +import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; +import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {IHasCast} from "../../_models/common/i-has-cast"; +import {Person, PersonRole} from "../../_models/metadata/person"; +import {Router} from "@angular/router"; +import {FilterField} from "../../_models/metadata/v2/filter-field"; +import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; +import {Genre} from "../../_models/metadata/genre"; +import {Tag} from "../../_models/tag"; +import {TagBadgeComponent, TagBadgeCursor} from "../../shared/tag-badge/tag-badge.component"; + +@Component({ + selector: 'app-details-tab', + standalone: true, + imports: [ + CarouselReelComponent, + PersonBadgeComponent, + TranslocoDirective, + TagBadgeComponent + ], + templateUrl: './details-tab.component.html', + styleUrl: './details-tab.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DetailsTabComponent { + + private readonly router = inject(Router); + private readonly filterUtilityService = inject(FilterUtilitiesService); + protected readonly PersonRole = PersonRole; + protected readonly FilterField = FilterField; + + @Input({required: true}) metadata!: IHasCast; + @Input() genres: Array = []; + @Input() tags: Array = []; + + + openPerson(queryParamName: FilterField, filter: Person) { + if (queryParamName === FilterField.None) return; + this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter.id}`).subscribe(); + } + + openGeneric(queryParamName: FilterField, filter: string | number) { + if (queryParamName === FilterField.None) return; + this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe(); + } + + + protected readonly TagBadgeCursor = TagBadgeCursor; +} diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html new file mode 100644 index 000000000..2e05a3d4d --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html @@ -0,0 +1,610 @@ + + + + + + + + {{t('field-locked-alt')}} + + + + + diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.scss b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.scss new file mode 100644 index 000000000..fcef7bfcb --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.scss @@ -0,0 +1,6 @@ +.lock-active { + > .input-group-text { + background-color: var(--primary-color); + color: white; + } +} diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts new file mode 100644 index 000000000..8af25489c --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -0,0 +1,534 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; +import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; +import { + AsyncPipe, + DatePipe, + DecimalPipe, + NgClass, + NgTemplateOutlet, + TitleCasePipe +} from "@angular/common"; +import { + NgbActiveModal, + NgbInputDatepicker, + NgbNav, + NgbNavContent, + NgbNavItem, + NgbNavLink, + NgbNavOutlet +} from "@ng-bootstrap/ng-bootstrap"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {AccountService} from "../../_services/account.service"; +import {Chapter} from "../../_models/chapter"; +import {LibraryType} from "../../_models/library/library"; +import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings"; +import {Tag} from "../../_models/tag"; +import {Language} from "../../_models/metadata/language"; +import {Person, PersonRole} from "../../_models/metadata/person"; +import {Genre} from "../../_models/metadata/genre"; +import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; +import {SeriesService} from "../../_services/series.service"; +import {ImageService} from "../../_services/image.service"; +import {UploadService} from "../../_services/upload.service"; +import {MetadataService} from "../../_services/metadata.service"; +import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; +import {ActionService} from "../../_services/action.service"; +import {DownloadService} from "../../shared/_services/download.service"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; +import {forkJoin, Observable, of} from "rxjs"; +import {map} from "rxjs/operators"; +import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component"; +import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; +import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component"; +import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {EntityInfoCardsComponent} from "../../cards/entity-info-cards/entity-info-cards.component"; +import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; +import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component"; +import {MangaFormat} from "../../_models/manga-format"; +import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; +import {TranslocoDatePipe} from "@jsverse/transloco-locale"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {BytesPipe} from "../../_pipes/bytes.pipe"; +import {ImageComponent} from "../../shared/image/image.component"; +import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; +import {ReadTimePipe} from "../../_pipes/read-time.pipe"; +import {ChapterService} from "../../_services/chapter.service"; + +enum TabID { + General = 'general-tab', + CoverImage = 'cover-image-tab', + Info = 'info-tab', + People = 'people-tab', + Tasks = 'tasks-tab', + Progress = 'progress-tab', + Tags = 'tags-tab' +} + +export interface EditChapterModalCloseResult { + success: boolean; + chapter: Chapter; + coverImageUpdate: boolean; + needsReload: boolean; + isDeleted: boolean; +} + +const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; + +@Component({ + selector: 'app-edit-chapter-modal', + standalone: true, + imports: [ + FormsModule, + NgbNav, + NgbNavContent, + NgbNavLink, + TranslocoDirective, + AsyncPipe, + NgbNavOutlet, + ReactiveFormsModule, + NgbNavItem, + SettingItemComponent, + NgTemplateOutlet, + NgClass, + TypeaheadComponent, + EntityTitleComponent, + TitleCasePipe, + SettingButtonComponent, + CoverImageChooserComponent, + EditChapterProgressComponent, + NgbInputDatepicker, + EntityInfoCardsComponent, + CompactNumberPipe, + IconAndTitleComponent, + DefaultDatePipe, + TranslocoDatePipe, + UtcToLocalTimePipe, + BytesPipe, + ImageComponent, + SafeHtmlPipe, + DecimalPipe, + DatePipe, + ReadTimePipe + ], + templateUrl: './edit-chapter-modal.component.html', + styleUrl: './edit-chapter-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditChapterModalComponent implements OnInit { + + protected readonly modal = inject(NgbActiveModal); + private readonly seriesService = inject(SeriesService); + public readonly utilityService = inject(UtilityService); + public readonly imageService = inject(ImageService); + private readonly uploadService = inject(UploadService); + private readonly metadataService = inject(MetadataService); + private readonly cdRef = inject(ChangeDetectorRef); + protected readonly accountService = inject(AccountService); + private readonly destroyRef = inject(DestroyRef); + private readonly actionFactoryService = inject(ActionFactoryService); + private readonly actionService = inject(ActionService); + private readonly downloadService = inject(DownloadService); + private readonly chapterService = inject(ChapterService); + + protected readonly Breakpoint = Breakpoint; + protected readonly TabID = TabID; + protected readonly Action = Action; + protected readonly PersonRole = PersonRole; + protected readonly MangaFormat = MangaFormat; + + @Input({required: true}) chapter!: Chapter; + @Input({required: true}) libraryType!: LibraryType; + @Input({required: true}) libraryId!: number; + @Input({required: true}) seriesId!: number; + + activeId = TabID.General; + editForm: FormGroup = new FormGroup({}); + selectedCover: string = ''; + coverImageReset = false; + + tagsSettings: TypeaheadSettings = new TypeaheadSettings(); + languageSettings: TypeaheadSettings = new TypeaheadSettings(); + peopleSettings: {[PersonRole: string]: TypeaheadSettings} = {}; + genreSettings: TypeaheadSettings = new TypeaheadSettings(); + + tags: Tag[] = []; + genres: Genre[] = []; + ageRatings: Array = []; + validLanguages: Array = []; + + tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getChapterActions(this.runTask.bind(this)), blackList); + /** + * A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified + */ + initChapter!: Chapter; + imageUrls: Array = []; + size: number = 0; + + get WebLinks() { + if (this.chapter.webLinks === '') return []; + return this.chapter.webLinks.split(','); + } + + + + ngOnInit() { + this.initChapter = Object.assign({}, this.chapter); + this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id)); + + this.size = this.utilityService.asChapter(this.chapter).files.reduce((sum, v) => sum + v.bytes, 0); + + + this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, [])); + this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)])); + this.editForm.addControl('summary', new FormControl(this.chapter.summary, [])); + this.editForm.addControl('language', new FormControl(this.chapter.language, [])); + this.editForm.addControl('isbn', new FormControl(this.chapter.isbn, [])); + this.editForm.addControl('ageRating', new FormControl(this.chapter.ageRating, [])); + this.editForm.addControl('releaseDate', new FormControl(this.chapter.releaseDate, [])); + + + this.editForm.addControl('genres', new FormControl(this.chapter.genres, [])); + this.editForm.addControl('tags', new FormControl(this.chapter.tags, [])); + + + this.editForm.addControl('coverImageIndex', new FormControl(0, [])); + this.editForm.addControl('coverImageLocked', new FormControl(this.chapter.coverImageLocked, [])); + + this.metadataService.getAllValidLanguages().subscribe(validLanguages => { + this.validLanguages = validLanguages; + this.setupLanguageTypeahead(); + this.cdRef.markForCheck(); + }); + + this.metadataService.getAllAgeRatings().subscribe(ratings => { + this.ageRatings = ratings; + this.cdRef.markForCheck(); + }); + + this.editForm.get('titleName')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.titleNameLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('sortOrder')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.sortOrderLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('isbn')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.isbnLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('ageRating')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.ageRatingLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('summary')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.summaryLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('releaseDate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.releaseDateLocked = true; + this.cdRef.markForCheck(); + }); + + this.setupTypeaheads(); + + } + + close() { + this.modal.dismiss(); + } + + save() { + const model = this.editForm.value; + const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0; + + this.chapter.releaseDate = model.releaseDate; + + + const apis = [ + this.chapterService.updateChapter(this.chapter) + ]; + + // We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image + const needsReload = this.editForm.get('titleName')?.dirty || this.editForm.get('sortOrder')?.dirty; + + + if (selectedIndex > 0 || this.coverImageReset) { + apis.push(this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover, !this.coverImageReset)); + } + + forkJoin(apis).subscribe(results => { + this.modal.close({success: true, chapter: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: needsReload, isDeleted: false} as EditChapterModalCloseResult); + }); + } + + unlock(b: any, field: string) { + if (b) { + b[field] = !b[field]; + } + this.cdRef.markForCheck(); + } + + async runTask(action: ActionItem) { + switch (action.action) { + + case Action.MarkAsRead: + this.actionService.markChapterAsRead(this.libraryId, this.seriesId, this.chapter, (p) => { + this.chapter.pagesRead = p.pagesRead; + this.cdRef.markForCheck(); + }); + break; + case Action.MarkAsUnread: + this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, this.chapter, (p) => { + this.chapter.pagesRead = 0; + this.cdRef.markForCheck(); + }); + break; + case Action.Delete: + await this.actionService.deleteChapter(this.chapter.id, (b) => { + if (!b) return; + this.modal.close({success: b, chapter: this.chapter, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditChapterModalCloseResult); + }); + break; + case Action.Download: + this.downloadService.download('chapter', this.chapter); + break; + } + } + + setupTypeaheads() { + forkJoin([ + this.setupTagSettings(), + this.setupGenreTypeahead(), + this.setupPersonTypeahead(), + this.setupLanguageTypeahead() + ]).subscribe(results => { + this.cdRef.markForCheck(); + }); + } + + setupTagSettings() { + this.tagsSettings.minCharacters = 0; + this.tagsSettings.multiple = true; + this.tagsSettings.id = 'tags'; + this.tagsSettings.unique = true; + this.tagsSettings.showLocked = true; + this.tagsSettings.addIfNonExisting = true; + + + this.tagsSettings.compareFn = (options: Tag[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags() + .pipe(map(items => this.tagsSettings.compareFn(items, filter))); + + this.tagsSettings.addTransformFn = ((title: string) => { + return {id: 0, title: title }; + }); + this.tagsSettings.selectionCompareFn = (a: Tag, b: Tag) => { + return a.title.toLowerCase() == b.title.toLowerCase(); + } + this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } + + if (this.chapter.tags) { + this.tagsSettings.savedData = this.chapter.tags; + } + return of(true); + } + + setupGenreTypeahead() { + this.genreSettings.minCharacters = 0; + this.genreSettings.multiple = true; + this.genreSettings.id = 'genres'; + this.genreSettings.unique = true; + this.genreSettings.showLocked = true; + this.genreSettings.addIfNonExisting = true; + this.genreSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllGenres() + .pipe(map(items => this.genreSettings.compareFn(items, filter))); + }; + this.genreSettings.compareFn = (options: Genre[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.genreSettings.compareFnForAdd = (options: Genre[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } + this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => { + return a.title.toLowerCase() == b.title.toLowerCase(); + } + + this.genreSettings.addTransformFn = ((title: string) => { + return {id: 0, title: title }; + }); + + if (this.chapter.genres) { + this.genreSettings.savedData = this.chapter.genres; + } + return of(true); + } + + setupLanguageTypeahead() { + this.languageSettings.minCharacters = 0; + this.languageSettings.multiple = false; + this.languageSettings.id = 'language'; + this.languageSettings.unique = true; + this.languageSettings.showLocked = true; + this.languageSettings.addIfNonExisting = false; + this.languageSettings.compareFn = (options: Language[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } + this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages) + .pipe(map(items => this.languageSettings.compareFn(items, filter))); + + this.languageSettings.selectionCompareFn = (a: Language, b: Language) => { + return a.isoCode == b.isoCode; + } + + const l = this.validLanguages.find(l => l.isoCode === this.chapter.language); + if (l !== undefined) { + this.languageSettings.savedData = l; + } + return of(true); + } + + + updateFromPreset(id: string, presetField: Array | undefined, role: PersonRole) { + const personSettings = this.createBlankPersonSettings(id, role) + if (presetField && presetField.length > 0) { + const fetch = personSettings.fetchFn as ((filter: string) => Observable); + return fetch('').pipe(map(people => { + const presetIds = presetField.map(p => p.id); + personSettings.savedData = people.filter(person => presetIds.includes(person.id)); + this.peopleSettings[role] = personSettings; + this.metadataService.updatePerson(this.chapter, personSettings.savedData as Person[], role); + this.cdRef.markForCheck(); + return true; + })); + } else { + this.peopleSettings[role] = personSettings; + return of(true); + } + } + + setupPersonTypeahead() { + this.peopleSettings = {}; + + return forkJoin([ + this.updateFromPreset('writer', this.chapter.writers, PersonRole.Writer), + this.updateFromPreset('character', this.chapter.characters, PersonRole.Character), + this.updateFromPreset('colorist', this.chapter.colorists, PersonRole.Colorist), + this.updateFromPreset('cover-artist', this.chapter.coverArtists, PersonRole.CoverArtist), + this.updateFromPreset('editor', this.chapter.editors, PersonRole.Editor), + this.updateFromPreset('inker', this.chapter.inkers, PersonRole.Inker), + this.updateFromPreset('letterer', this.chapter.letterers, PersonRole.Letterer), + this.updateFromPreset('penciller', this.chapter.pencillers, PersonRole.Penciller), + this.updateFromPreset('publisher', this.chapter.publishers, PersonRole.Publisher), + this.updateFromPreset('imprint', this.chapter.imprints, PersonRole.Imprint), + this.updateFromPreset('translator', this.chapter.translators, PersonRole.Translator), + this.updateFromPreset('teams', this.chapter.teams, PersonRole.Team), + this.updateFromPreset('locations', this.chapter.locations, PersonRole.Location), + ]).pipe(map(results => { + return of(true); + })); + } + + fetchPeople(role: PersonRole, filter: string) { + return this.metadataService.getAllPeople().pipe(map(people => { + return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)); + })); + } + + createBlankPersonSettings(id: string, role: PersonRole) { + var personSettings = new TypeaheadSettings(); + personSettings.minCharacters = 0; + personSettings.multiple = true; + personSettings.showLocked = true; + personSettings.unique = true; + personSettings.addIfNonExisting = true; + personSettings.id = id; + personSettings.compareFn = (options: Person[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.name, filter)); + } + personSettings.compareFnForAdd = (options: Person[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.name, filter)); + } + + personSettings.selectionCompareFn = (a: Person, b: Person) => { + return a.name == b.name && a.role == b.role; + } + personSettings.fetchFn = (filter: string) => { + return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter))); + }; + + personSettings.addTransformFn = ((title: string) => { + return {id: 0, name: title, role: role }; + }); + + return personSettings; + } + + updateTags(tags: Tag[]) { + this.tags = tags; + this.chapter.tags = tags; + this.cdRef.markForCheck(); + } + + updateGenres(genres: Genre[]) { + this.genres = genres; + this.chapter.genres = genres; + this.cdRef.markForCheck(); + } + + updatePerson(persons: Person[], role: PersonRole) { + this.metadataService.updatePerson(this.chapter, persons, role); + this.chapter.locationLocked = true; + this.cdRef.markForCheck(); + } + + updateLanguage(language: Array) { + if (language.length === 0) { + this.chapter.language = ''; + return; + } + this.chapter.language = language[0].isoCode; + this.chapter.languageLocked = true; + this.cdRef.markForCheck(); + } + + updateSelectedIndex(index: number) { + this.editForm.patchValue({ + coverImageIndex: index + }); + this.cdRef.markForCheck(); + } + + updateSelectedImage(url: string) { + this.selectedCover = url; + this.cdRef.markForCheck(); + } + + handleReset() { + this.coverImageReset = true; + this.editForm.patchValue({ + coverImageLocked: false + }); + this.cdRef.markForCheck(); + } + + getPersonsSettings(role: PersonRole) { + return this.peopleSettings[role]; + } +} diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html new file mode 100644 index 000000000..a8acc7a9c --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html @@ -0,0 +1,143 @@ + + + + + + diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.scss b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts new file mode 100644 index 000000000..760f38f47 --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts @@ -0,0 +1,231 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; +import { + NgbActiveModal, + NgbInputDatepicker, + NgbNav, + NgbNavContent, + NgbNavItem, + NgbNavLink, + NgbNavOutlet +} from "@ng-bootstrap/ng-bootstrap"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {AsyncPipe, DatePipe, DecimalPipe, NgClass, NgTemplateOutlet, TitleCasePipe} from "@angular/common"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; +import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component"; +import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; +import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component"; +import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component"; +import {EntityInfoCardsComponent} from "../../cards/entity-info-cards/entity-info-cards.component"; +import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; +import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component"; +import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; +import {TranslocoDatePipe} from "@jsverse/transloco-locale"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {BytesPipe} from "../../_pipes/bytes.pipe"; +import {ImageComponent} from "../../shared/image/image.component"; +import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; +import {ReadTimePipe} from "../../_pipes/read-time.pipe"; +import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; +import {Volume} from "../../_models/volume"; +import {SeriesService} from "../../_services/series.service"; +import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; +import {ImageService} from "../../_services/image.service"; +import {UploadService} from "../../_services/upload.service"; +import {MetadataService} from "../../_services/metadata.service"; +import {AccountService} from "../../_services/account.service"; +import {ActionService} from "../../_services/action.service"; +import {DownloadService} from "../../shared/_services/download.service"; +import {Chapter} from "../../_models/chapter"; +import {LibraryType} from "../../_models/library/library"; +import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings"; +import {Tag} from "../../_models/tag"; +import {Language} from "../../_models/metadata/language"; +import {Person, PersonRole} from "../../_models/metadata/person"; +import {Genre} from "../../_models/metadata/genre"; +import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {forkJoin, Observable, of} from "rxjs"; +import {map} from "rxjs/operators"; +import {EditChapterModalCloseResult} from "../edit-chapter-modal/edit-chapter-modal.component"; +import { MangaFormat } from 'src/app/_models/manga-format'; +import {MangaFile} from "../../_models/manga-file"; +import {VolumeService} from "../../_services/volume.service"; + +enum TabID { + General = 'general-tab', + CoverImage = 'cover-image-tab', + Info = 'info-tab', + Tasks = 'tasks-tab', + Progress = 'progress-tab', +} + +export interface EditVolumeModalCloseResult { + success: boolean; + volume: Volume; + coverImageUpdate: boolean; + needsReload: boolean; + isDeleted: boolean; +} + +const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; + +@Component({ + selector: 'app-edit-volume-modal', + standalone: true, + imports: [ + FormsModule, + NgbNav, + NgbNavContent, + NgbNavLink, + TranslocoDirective, + AsyncPipe, + NgbNavOutlet, + ReactiveFormsModule, + NgbNavItem, + SettingItemComponent, + NgTemplateOutlet, + NgClass, + TypeaheadComponent, + EntityTitleComponent, + TitleCasePipe, + SettingButtonComponent, + CoverImageChooserComponent, + EditChapterProgressComponent, + NgbInputDatepicker, + EntityInfoCardsComponent, + CompactNumberPipe, + IconAndTitleComponent, + DefaultDatePipe, + TranslocoDatePipe, + UtcToLocalTimePipe, + BytesPipe, + ImageComponent, + SafeHtmlPipe, + DecimalPipe, + DatePipe, + ReadTimePipe + ], + templateUrl: './edit-volume-modal.component.html', + styleUrl: './edit-volume-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditVolumeModalComponent implements OnInit { + public readonly modal = inject(NgbActiveModal); + public readonly utilityService = inject(UtilityService); + public readonly imageService = inject(ImageService); + private readonly uploadService = inject(UploadService); + private readonly cdRef = inject(ChangeDetectorRef); + public readonly accountService = inject(AccountService); + private readonly actionFactoryService = inject(ActionFactoryService); + private readonly actionService = inject(ActionService); + private readonly downloadService = inject(DownloadService); + private readonly volumeService = inject(VolumeService); + + protected readonly Breakpoint = Breakpoint; + protected readonly TabID = TabID; + protected readonly Action = Action; + protected readonly PersonRole = PersonRole; + protected readonly MangaFormat = MangaFormat; + + @Input({required: true}) volume!: Volume; + @Input({required: true}) libraryType!: LibraryType; + @Input({required: true}) libraryId!: number; + @Input({required: true}) seriesId!: number; + + activeId = TabID.CoverImage; + editForm: FormGroup = new FormGroup({}); + selectedCover: string = ''; + coverImageReset = false; + + + tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getVolumeActions(this.runTask.bind(this)), blackList); + /** + * A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified + */ + initVolume!: Volume; + imageUrls: Array = []; + size: number = 0; + files: Array = []; + + + + ngOnInit() { + this.initVolume = Object.assign({}, this.volume); + this.imageUrls.push(this.imageService.getVolumeCoverImage(this.volume.id)); + + this.files = this.volume.chapters.flatMap(c => c.files); + this.size = this.files.reduce((sum, v) => sum + v.bytes, 0); + + this.editForm.addControl('coverImageIndex', new FormControl(0, [])); + this.editForm.addControl('coverImageLocked', new FormControl(this.volume.coverImageLocked, [])); + } + + close() { + this.modal.dismiss(); + } + + save() { + const model = this.editForm.value; + const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0; + + const apis = []; + + + if (selectedIndex > 0 || this.coverImageReset) { + apis.push(this.uploadService.updateVolumeCoverImage(model.id, this.selectedCover, !this.coverImageReset)); + } + + forkJoin(apis).subscribe(results => { + this.modal.close({success: true, volume: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: false, isDeleted: false} as EditVolumeModalCloseResult); + }); + } + + + async runTask(action: ActionItem) { + switch (action.action) { + case Action.MarkAsRead: + this.actionService.markVolumeAsRead(this.seriesId, this.volume, (p) => { + this.volume.pagesRead = p.pagesRead; + this.cdRef.markForCheck(); + }); + break; + case Action.MarkAsUnread: + this.actionService.markVolumeAsUnread(this.seriesId, this.volume, (p) => { + this.volume.pagesRead = 0; + this.cdRef.markForCheck(); + }); + break; + case Action.Delete: + await this.actionService.deleteVolume(this.volume.id, (b) => { + if (!b) return; + this.modal.close({success: b, volume: this.volume, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditVolumeModalCloseResult); + }); + break; + case Action.Download: + this.downloadService.download('volume', this.volume); + break; + } + } + + updateSelectedIndex(index: number) { + this.editForm.patchValue({ + coverImageIndex: index + }); + this.cdRef.markForCheck(); + } + + updateSelectedImage(url: string) { + this.selectedCover = url; + this.cdRef.markForCheck(); + } + + handleReset() { + this.coverImageReset = true; + this.editForm.patchValue({ + coverImageLocked: false + }); + this.cdRef.markForCheck(); + } +} diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.html b/UI/Web/src/app/_single-module/review-card/review-card.component.html index 54338740b..6ff63058d 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.html +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.html @@ -1,36 +1,31 @@
-
- +
@if (isMyReview) { -
- - {{t('your-review')}} -
+ + + } @else { + }
-
+
+

- +

-