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:
Joseph Milazzo 2022-02-24 13:23:40 -07:00 committed by GitHub
parent 58b1d0df8a
commit d291eb809d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 944 additions and 187 deletions

View File

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

View File

@ -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)]

View 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
}

View File

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

View File

@ -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>

View 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; }
}

View File

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

View File

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

View File

@ -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

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

View 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>;
}

View File

@ -4,7 +4,6 @@ export interface Volume {
id: number;
number: number;
name: string;
coverImage: string;
created: string;
lastModified: string;
pages: number;

View File

@ -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 + '');

View File

@ -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>

View File

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

View File

@ -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>

View File

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

View File

@ -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">

View File

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

View File

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