UX Overhaul Part 2 (#3112)

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-08-16 19:37:12 -05:00 committed by GitHub
parent 0247bc5012
commit 3d8aa2ad24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
192 changed files with 14808 additions and 1874 deletions

View file

@ -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;
}
/// <summary>
/// Gets a single chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet]
public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId)
{
@ -25,5 +45,234 @@ public class ChapterController : BaseApiController
return Ok(chapter);
}
/// <summary>
/// Removes a Chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete]
public async Task<ActionResult<bool>> 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);
}
/// <summary>
/// Update chapter metadata
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> 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<Genre>();
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<Tag>();
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<Person>();
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();
}
}

View file

@ -63,7 +63,7 @@ public class ReadingListController : BaseApiController
}
/// <summary>
/// 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.
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
@ -74,6 +74,18 @@ public class ReadingListController : BaseApiController
seriesId, true));
}
/// <summary>
/// Returns all Reading Lists the user has access to that has the given chapter within it.
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("lists-for-chapter")]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForChapter(int chapterId)
{
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtosForChapterAndUserAsync(User.GetUserId(),
chapterId, true));
}
/// <summary>
/// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress
/// </summary>

View file

@ -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<ActionResult> 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
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("reset-chapter-lock")]
[Obsolete("Use LockCover in UploadFileDto")]
public async Task<ActionResult> ResetChapterLock(UploadFileDto uploadFileDto)
{
try
@ -461,4 +488,6 @@ public class UploadController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock"));
}
}

View file

@ -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<ActionResult<VolumeDto>> GetVolume(int volumeId)
{
var volume =
await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId());
return Ok(volume);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete]
public async Task<ActionResult<bool>> 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);
}
}

View file

@ -158,6 +158,33 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
/// </summary>
public int TotalCount { get; set; }
public bool LanguageLocked { get; set; }
public bool SummaryLocked { get; set; }
/// <summary>
/// Locked by user so metadata updates from scan loop will not override AgeRating
/// </summary>
public bool AgeRatingLocked { get; set; }
/// <summary>
/// Locked by user so metadata updates from scan loop will not override PublicationStatus
/// </summary>
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; }

View file

@ -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;
/// <summary>
/// Exclusively metadata about a given chapter
/// </summary>
[Obsolete("Will not be maintained as of v0.8.1")]
public class ChapterMetadataDto
{
public int Id { get; set; }

View file

@ -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;
/// <summary>
/// Genres for the Chapter
/// </summary>
public ICollection<GenreTagDto> Genres { get; set; } = new List<GenreTagDto>();
/// <summary>
/// Collection of all Tags from underlying chapters for a Chapter
/// </summary>
public ICollection<TagDto> Tags { get; set; } = new List<TagDto>();
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> CoverArtists { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Publishers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Characters { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Pencillers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Inkers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Imprints { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Colorists { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Teams { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Locations { get; set; } = new List<PersonDto>();
/// <summary>
/// Highest Age Rating from all Chapters
/// </summary>
public AgeRating AgeRating { get; set; } = AgeRating.Unknown;
/// <summary>
/// Language of the content (BCP-47 code)
/// </summary>
public string Language { get; set; } = string.Empty;
/// <summary>
/// Locked by user so metadata updates from scan loop will not override AgeRating
/// </summary>
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; }
/// <summary>
/// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden.
/// </summary>
public float SortOrder { get; set; }
/// <summary>
/// Can the sort order be updated on scan or is it locked from UI
/// </summary>
public bool SortOrderLocked { get; set; }
/// <summary>
/// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary>
public string WebLinks { get; set; } = string.Empty;
public string ISBN { get; set; } = string.Empty;
/// <summary>
/// Date which chapter was released
/// </summary>
public DateTime ReleaseDate { get; set; }
/// <summary>
/// Chapter title
/// </summary>
/// <remarks>This should not be confused with Title which is used for special filenames.</remarks>
public string TitleName { get; set; } = string.Empty;
}

View file

@ -10,4 +10,9 @@ public class UploadFileDto
/// Base Url encoding of the file to upload from (can be null)
/// </summary>
public required string Url { get; set; }
/// <summary>
/// Lock the cover or not
/// </summary>
public bool LockCover { get; set; } = true;
}

View file

@ -44,6 +44,7 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
public long WordCount { get; set; }
/// <summary>
/// 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; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,249 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ChapterMetadataLocks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AgeRatingLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CharacterLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "ColoristLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CoverArtistLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "EditorLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "GenresLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "ISBNLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "ImprintLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "InkerLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LanguageLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LettererLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LocationLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "PencillerLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "PublisherLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "ReleaseDateLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SummaryLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "TagsLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "TeamLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "TitleNameLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "TranslatorLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "WriterLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class VolumeCoverLocked : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CoverImageLocked",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CoverImageLocked",
table: "Volume");
}
}
}

View file

@ -719,6 +719,9 @@ namespace API.Data.Migrations
b.Property<int>("AgeRating")
.HasColumnType("INTEGER");
b.Property<bool>("AgeRatingLocked")
.HasColumnType("INTEGER");
b.Property<int>("AlternateCount")
.HasColumnType("INTEGER");
@ -731,9 +734,18 @@ namespace API.Data.Migrations
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<bool>("CharacterLocked")
.HasColumnType("INTEGER");
b.Property<bool>("ColoristLocked")
.HasColumnType("INTEGER");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<bool>("CoverArtistLocked")
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
@ -746,23 +758,47 @@ namespace API.Data.Migrations
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<bool>("EditorLocked")
.HasColumnType("INTEGER");
b.Property<bool>("GenresLocked")
.HasColumnType("INTEGER");
b.Property<string>("ISBN")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ISBNLocked")
.HasColumnType("INTEGER");
b.Property<bool>("ImprintLocked")
.HasColumnType("INTEGER");
b.Property<bool>("InkerLocked")
.HasColumnType("INTEGER");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<bool>("LanguageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<bool>("LettererLocked")
.HasColumnType("INTEGER");
b.Property<bool>("LocationLocked")
.HasColumnType("INTEGER");
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");
@ -781,15 +817,24 @@ namespace API.Data.Migrations
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<bool>("PencillerLocked")
.HasColumnType("INTEGER");
b.Property<string>("PrimaryColor")
.HasColumnType("TEXT");
b.Property<bool>("PublisherLocked")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<DateTime>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<bool>("ReleaseDateLocked")
.HasColumnType("INTEGER");
b.Property<string>("SecondaryColor")
.HasColumnType("TEXT");
@ -811,15 +856,30 @@ namespace API.Data.Migrations
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<bool>("SummaryLocked")
.HasColumnType("INTEGER");
b.Property<bool>("TagsLocked")
.HasColumnType("INTEGER");
b.Property<bool>("TeamLocked")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<string>("TitleName")
.HasColumnType("TEXT");
b.Property<bool>("TitleNameLocked")
.HasColumnType("INTEGER");
b.Property<int>("TotalCount")
.HasColumnType("INTEGER");
b.Property<bool>("TranslatorLocked")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
@ -831,6 +891,9 @@ namespace API.Data.Migrations
b.Property<long>("WordCount")
.HasColumnType("INTEGER");
b.Property<bool>("WriterLocked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
@ -1983,6 +2046,9 @@ namespace API.Data.Migrations
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");

View file

@ -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<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None);
Task<IChapterInfoDto?> GetChapterInfoDtoAsync(int chapterId);
Task<int> 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<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None)
{
return await _context.Chapter

View file

@ -36,6 +36,8 @@ public interface IReadingListRepository
Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId);
Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId,
bool includePromoted);
Task<IEnumerable<ReadingListDto>> GetReadingListDtosForChapterAndUserAsync(int userId, int chapterId,
bool includePromoted);
void Remove(ReadingListItem item);
void Add(ReadingList list);
void BulkRemove(IEnumerable<ReadingListItem> 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<IEnumerable<ReadingListDto>> 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<ReadingListDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
return await query.ToListAsync();
}
public async Task<ReadingList?> GetReadingListByIdAsync(int readingListId, ReadingListIncludes includes = ReadingListIncludes.None)
{
return await _context.ReadingList

View file

@ -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
/// <summary>
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
/// </summary>

View file

@ -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)
/// </summary>
public required float MaxNumber { get; set; }
public IList<Chapter> 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<Chapter> Chapters { get; set; } = null!;
public Series Series { get; set; } = null!;
public int SeriesId { get; set; }

View file

@ -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();
}

View file

@ -108,4 +108,49 @@ public static class GenreHelper
onModified();
}
}
public static void UpdateGenreList(ICollection<GenreTagDto>? tags, Chapter chapter,
IReadOnlyCollection<Genre> allTags, Action<Genre> 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();
}
}
}

View file

@ -165,18 +165,76 @@ public static class PersonHelper
}
}
public static bool HasAnyPeople(SeriesMetadataDto? seriesMetadata)
public static void UpdatePeopleList(PersonRole role, ICollection<PersonDto>? people, Chapter chapter, IReadOnlyCollection<Person> allPeople,
Action<Person> 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;
}
}

View file

@ -151,6 +151,48 @@ public static class TagHelper
onModified();
}
}
public static void UpdateTagList(ICollection<TagDto>? tags, Chapter chapter, IReadOnlyCollection<Tag> allTags, Action<Tag> 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

View file

@ -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();
}

View file

@ -75,8 +75,10 @@ public class MetadataService : IMetadataService
/// <param name="chapter"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
/// <param name="encodeFormat">Convert image to Encoding Format when extracting the cover</param>
private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize)
private Task<bool> 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<bool> 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<Chapter>();
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<Chapter>();
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));

View file

@ -129,62 +129,81 @@ public class ParseScannedFiles
var result = new List<ScanResult>();
// 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<string>.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<string>.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<IList<ScanResult>> ScanDirectories(string folderPath, IDictionary<string, IList<SeriesModified>> seriesPaths, Library library, bool forceCheck,
GlobMatcher matcher, List<ScanResult> 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<string>.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<string>.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<IList<ScanResult>> ScanSingleDirectory(string folderPath, IDictionary<string, IList<SeriesModified>> seriesPaths, Library library, bool forceCheck, List<ScanResult> 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;
}

View file

@ -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);
}
}
}

View file

@ -138,6 +138,14 @@ public static class MessageFactory
/// A Progress event when a smart collection is synchronizing
/// </summary>
public const string SmartCollectionSync = "SmartCollectionSync";
/// <summary>
/// Chapter is removed from server
/// </summary>
public const string ChapterRemoved = "ChapterRemoved";
/// <summary>
/// Volume is removed from server
/// </summary>
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 = "")
{