mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Series Detail Refactor (#1118)
* Fixed a bug where reading list and collection's summary wouldn't render newlines * Moved all the logic in the UI for Series Detail into the backend (messy code). We are averaging 400ms max with much optimizations available. Next step is to refactor out of controller and provide unit tests. * Unit tests for CleanSpecialTitle * Laid out foundation for testing major code in SeriesController. * Refactored code so that read doesn't need to be disabled on page load. SeriesId doesn't need the series to actually load. * Removed old property from Volume * Changed tagbadge font size to rem. * Refactored some methods from SeriesController.cs into SeriesService.cs * UpdateRating unit tested * Wrote unit tests for SeriesDetail * Worked up some code where books are rendered only as volumes. However, looks like I will need to use Chapters to better support series_index as floats. * Refactored Series Detail to change Volume Name on Book libraries to have book name and series_index. * Some cleanup on the code * DeleteMultipleSeries test is hard. Going to skip. * Removed some debug code and make all tabs Books for Book library Type
This commit is contained in:
parent
58b1d0df8a
commit
d291eb809d
@ -28,7 +28,7 @@ namespace API.Tests.Helpers
|
||||
return new Volume()
|
||||
{
|
||||
Name = volumeNumber,
|
||||
Number = int.Parse(volumeNumber),
|
||||
Number = (int) API.Parser.Parser.MinimumNumberFromRange(volumeNumber),
|
||||
Pages = 0,
|
||||
Chapters = chapters ?? new List<Chapter>()
|
||||
};
|
||||
|
@ -15,6 +15,16 @@ namespace API.Tests.Parser
|
||||
Assert.Equal(expected, CleanAuthor(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "")]
|
||||
[InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")]
|
||||
[InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")]
|
||||
[InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")]
|
||||
public void CleanSpecialTitleTest(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CleanSpecialTitle(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Beastars - SP01", true)]
|
||||
[InlineData("Beastars SP01", true)]
|
||||
|
531
API.Tests/Services/SeriesServiceTests.cs
Normal file
531
API.Tests/Services/SeriesServiceTests.cs
Normal file
@ -0,0 +1,531 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data.Common;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
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.Helpers;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using API.Tests.Helpers;
|
||||
using AutoMapper;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class SeriesServiceTests
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
private readonly DbConnection _connection;
|
||||
private readonly DataContext _context;
|
||||
|
||||
private readonly ISeriesService _seriesService;
|
||||
|
||||
private const string CacheDirectory = "C:/kavita/config/cache/";
|
||||
private const string CoverImageDirectory = "C:/kavita/config/covers/";
|
||||
private const string BackupDirectory = "C:/kavita/config/backups/";
|
||||
private const string DataDirectory = "C:/data/";
|
||||
|
||||
public SeriesServiceTests()
|
||||
{
|
||||
var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options;
|
||||
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
|
||||
|
||||
_context = new DataContext(contextOptions);
|
||||
Task.Run(SeedDb).GetAwaiter().GetResult();
|
||||
|
||||
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
|
||||
var mapper = config.CreateMapper();
|
||||
_unitOfWork = new UnitOfWork(_context, mapper, null);
|
||||
|
||||
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
|
||||
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>());
|
||||
}
|
||||
#region Setup
|
||||
|
||||
private static DbConnection CreateInMemoryDatabase()
|
||||
{
|
||||
var connection = new SqliteConnection("Filename=:memory:");
|
||||
|
||||
connection.Open();
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private async Task<bool> SeedDb()
|
||||
{
|
||||
await _context.Database.MigrateAsync();
|
||||
var filesystem = CreateFileSystem();
|
||||
|
||||
await Seed.SeedSettings(_context,
|
||||
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
|
||||
|
||||
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
|
||||
setting.Value = CacheDirectory;
|
||||
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
|
||||
setting.Value = BackupDirectory;
|
||||
|
||||
_context.ServerSetting.Update(setting);
|
||||
|
||||
var lib = new Library()
|
||||
{
|
||||
Name = "Manga", Folders = new List<FolderPath>() {new FolderPath() {Path = "C:/data/"}}
|
||||
};
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007",
|
||||
Libraries = new List<Library>()
|
||||
{
|
||||
lib
|
||||
}
|
||||
});
|
||||
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
private async Task ResetDb()
|
||||
{
|
||||
_context.Series.RemoveRange(_context.Series.ToList());
|
||||
_context.AppUserRating.RemoveRange(_context.AppUserRating.ToList());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static MockFileSystem CreateFileSystem()
|
||||
{
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
|
||||
fileSystem.AddDirectory("C:/kavita/config/");
|
||||
fileSystem.AddDirectory(CacheDirectory);
|
||||
fileSystem.AddDirectory(CoverImageDirectory);
|
||||
fileSystem.AddDirectory(BackupDirectory);
|
||||
fileSystem.AddDirectory(DataDirectory);
|
||||
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SeriesDetail
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnSpecials()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("Omake", true, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("Something SP02", true, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var expectedRanges = new[] {"Omake", "Something SP02"};
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Specials);
|
||||
Assert.True(2 == detail.Specials.Count());
|
||||
Assert.All(detail.Specials, dto => Assert.Contains(dto.Range, expectedRanges));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnVolumesAndChapters()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Chapters);
|
||||
Assert.Equal(6, detail.Chapters.Count());
|
||||
|
||||
Assert.NotEmpty(detail.Volumes);
|
||||
Assert.Equal(3, detail.Volumes.Count()); // This returns 3 because 0 volume will still come
|
||||
Assert.All(detail.Volumes, dto => Assert.Contains(dto.Name, new[] {"0", "2", "3"}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnVolumesAndChapters_ButRemove0Chapter()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Chapters);
|
||||
Assert.Equal(3, detail.Chapters.Count()); // volume 2 has a 0 chapter aka a single chapter that is represented as a volume. We don't show in Chapters area
|
||||
|
||||
Assert.NotEmpty(detail.Volumes);
|
||||
Assert.Equal(3, detail.Volumes.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnChaptersOnly_WhenBookLibrary()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Volumes);
|
||||
|
||||
Assert.Empty(detail.Chapters); // A book library where all books are Volumes, will show no "chapters" on the UI because it doesn't make sense
|
||||
Assert.Equal(2, detail.Volumes.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldSortVolumesByName()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Book,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1.2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.Equal("1", detail.Volumes.ElementAt(0).Name);
|
||||
Assert.Equal("1.2", detail.Volumes.ElementAt(1).Name);
|
||||
Assert.Equal("2", detail.Volumes.ElementAt(2).Name);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region UpdateRating
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldSetRating()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 3,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(3, ratings.First().Rating);
|
||||
Assert.Equal("Average", ratings.First().Review);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldUpdateExistingRating()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 3,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(3, ratings.First().Rating);
|
||||
Assert.Equal("Average", ratings.First().Review);
|
||||
|
||||
// Update the DB again
|
||||
|
||||
var result2 = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 5,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.True(result2);
|
||||
|
||||
var ratings2 = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings2);
|
||||
Assert.True(ratings2.Count == 1);
|
||||
Assert.Equal(5, ratings2.First().Rating);
|
||||
Assert.Equal("Average", ratings2.First().Review);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldClampRatingAt5()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 10,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(5, ratings.First().Rating);
|
||||
Assert.Equal("Average", ratings.First().Review);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
new Volume()
|
||||
{
|
||||
Chapters = new List<Chapter>()
|
||||
{
|
||||
new Chapter()
|
||||
{
|
||||
Pages = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
{
|
||||
SeriesId = 2,
|
||||
UserRating = 5,
|
||||
UserReview = "Average"
|
||||
});
|
||||
|
||||
Assert.False(result);
|
||||
|
||||
var ratings = user.Ratings;
|
||||
Assert.Empty(ratings);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
@ -11,12 +11,10 @@ using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.Extensions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers
|
||||
@ -26,15 +24,15 @@ namespace API.Controllers
|
||||
private readonly ILogger<SeriesController> _logger;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ISeriesService _seriesService;
|
||||
|
||||
|
||||
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEventHub eventHub)
|
||||
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService)
|
||||
{
|
||||
_logger = logger;
|
||||
_taskScheduler = taskScheduler;
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
_seriesService = seriesService;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -60,7 +58,7 @@ namespace API.Controllers
|
||||
/// <param name="seriesId">Series Id to fetch details for</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException">Throws an exception if the series Id does exist</exception>
|
||||
[HttpGet("{seriesId}")]
|
||||
[HttpGet("{seriesId:int}")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeries(int seriesId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
@ -83,22 +81,7 @@ namespace API.Controllers
|
||||
var username = User.GetUsername();
|
||||
_logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username);
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
|
||||
var chapterIds = (await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new []{seriesId}));
|
||||
var result = await _unitOfWork.SeriesRepository.DeleteSeriesAsync(seriesId);
|
||||
|
||||
if (result)
|
||||
{
|
||||
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
|
||||
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||
await _unitOfWork.CommitAsync();
|
||||
_taskScheduler.CleanupChapters(chapterIds);
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved,
|
||||
MessageFactory.SeriesRemovedEvent(seriesId, series.Name, series.LibraryId), false);
|
||||
}
|
||||
return Ok(result);
|
||||
return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId}));
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
@ -108,25 +91,9 @@ namespace API.Controllers
|
||||
var username = User.GetUsername();
|
||||
_logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", dto.SeriesIds, username);
|
||||
|
||||
var chapterMappings =
|
||||
await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(dto.SeriesIds.ToArray());
|
||||
if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok();
|
||||
|
||||
var allChapterIds = new List<int>();
|
||||
foreach (var mapping in chapterMappings)
|
||||
{
|
||||
allChapterIds.AddRange(mapping.Value);
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds);
|
||||
_unitOfWork.SeriesRepository.Remove(series);
|
||||
|
||||
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
|
||||
{
|
||||
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
|
||||
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||
_taskScheduler.CleanupChapters(allChapterIds.ToArray());
|
||||
}
|
||||
return Ok();
|
||||
return BadRequest("There was an issue deleting the series requested");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -159,23 +126,7 @@ namespace API.Controllers
|
||||
public async Task<ActionResult> UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings);
|
||||
var userRating = await _unitOfWork.UserRepository.GetUserRatingAsync(updateSeriesRatingDto.SeriesId, user.Id) ??
|
||||
new AppUserRating();
|
||||
|
||||
userRating.Rating = updateSeriesRatingDto.UserRating;
|
||||
userRating.Review = updateSeriesRatingDto.UserReview;
|
||||
userRating.SeriesId = updateSeriesRatingDto.SeriesId;
|
||||
|
||||
if (userRating.Id == 0)
|
||||
{
|
||||
user.Ratings ??= new List<AppUserRating>();
|
||||
user.Ratings.Add(userRating);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical error.");
|
||||
|
||||
if (!await _seriesService.UpdateRating(user, updateSeriesRatingDto)) return BadRequest("There was a critical error.");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@ -320,77 +271,9 @@ namespace API.Controllers
|
||||
[HttpPost("metadata")]
|
||||
public async Task<ActionResult> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
|
||||
{
|
||||
try
|
||||
if (await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
|
||||
{
|
||||
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
var allTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList();
|
||||
if (series.Metadata == null)
|
||||
{
|
||||
series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags
|
||||
.Select(dto => DbFactory.CollectionTag(dto.Id, dto.Title, dto.Summary, dto.Promoted)).ToList());
|
||||
}
|
||||
else
|
||||
{
|
||||
series.Metadata.CollectionTags ??= new List<CollectionTag>();
|
||||
// TODO: Move this merging logic into a reusable code as it can be used for any Tag
|
||||
var newTags = new List<CollectionTag>();
|
||||
|
||||
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
|
||||
var existingTags = series.Metadata.CollectionTags.ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (updateSeriesMetadataDto.Tags.SingleOrDefault(t => t.Id == existing.Id) == null)
|
||||
{
|
||||
// Remove tag
|
||||
series.Metadata.CollectionTags.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all tags that aren't in dto have been removed.
|
||||
foreach (var tag in updateSeriesMetadataDto.Tags)
|
||||
{
|
||||
var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title))
|
||||
{
|
||||
newTags.Add(existingTag);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
newTags.Add(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tag in newTags)
|
||||
{
|
||||
series.Metadata.CollectionTags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_unitOfWork.HasChanges())
|
||||
{
|
||||
return Ok("No changes to save");
|
||||
}
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
foreach (var tag in updateSeriesMetadataDto.Tags)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection,
|
||||
MessageFactory.SeriesAddedToCollectionEvent(tag.Id,
|
||||
updateSeriesMetadataDto.SeriesMetadata.SeriesId), false);
|
||||
}
|
||||
return Ok("Successfully updated");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception when updating metadata");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
return Ok("Successfully updated");
|
||||
}
|
||||
|
||||
return BadRequest("Could not update metadata");
|
||||
@ -439,5 +322,12 @@ namespace API.Controllers
|
||||
|
||||
return Ok(val.ToDescription());
|
||||
}
|
||||
|
||||
[HttpGet("series-detail")]
|
||||
public async Task<ActionResult<SeriesDetailDto>> GetSeriesDetailBreakdown(int seriesId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ namespace API.DTOs
|
||||
/// <summary>
|
||||
/// Used for books/specials to display custom title. For non-specials/books, will be set to <see cref="Range"/>
|
||||
/// </summary>
|
||||
public string Title { get; init; }
|
||||
public string Title { get; set; }
|
||||
/// <summary>
|
||||
/// The files that represent this Chapter
|
||||
/// </summary>
|
||||
|
28
API/DTOs/SeriesDetailDto.cs
Normal file
28
API/DTOs/SeriesDetailDto.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout.
|
||||
/// This is subject to change, do not rely on this Data model.
|
||||
/// </summary>
|
||||
public class SeriesDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Specials for the Series. These will have their title and range cleaned to remove the special marker and prepare
|
||||
/// </summary>
|
||||
public IEnumerable<ChapterDto> Specials { get; set; }
|
||||
/// <summary>
|
||||
/// All Chapters, excluding Specials and single chapters (0 chapter) for a volume
|
||||
/// </summary>
|
||||
public IEnumerable<ChapterDto> Chapters { get; set; }
|
||||
/// <summary>
|
||||
/// Just the Volumes for the Series (Excludes Volume 0)
|
||||
/// </summary>
|
||||
public IEnumerable<VolumeDto> Volumes { get; set; }
|
||||
/// <summary>
|
||||
/// These are chapters that are in Volume 0 and should be read AFTER the volumes
|
||||
/// </summary>
|
||||
public IEnumerable<ChapterDto> StorylineChapters { get; set; }
|
||||
|
||||
}
|
@ -8,9 +8,13 @@ namespace API.Entities
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// A String representation of the volume number. Allows for floats
|
||||
/// A String representation of the volume number. Allows for floats.
|
||||
/// </summary>
|
||||
/// <remarks>For Books with Series_index, this will map to the Series Index.</remarks>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// The minimum number in the Name field in Int form
|
||||
/// </summary>
|
||||
public int Number { get; set; }
|
||||
public IList<Chapter> Chapters { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
|
@ -41,6 +41,7 @@ namespace API.Extensions
|
||||
services.AddScoped<IEmailService, EmailService>();
|
||||
services.AddScoped<IBookmarkService, BookmarkService>();
|
||||
services.AddScoped<ISiteThemeService, SiteThemeService>();
|
||||
services.AddScoped<ISeriesService, SeriesService>();
|
||||
|
||||
|
||||
services.AddScoped<IFileSystem, FileSystem>();
|
||||
|
@ -962,6 +962,25 @@ namespace API.Parser
|
||||
return string.IsNullOrEmpty(normalized) ? name : normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for preparing special title for rendering to the UI. Replaces _ with ' ' and strips out SP\d+
|
||||
/// </summary>
|
||||
/// <param name="name"></param>
|
||||
/// <returns></returns>
|
||||
public static string CleanSpecialTitle(string name)
|
||||
{
|
||||
// TODO: Optimize this code & Test
|
||||
if (string.IsNullOrEmpty(name)) return name;
|
||||
var cleaned = new Regex(@"SP\d+").Replace(name.Replace('_', ' '), string.Empty).Trim();
|
||||
var lastIndex = cleaned.LastIndexOf('.');
|
||||
if (lastIndex > 0)
|
||||
{
|
||||
cleaned = cleaned.Substring(0, cleaned.LastIndexOf('.')).Trim();
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(cleaned) ? name : cleaned;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Tests whether the file is a cover image such that: contains "cover", is named "folder", and is an image
|
||||
|
259
API/Services/SeriesService.cs
Normal file
259
API/Services/SeriesService.cs
Normal file
@ -0,0 +1,259 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
|
||||
public interface ISeriesService
|
||||
{
|
||||
Task<SeriesDetailDto> GetSeriesDetail(int seriesId, int userId);
|
||||
Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto);
|
||||
Task<bool> UpdateRating(AppUser user, UpdateSeriesRatingDto updateSeriesRatingDto);
|
||||
Task<bool> DeleteMultipleSeries(IList<int> seriesIds);
|
||||
|
||||
}
|
||||
|
||||
public class SeriesService : ISeriesService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly ILogger<SeriesService> _logger;
|
||||
|
||||
public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, ILogger<SeriesService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
_taskScheduler = taskScheduler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
var allTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList();
|
||||
if (series.Metadata == null)
|
||||
{
|
||||
series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags
|
||||
.Select(dto => DbFactory.CollectionTag(dto.Id, dto.Title, dto.Summary, dto.Promoted)).ToList());
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
series.Metadata.CollectionTags ??= new List<CollectionTag>();
|
||||
// TODO: Move this merging logic into a reusable code as it can be used for any Tag
|
||||
var newTags = new List<CollectionTag>();
|
||||
|
||||
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
|
||||
var existingTags = series.Metadata.CollectionTags.ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (updateSeriesMetadataDto.Tags.SingleOrDefault(t => t.Id == existing.Id) == null)
|
||||
{
|
||||
// Remove tag
|
||||
series.Metadata.CollectionTags.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all tags that aren't in dto have been removed.
|
||||
foreach (var tag in updateSeriesMetadataDto.Tags)
|
||||
{
|
||||
var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title))
|
||||
{
|
||||
newTags.Add(existingTag);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
newTags.Add(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tag in newTags)
|
||||
{
|
||||
series.Metadata.CollectionTags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_unitOfWork.HasChanges())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
foreach (var tag in updateSeriesMetadataDto.Tags)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection,
|
||||
MessageFactory.SeriesAddedToCollectionEvent(tag.Id,
|
||||
updateSeriesMetadataDto.SeriesMetadata.SeriesId), false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception when updating metadata");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="user">User with Ratings includes</param>
|
||||
/// <param name="updateSeriesRatingDto"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> UpdateRating(AppUser user, UpdateSeriesRatingDto updateSeriesRatingDto)
|
||||
{
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogError("Cannot update rating of null user");
|
||||
return false;
|
||||
}
|
||||
|
||||
var userRating =
|
||||
await _unitOfWork.UserRepository.GetUserRatingAsync(updateSeriesRatingDto.SeriesId, user.Id) ??
|
||||
new AppUserRating();
|
||||
try
|
||||
{
|
||||
userRating.Rating = Math.Clamp(updateSeriesRatingDto.UserRating, 0, 5);
|
||||
userRating.Review = updateSeriesRatingDto.UserReview;
|
||||
userRating.SeriesId = updateSeriesRatingDto.SeriesId;
|
||||
|
||||
if (userRating.Id == 0)
|
||||
{
|
||||
user.Ratings ??= new List<AppUserRating>();
|
||||
user.Ratings.Add(userRating);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception saving rating");
|
||||
}
|
||||
|
||||
await _unitOfWork.RollbackAsync();
|
||||
user.Ratings?.Remove(userRating);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteMultipleSeries(IList<int> seriesIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var chapterMappings =
|
||||
await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(seriesIds.ToArray());
|
||||
|
||||
var allChapterIds = new List<int>();
|
||||
foreach (var mapping in chapterMappings)
|
||||
{
|
||||
allChapterIds.AddRange(mapping.Value);
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(seriesIds);
|
||||
_unitOfWork.SeriesRepository.Remove(series);
|
||||
|
||||
if (!_unitOfWork.HasChanges() || !await _unitOfWork.CommitAsync()) return true;
|
||||
|
||||
foreach (var s in series)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved,
|
||||
MessageFactory.SeriesRemovedEvent(s.Id, s.Name, s.LibraryId), false);
|
||||
}
|
||||
|
||||
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
|
||||
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||
_taskScheduler.CleanupChapters(allChapterIds.ToArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue when trying to delete multiple series");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This generates all the arrays needed by the Series Detail page in the UI. It is a specialized API for the unique layout constraints.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<SeriesDetailDto> GetSeriesDetail(int seriesId, int userId)
|
||||
{
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))
|
||||
.OrderBy(v => float.Parse(v.Name))
|
||||
.ToList();
|
||||
var chapters = volumes.SelectMany(v => v.Chapters).ToList();
|
||||
|
||||
// For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number.
|
||||
if (libraryType == LibraryType.Book)
|
||||
{
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
var firstChapter = volume.Chapters.First();
|
||||
if (!string.IsNullOrEmpty(firstChapter.TitleName)) volume.Name += $" - {firstChapter.TitleName}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var specials = new List<ChapterDto>();
|
||||
foreach (var chapter in chapters.Where(c => c.IsSpecial))
|
||||
{
|
||||
chapter.Title = Parser.Parser.CleanSpecialTitle(chapter.Title);
|
||||
specials.Add(chapter);
|
||||
}
|
||||
return new SeriesDetailDto()
|
||||
{
|
||||
Specials = specials,
|
||||
// Don't show chapter 0 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes)
|
||||
Chapters = chapters
|
||||
.Where(ShouldIncludeChapter)
|
||||
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer()),
|
||||
Volumes = volumes,
|
||||
StorylineChapters = volumes
|
||||
.Where(v => v.Number == 0)
|
||||
.SelectMany(v => v.Chapters)
|
||||
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer())
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Should we show the given chapter on the UI. We only show non-specials and non-zero chapters.
|
||||
/// </summary>
|
||||
/// <param name="c"></param>
|
||||
/// <returns></returns>
|
||||
private static bool ShouldIncludeChapter(ChapterDto c)
|
||||
{
|
||||
return !c.IsSpecial && !c.Number.Equals(Parser.Parser.DefaultChapter);
|
||||
}
|
||||
}
|
12
UI/Web/src/app/_models/series-detail/series-detail.ts
Normal file
12
UI/Web/src/app/_models/series-detail/series-detail.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Chapter } from "../chapter";
|
||||
import { Volume } from "../volume";
|
||||
|
||||
/**
|
||||
* This is built for Series Detail itself
|
||||
*/
|
||||
export interface SeriesDetail {
|
||||
specials: Array<Chapter>;
|
||||
chapters: Array<Chapter>;
|
||||
volumes: Array<Volume>;
|
||||
storylineChapters: Array<Chapter>;
|
||||
}
|
@ -4,7 +4,6 @@ export interface Volume {
|
||||
id: number;
|
||||
number: number;
|
||||
name: string;
|
||||
coverImage: string;
|
||||
created: string;
|
||||
lastModified: string;
|
||||
pages: number;
|
||||
|
@ -8,6 +8,7 @@ import { CollectionTag } from '../_models/collection-tag';
|
||||
import { PaginatedResult } from '../_models/pagination';
|
||||
import { RecentlyAddedItem } from '../_models/recently-added-item';
|
||||
import { Series } from '../_models/series';
|
||||
import { SeriesDetail } from '../_models/series-detail/series-detail';
|
||||
import { SeriesFilter } from '../_models/series-filter';
|
||||
import { SeriesGroup } from '../_models/series-group';
|
||||
import { SeriesMetadata } from '../_models/series-metadata';
|
||||
@ -185,6 +186,10 @@ export class SeriesService {
|
||||
);
|
||||
}
|
||||
|
||||
getSeriesDetail(seriesId: number) {
|
||||
return this.httpClient.get<SeriesDetail>(this.baseUrl + 'series/series-detail?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
_addPaginationIfExists(params: HttpParams, pageNum?: number, itemsPerPage?: number) {
|
||||
if (pageNum !== null && pageNum !== undefined && itemsPerPage !== null && itemsPerPage !== undefined) {
|
||||
params = params.append('pageNumber', pageNum + '');
|
||||
|
@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<app-read-more [text]="collectionTag.summary" [maxLength]="250"></app-read-more>
|
||||
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -40,6 +40,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
||||
isAdmin: boolean = false;
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
summary: string = '';
|
||||
|
||||
private onDestory: Subject<void> = new Subject<void>();
|
||||
|
||||
@ -149,6 +150,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.collectionTag = matchingTags[0];
|
||||
this.summary = (this.collectionTag.summary === null ? '' : this.collectionTag.summary).replace(/\n/g, '<br>');
|
||||
this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(this.collectionTag.id));
|
||||
this.titleService.setTitle('Kavita - ' + this.collectionTag.title + ' Collection');
|
||||
});
|
||||
|
@ -38,7 +38,7 @@
|
||||
</div>
|
||||
<!-- Summary row-->
|
||||
<div class="row g-0 mt-2">
|
||||
<app-read-more [text]="readingList.summary" [maxLength]="250"></app-read-more>
|
||||
<app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -34,6 +34,8 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
hasDownloadingRole: boolean = false;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
readingListSummary: string = '';
|
||||
|
||||
libraryTypes: {[key: number]: LibraryType} = {};
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
@ -77,6 +79,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
this.readingList = readingList;
|
||||
this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>');
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
@ -113,6 +116,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
this.actionService.editReadingList(readingList, (readingList: ReadingList) => {
|
||||
// Reload information around list
|
||||
this.readingList = readingList;
|
||||
this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>');
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-primary" (click)="read()" [disabled]="isLoading">
|
||||
<button class="btn btn-primary" (click)="read()">
|
||||
<span>
|
||||
<i class="fa {{showBook ? 'fa-book-open' : 'fa-book'}}"></i>
|
||||
</span>
|
||||
@ -63,7 +63,7 @@
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)">
|
||||
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
|
||||
<a ngbNavLink>Specials</a>
|
||||
<a ngbNavLink>{{libraryType === LibraryType.Book ? 'Books': 'Specials'}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<ng-container *ngFor="let chapter of specials; let idx = index; trackBy: trackByChapterIdentity">
|
||||
@ -92,7 +92,7 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="TabID.Volumes" *ngIf="hasNonSpecialVolumeChapters">
|
||||
<a ngbNavLink>Volumes</a>
|
||||
<a ngbNavLink>{{libraryType === LibraryType.Book ? 'Books': 'Volumes'}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<ng-container *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
|
||||
|
@ -46,6 +46,10 @@ enum TabID {
|
||||
})
|
||||
export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* Series Id. Set at load before UI renders
|
||||
*/
|
||||
seriesId!: number;
|
||||
series!: Series;
|
||||
volumes: Volume[] = [];
|
||||
chapters: Chapter[] = [];
|
||||
@ -185,34 +189,26 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// this.messageHub.messages$.pipe(takeUntil(this.onDestroy), takeWhile(e => this.messageHub.isEventType(e, EVENTS.ScanSeries))).subscribe((e) => {
|
||||
// const event = e.payload as ScanSeriesEvent;
|
||||
// if (event.seriesId == this.series.id)
|
||||
// this.loadSeries(seriesId);
|
||||
// this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id));
|
||||
// this.toastr.success('Scan series completed');
|
||||
// });
|
||||
|
||||
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
|
||||
if (event.event === EVENTS.SeriesRemoved) {
|
||||
const seriesRemovedEvent = event.payload as SeriesRemovedEvent;
|
||||
if (seriesRemovedEvent.seriesId === this.series.id) {
|
||||
if (seriesRemovedEvent.seriesId === this.seriesId) {
|
||||
this.toastr.info('This series no longer exists');
|
||||
this.router.navigateByUrl('/libraries');
|
||||
}
|
||||
} else if (event.event === EVENTS.ScanSeries) {
|
||||
const seriesCoverUpdatedEvent = event.payload as ScanSeriesEvent;
|
||||
if (seriesCoverUpdatedEvent.seriesId === this.series.id) {
|
||||
this.loadSeries(seriesId);
|
||||
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id)); // NOTE: Is this needed as cover update will update the image for us
|
||||
if (seriesCoverUpdatedEvent.seriesId === this.seriesId) {
|
||||
this.loadSeries(this.seriesId);
|
||||
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.seriesId)); // NOTE: Is this needed as cover update will update the image for us
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const seriesId = parseInt(routeId, 10);
|
||||
this.seriesId = parseInt(routeId, 10);
|
||||
this.libraryId = parseInt(libraryId, 10);
|
||||
this.seriesImage = this.imageService.getSeriesCoverImage(seriesId);
|
||||
this.loadSeries(seriesId);
|
||||
this.seriesImage = this.imageService.getSeriesCoverImage(this.seriesId);
|
||||
this.loadSeries(this.seriesId);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@ -288,7 +284,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
this.openViewInfo(volume);
|
||||
break;
|
||||
case(Action.AddToReadingList):
|
||||
this.actionService.addVolumeToReadingList(volume, this.series.id, () => {/* No Operation */ });
|
||||
this.actionService.addVolumeToReadingList(volume, this.seriesId, () => {/* No Operation */ });
|
||||
break;
|
||||
case(Action.IncognitoRead):
|
||||
if (volume.chapters != undefined && volume.chapters?.length >= 1) {
|
||||
@ -312,7 +308,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
this.openViewInfo(chapter);
|
||||
break;
|
||||
case(Action.AddToReadingList):
|
||||
this.actionService.addChapterToReadingList(chapter, this.series.id, () => {/* No Operation */ });
|
||||
this.actionService.addChapterToReadingList(chapter, this.seriesId, () => {/* No Operation */ });
|
||||
break;
|
||||
case(Action.IncognitoRead):
|
||||
this.openChapter(chapter, true);
|
||||
@ -336,6 +332,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
this.coverImageOffset = 0;
|
||||
|
||||
this.seriesService.getMetadata(seriesId).subscribe(metadata => this.seriesMetadata = metadata);
|
||||
this.setContinuePoint();
|
||||
|
||||
forkJoin([
|
||||
this.libraryService.getLibraryType(this.libraryId),
|
||||
@ -354,30 +351,15 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
|
||||
|
||||
this.seriesService.getSeriesDetail(this.seriesId).subscribe(detail => {
|
||||
this.hasSpecials = detail.specials.length > 0
|
||||
this.specials = detail.specials;
|
||||
|
||||
this.seriesService.getVolumes(this.series.id).subscribe(volumes => {
|
||||
this.volumes = volumes; // volumes are already be sorted in the backend
|
||||
const vol0 = this.volumes.filter(v => v.number === 0);
|
||||
this.storyChapters = vol0.map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters);
|
||||
this.chapters = volumes.map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters).filter(c => !c.isSpecial || isNaN(parseInt(c.range, 10)));
|
||||
|
||||
|
||||
this.setContinuePoint();
|
||||
|
||||
|
||||
const specials = this.storyChapters.filter(c => c.isSpecial || isNaN(parseInt(c.range, 10)));
|
||||
this.hasSpecials = specials.length > 0
|
||||
if (this.hasSpecials) {
|
||||
this.specials = specials
|
||||
.map(c => {
|
||||
c.title = this.utilityService.cleanSpecialTitle(c.title);
|
||||
c.range = this.utilityService.cleanSpecialTitle(c.range);
|
||||
return c;
|
||||
});
|
||||
}
|
||||
this.chapters = detail.chapters;
|
||||
this.volumes = detail.volumes;
|
||||
this.storyChapters = detail.storylineChapters;
|
||||
|
||||
this.updateSelectedTab();
|
||||
|
||||
this.isLoading = false;
|
||||
});
|
||||
}, err => {
|
||||
@ -422,8 +404,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
setContinuePoint() {
|
||||
this.readerService.hasSeriesProgress(this.series.id).subscribe(hasProgress => this.hasReadingProgress = hasProgress);
|
||||
this.readerService.getCurrentChapter(this.series.id).subscribe(chapter => this.currentlyReadingChapter = chapter);
|
||||
this.readerService.hasSeriesProgress(this.seriesId).subscribe(hasProgress => this.hasReadingProgress = hasProgress);
|
||||
this.readerService.getCurrentChapter(this.seriesId).subscribe(chapter => this.currentlyReadingChapter = chapter);
|
||||
}
|
||||
|
||||
markVolumeAsRead(vol: Volume) {
|
||||
@ -431,7 +413,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionService.markVolumeAsRead(this.series.id, vol, () => {
|
||||
this.actionService.markVolumeAsRead(this.seriesId, vol, () => {
|
||||
this.setContinuePoint();
|
||||
this.actionInProgress = false;
|
||||
});
|
||||
@ -442,7 +424,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionService.markVolumeAsUnread(this.series.id, vol, () => {
|
||||
this.actionService.markVolumeAsUnread(this.seriesId, vol, () => {
|
||||
this.setContinuePoint();
|
||||
this.actionInProgress = false;
|
||||
});
|
||||
@ -453,7 +435,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionService.markChapterAsRead(this.series.id, chapter, () => {
|
||||
this.actionService.markChapterAsRead(this.seriesId, chapter, () => {
|
||||
this.setContinuePoint();
|
||||
this.actionInProgress = false;
|
||||
});
|
||||
@ -464,14 +446,21 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionService.markChapterAsUnread(this.series.id, chapter, () => {
|
||||
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => {
|
||||
this.setContinuePoint();
|
||||
this.actionInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
read() {
|
||||
if (this.currentlyReadingChapter !== undefined) { this.openChapter(this.currentlyReadingChapter); }
|
||||
if (this.currentlyReadingChapter !== undefined) {
|
||||
this.openChapter(this.currentlyReadingChapter);
|
||||
return;
|
||||
}
|
||||
|
||||
this.readerService.getCurrentChapter(this.seriesId).subscribe(chapter => {
|
||||
this.openChapter(chapter);
|
||||
});
|
||||
}
|
||||
|
||||
updateRating(rating: any) {
|
||||
@ -509,7 +498,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
// If user has progress on the volume, load them where they left off
|
||||
if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
|
||||
// Find the continue point chapter and load it
|
||||
this.readerService.getCurrentChapter(this.series.id).subscribe(chapter => this.openChapter(chapter));
|
||||
this.readerService.getCurrentChapter(this.seriesId).subscribe(chapter => this.openChapter(chapter));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -540,10 +529,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
|
||||
window.scrollTo(0, 0);
|
||||
if (closeResult.success) {
|
||||
this.loadSeries(this.series.id);
|
||||
this.loadSeries(this.seriesId);
|
||||
if (closeResult.coverImageUpdate) {
|
||||
// Random triggers a load change without any problems with API
|
||||
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id));
|
||||
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.seriesId));
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -585,7 +574,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
downloadSeries() {
|
||||
this.downloadService.downloadSeriesSize(this.series.id).pipe(take(1)).subscribe(async (size) => {
|
||||
this.downloadService.downloadSeriesSize(this.seriesId).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'series');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
@ -604,6 +593,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
formatVolumeTitle(volume: Volume) {
|
||||
if (this.libraryType === LibraryType.Book) {
|
||||
return volume.name;
|
||||
}
|
||||
|
||||
return 'Volume ' + volume.name;
|
||||
}
|
||||
}
|
||||
|
@ -3,13 +3,13 @@
|
||||
margin: 3px 5px 3px 0px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-size: 0.9rem;
|
||||
display: inline-block;
|
||||
cursor: default;
|
||||
width: auto;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
margin-right: 0px;
|
||||
cursor: pointer;
|
||||
|
Loading…
x
Reference in New Issue
Block a user