diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 5bee50e32..b62acb1d1 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -2226,4 +2226,132 @@ public class ReaderServiceTests } #endregion + + #region MarkVolumesUntilAsRead + [Fact] + public async Task MarkVolumesUntilAsRead_ShouldMarkVolumesAsRead() + { + await ResetDb(); + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("10", false, new List(), 1), + EntityFactory.CreateChapter("20", false, new List(), 1), + EntityFactory.CreateChapter("30", false, new List(), 1), + EntityFactory.CreateChapter("Some Special Title", true, new List(), 1), + }), + + EntityFactory.CreateVolume("1997", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + }), + EntityFactory.CreateVolume("2002", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + }), + EntityFactory.CreateVolume("2003", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await readerService.MarkVolumesUntilAsRead(user, 1, 2002); + await _context.SaveChangesAsync(); + + // Validate loose leaf chapters don't get marked as read + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); + + // Validate that volumes 1997 and 2002 both have their respective chapter 0 marked as read + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)).PagesRead); + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1)).PagesRead); + // Validate that the chapter 0 of the following volume (2003) is not read + Assert.Null(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(7, 1)); + + } + + [Fact] + public async Task MarkVolumesUntilAsRead_ShouldMarkChapterBasedVolumesAsRead() + { + await ResetDb(); + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("10", false, new List(), 1), + EntityFactory.CreateChapter("20", false, new List(), 1), + EntityFactory.CreateChapter("30", false, new List(), 1), + EntityFactory.CreateChapter("Some Special Title", true, new List(), 1), + }), + + EntityFactory.CreateVolume("1997", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + }), + EntityFactory.CreateVolume("2002", new List() + { + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2003", new List() + { + EntityFactory.CreateChapter("3", false, new List(), 1), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await readerService.MarkVolumesUntilAsRead(user, 1, 2002); + await _context.SaveChangesAsync(); + + // Validate loose leaf chapters don't get marked as read + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); + + // Validate volumes chapter 0 have read status + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)).PagesRead); + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1)).PagesRead); + Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); + } + + #endregion + } diff --git a/API.Tests/Services/TachiyomiServiceTests.cs b/API.Tests/Services/TachiyomiServiceTests.cs new file mode 100644 index 000000000..f623890d6 --- /dev/null +++ b/API.Tests/Services/TachiyomiServiceTests.cs @@ -0,0 +1,733 @@ +namespace API.Tests.Services; +using System.Collections.Generic; +using System.Data.Common; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using Data; +using Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using API.Services; +using SignalR; +using Helpers; +using AutoMapper; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +public class TachiyomiServiceTests +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly DataContext _context; + 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 TachiyomiServiceTests() + { + var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + _mapper = config.CreateMapper(); + _unitOfWork = new UnitOfWork(_context, _mapper, null); + + + } + + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, + new DirectoryService(Substitute.For>(), 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); + + _context.Library.Add(new Library() + { + Name = "Manga", Folders = new List() {new FolderPath() {Path = "C:/data/"}} + }); + return await _context.SaveChangesAsync() > 0; + } + + private async Task ResetDb() + { + _context.Series.RemoveRange(_context.Series.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 GetLatestChapter + + [Fact] + public async Task GetLatestChapter_ShouldReturnChapter_NoProgress() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", true, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("3", false, new List(), 1), + EntityFactory.CreateChapter("4", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + }, + Pages = 7 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + + Assert.Null(latestChapter); + } + + [Fact] + public async Task GetLatestChapter_ShouldReturnMaxChapter_CompletelyRead() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", true, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("3", false, new List(), 1), + EntityFactory.CreateChapter("4", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + }, + Pages = 7 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await readerService.MarkSeriesAsRead(user,1); + + await _context.SaveChangesAsync(); + + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + + Assert.Equal("96", latestChapter.Number); + } + + [Fact] + public async Task GetLatestChapter_ShouldReturnHighestChapter_Progress() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + EntityFactory.CreateChapter("23", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + }, + Pages = 7 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await tachiyomiService.MarkChaptersUntilAsRead(user,1,21); + + await _context.SaveChangesAsync(); + + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + + Assert.Equal("21", latestChapter.Number); + } + [Fact] + public async Task GetLatestChapter_ShouldReturnEncodedVolume_Progress() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", true, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + EntityFactory.CreateChapter("23", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + }, + Pages = 7 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + + await tachiyomiService.MarkChaptersUntilAsRead(user,1,1/10_000F); + + await _context.SaveChangesAsync(); + + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + Assert.Equal("0.0001", latestChapter.Number); + } + + [Fact] + public async Task GetLatestChapter_ShouldReturnEncodedVolume_Progress2() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 199), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 192), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 255), + }), + }, + Pages = 646 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + + await readerService.MarkSeriesAsRead(user, 1); + + await _context.SaveChangesAsync(); + + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + Assert.Equal("0.0003", latestChapter.Number); + } + + + [Fact] + public async Task GetLatestChapter_ShouldReturnEncodedYearlyVolume_Progress() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1997", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + }), + EntityFactory.CreateVolume("2002", new List() + { + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2005", new List() + { + EntityFactory.CreateChapter("3", false, new List(), 1), + }), + }, + Pages = 7 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Comic, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + + await tachiyomiService.MarkChaptersUntilAsRead(user,1,2002/10_000F); + + await _context.SaveChangesAsync(); + + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + Assert.Equal("0.2002", latestChapter.Number); + } + + #endregion + + + #region MarkChaptersUntilAsRead + + [Fact] + public async Task MarkChaptersUntilAsRead_ShouldReturnChapter_NoProgress() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", true, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("3", false, new List(), 1), + EntityFactory.CreateChapter("4", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + }, + Pages = 7 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + + Assert.Null(latestChapter); + } + [Fact] + public async Task MarkChaptersUntilAsRead_ShouldReturnMaxChapter_CompletelyRead() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", true, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("3", false, new List(), 1), + EntityFactory.CreateChapter("4", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + }, + Pages = 7 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await readerService.MarkSeriesAsRead(user,1); + + await _context.SaveChangesAsync(); + + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + + Assert.Equal("96", latestChapter.Number); + } + + [Fact] + public async Task MarkChaptersUntilAsRead_ShouldReturnHighestChapter_Progress() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + EntityFactory.CreateChapter("23", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + }, + Pages = 7 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await tachiyomiService.MarkChaptersUntilAsRead(user,1,21); + + await _context.SaveChangesAsync(); + + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + + Assert.Equal("21", latestChapter.Number); + } + [Fact] + public async Task MarkChaptersUntilAsRead_ShouldReturnEncodedVolume_Progress() + { + await ResetDb(); + + var series = new Series + { + Name = "Test", + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, new List(), 1), + EntityFactory.CreateChapter("96", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", true, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + EntityFactory.CreateChapter("23", false, new List(), 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List(), 1), + EntityFactory.CreateChapter("32", false, new List(), 1), + }), + }, + Pages = 7 + }; + var library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + Series = new List() { series } + }; + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + Libraries = new List() + { + library + } + + }); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), readerService); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + + await tachiyomiService.MarkChaptersUntilAsRead(user,1,1/10_000F); + + await _context.SaveChangesAsync(); + + + var latestChapter = await tachiyomiService.GetLatestChapter(1, 1); + Assert.Equal("0.0001", latestChapter.Number); + } + + #endregion + +} diff --git a/API/Controllers/TachiyomiController.cs b/API/Controllers/TachiyomiController.cs index 3e20220f8..77f32764d 100644 --- a/API/Controllers/TachiyomiController.cs +++ b/API/Controllers/TachiyomiController.cs @@ -1,15 +1,9 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Threading.Tasks; -using API.Comparators; +using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; -using API.Entities; using API.Extensions; using API.Services; -using AutoMapper; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; @@ -21,14 +15,12 @@ namespace API.Controllers; public class TachiyomiController : BaseApiController { private readonly IUnitOfWork _unitOfWork; - private readonly IReaderService _readerService; - private readonly IMapper _mapper; + private readonly ITachiyomiService _tachiyomiService; - public TachiyomiController(IUnitOfWork unitOfWork, IReaderService readerService, IMapper mapper) + public TachiyomiController(IUnitOfWork unitOfWork, ITachiyomiService tachiyomiService) { _unitOfWork = unitOfWork; - _readerService = readerService; - _mapper = mapper; + _tachiyomiService = tachiyomiService; } /// @@ -39,53 +31,9 @@ public class TachiyomiController : BaseApiController [HttpGet("latest-chapter")] public async Task> GetLatestChapter(int seriesId) { + if (seriesId < 1) return BadRequest("seriesId must be greater than 0"); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - - var currentChapter = await _readerService.GetContinuePoint(seriesId, userId); - - var prevChapterId = - await _readerService.GetPrevChapterIdAsync(seriesId, currentChapter.VolumeId, currentChapter.Id, userId); - - // If prevChapterId is -1, this means either nothing is read or everything is read. - if (prevChapterId == -1) - { - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var userHasProgress = series.PagesRead != 0 && series.PagesRead <= series.Pages; - - // If the user doesn't have progress, then return null, which the extension will catch as 204 (no content) and report nothing as read - if (!userHasProgress) return null; - - // Else return the max chapter to Tachiyomi so it can consider everything read - var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(seriesId)).ToImmutableList(); - var looseLeafChapterVolume = volumes.FirstOrDefault(v => v.Number == 0); - if (looseLeafChapterVolume == null) - { - var volumeChapter = _mapper.Map(volumes.Last().Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparerZeroFirst.Default).Last()); - return Ok(new ChapterDto() - { - Number = $"{int.Parse(volumeChapter.Number) / 100f}" - }); - } - - var lastChapter = looseLeafChapterVolume.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default).Last(); - return Ok(_mapper.Map(lastChapter)); - } - - // There is progress, we now need to figure out the highest volume or chapter and return that. - var prevChapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(prevChapterId); - var volumeWithProgress = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(prevChapter.VolumeId, userId); - // We only encode for single-file volumes - if (volumeWithProgress.Number != 0 && volumeWithProgress.Chapters.Count == 1) - { - // The progress is on a volume, encode it as a fake chapterDTO - return Ok(new ChapterDto() - { - Number = $"{volumeWithProgress.Number / 100f}" - }); - } - - // Progress is just on a chapter, return as is - return Ok(prevChapter); + return Ok(await _tachiyomiService.GetLatestChapter(seriesId, userId)); } /// @@ -97,34 +45,6 @@ public class TachiyomiController : BaseApiController public async Task> MarkChaptersUntilAsRead(int seriesId, float chapterNumber) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - user.Progresses ??= new List(); - - switch (chapterNumber) - { - // When Tachiyomi sync's progress, if there is no current progress in Tachiyomi, 0.0f is sent. - // Due to the encoding for volumes, this marks all chapters in volume 0 (loose chapters) as read. - // Hence we catch and return early, so we ignore the request. - case 0.0f: - return true; - case < 1.0f: - { - // This is a hack to track volume number. We need to map it back by x100 - var volumeNumber = int.Parse($"{chapterNumber * 100f}"); - await _readerService.MarkVolumesUntilAsRead(user, seriesId, volumeNumber); - break; - } - default: - await _readerService.MarkChaptersUntilAsRead(user, seriesId, chapterNumber); - break; - } - - - _unitOfWork.UserRepository.Update(user); - - if (!_unitOfWork.HasChanges()) return Ok(true); - if (await _unitOfWork.CommitAsync()) return Ok(true); - - await _unitOfWork.RollbackAsync(); - return Ok(false); + return Ok(await _tachiyomiService.MarkChaptersUntilAsRead(user, seriesId, chapterNumber)); } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index e11e74142..cb4c871c9 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -54,6 +54,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Services/TachiyomiService.cs b/API/Services/TachiyomiService.cs new file mode 100644 index 000000000..23f57562d --- /dev/null +++ b/API/Services/TachiyomiService.cs @@ -0,0 +1,158 @@ +using System; +using API.DTOs; +using System.Threading.Tasks; +using API.Data; +using System.Collections.Immutable; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using API.Comparators; +using API.Entities; +using AutoMapper; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface ITachiyomiService +{ + Task GetLatestChapter(int seriesId, int userId); + Task MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber); +} + +/// +/// All APIs are for Tachiyomi extension and app. They have hacks for our implementation and should not be used for any +/// other purposes. +/// +public class TachiyomiService : ITachiyomiService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly ILogger _logger; + private readonly IReaderService _readerService; + + private static readonly CultureInfo EnglishCulture = CultureInfo.CreateSpecificCulture("en-US"); + + public TachiyomiService(IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, IReaderService readerService) + { + _unitOfWork = unitOfWork; + _readerService = readerService; + _mapper = mapper; + _logger = logger; + } + + /// + /// Gets the latest chapter/volume read. + /// + /// + /// + /// Due to how Tachiyomi works we need a hack to properly return both chapters and volumes. + /// If its a chapter, return the chapterDto as is. + /// If it's a volume, the volume number gets returned in the 'Number' attribute of a chapterDto encoded. + /// The volume number gets divided by 10,000 because that's how Tachiyomi interprets volumes + public async Task GetLatestChapter(int seriesId, int userId) + { + + + var currentChapter = await _readerService.GetContinuePoint(seriesId, userId); + + var prevChapterId = + await _readerService.GetPrevChapterIdAsync(seriesId, currentChapter.VolumeId, currentChapter.Id, userId); + + // If prevChapterId is -1, this means either nothing is read or everything is read. + if (prevChapterId == -1) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + var userHasProgress = series.PagesRead != 0 && series.PagesRead <= series.Pages; + + // If the user doesn't have progress, then return null, which the extension will catch as 204 (no content) and report nothing as read + if (!userHasProgress) return null; + + // Else return the max chapter to Tachiyomi so it can consider everything read + var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(seriesId)).ToImmutableList(); + var looseLeafChapterVolume = volumes.FirstOrDefault(v => v.Number == 0); + if (looseLeafChapterVolume == null) + { + var volumeChapter = _mapper.Map(volumes.Last().Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparerZeroFirst.Default).Last()); + if (volumeChapter.Number == "0") + { + var volume = volumes.First(v => v.Id == volumeChapter.VolumeId); + return new ChapterDto() + { + // Use R to ensure that localization of underlying system doesn't affect the stringification + // https://docs.microsoft.com/en-us/globalization/locale/number-formatting-in-dotnet-framework + Number = (volume.Number / 10_000f).ToString("R", EnglishCulture) + }; + } + + return new ChapterDto() + { + Number = (int.Parse(volumeChapter.Number) / 10_000f).ToString("R", EnglishCulture) + }; + } + + var lastChapter = looseLeafChapterVolume.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default).Last(); + return _mapper.Map(lastChapter); + } + + // There is progress, we now need to figure out the highest volume or chapter and return that. + var prevChapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(prevChapterId); + var volumeWithProgress = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(prevChapter.VolumeId, userId); + // We only encode for single-file volumes + if (volumeWithProgress.Number != 0 && volumeWithProgress.Chapters.Count == 1) + { + // The progress is on a volume, encode it as a fake chapterDTO + return new ChapterDto() + { + // Use R to ensure that localization of underlying system doesn't affect the stringification + // https://docs.microsoft.com/en-us/globalization/locale/number-formatting-in-dotnet-framework + Number = (volumeWithProgress.Number / 10_000f).ToString("R", EnglishCulture) + + }; + } + + // Progress is just on a chapter, return as is + return prevChapter; + } + + /// + /// Marks every chapter and volume that is sorted below the passed number as Read. This will not mark any specials as read. + /// Passed number will also be marked as read + /// + /// + /// + /// Can also be a Tachiyomi encoded volume number + public async Task MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber) + { + userWithProgress.Progresses ??= new List(); + + switch (chapterNumber) + { + // When Tachiyomi sync's progress, if there is no current progress in Tachiyomi, 0.0f is sent. + // Due to the encoding for volumes, this marks all chapters in volume 0 (loose chapters) as read. + // Hence we catch and return early, so we ignore the request. + case 0.0f: + return true; + case < 1.0f: + { + // This is a hack to track volume number. We need to map it back by x10,000 + var volumeNumber = int.Parse($"{(int)(chapterNumber * 10_000)}", EnglishCulture); + await _readerService.MarkVolumesUntilAsRead(userWithProgress, seriesId, volumeNumber); + break; + } + default: + await _readerService.MarkChaptersUntilAsRead(userWithProgress, seriesId, chapterNumber); + break; + } + + try { + _unitOfWork.UserRepository.Update(userWithProgress); + + if (!_unitOfWork.HasChanges()) return true; + if (await _unitOfWork.CommitAsync()) return true; + } catch (Exception ex) { + _logger.LogError(ex, "There was an error saving progress from tachiyomi"); + await _unitOfWork.RollbackAsync(); + } + return false; + } +}