Tachiyomi unit tests and fixes (#1549)

* Moved logic from TachiyomiController.cs to TachiyomiService.cs

* Added GetLatestChapter Unit Tests

* Tachiyomi more tests.
Implemented test for yearly volumes

* MarkVolumesUntilAsRead unit test

* Registered tachiyomi service.
Added new test

* Fixed test pages

* Added missing check if its single-file volume

* Removed dead code

* Added method documentation and breaked thousands with `_`

* Review details and renamed test method to be more descriptive

* Review changes
- Removed automapper
- Added spaces after commas
- Added class documentation (copied from controller)
- Made Culture static
- Added 'R' doc linking to docs.ms
- Added trycatch to service when saving progress and logged
- Removed redundant qualifiers

* finishing touches

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
This commit is contained in:
ThePromidius 2022-09-20 18:46:46 +02:00 committed by GitHub
parent 090c4e279c
commit e2fb19b288
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 1027 additions and 87 deletions

View File

@ -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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("10", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("20", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("30", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("Some Special Title", true, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1997", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2002", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2003", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("10", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("20", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("30", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("Some Special Title", true, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1997", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2002", new List<Chapter>()
{
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2003", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false, new List<MangaFile>(), 1),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
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
}

View File

@ -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<AutoMapperProfiles>());
_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<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);
_context.Library.Add(new Library()
{
Name = "Manga", Folders = new List<FolderPath>() {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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", true, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("4", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
}),
},
Pages = 7
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", true, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("4", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
}),
},
Pages = 7
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("23", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
}),
},
Pages = 7
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", true, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("23", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
}),
},
Pages = 7
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 199),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 192),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 255),
}),
},
Pages = 646
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1997", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2002", new List<Chapter>()
{
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2005", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false, new List<MangaFile>(), 1),
}),
},
Pages = 7
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Comic,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", true, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("4", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
}),
},
Pages = 7
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", true, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("4", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
}),
},
Pages = 7
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("23", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
}),
},
Pages = 7
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("95", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("96", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", true, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("23", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("32", false, new List<MangaFile>(), 1),
}),
},
Pages = 7
};
var library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>() { series }
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
library
}
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For<ILogger<ReaderService>>(), 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
}

View File

@ -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;
}
/// <summary>
@ -39,53 +31,9 @@ public class TachiyomiController : BaseApiController
[HttpGet("latest-chapter")]
public async Task<ActionResult<ChapterDto>> 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<ChapterDto>(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<ChapterDto>(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));
}
/// <summary>
@ -97,34 +45,6 @@ public class TachiyomiController : BaseApiController
public async Task<ActionResult<bool>> MarkChaptersUntilAsRead(int seriesId, float chapterNumber)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
user.Progresses ??= new List<AppUserProgress>();
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));
}
}

View File

@ -54,6 +54,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IMetadataService, MetadataService>();
services.AddScoped<IWordCountAnalyzerService, WordCountAnalyzerService>();
services.AddScoped<ILibraryWatcher, LibraryWatcher>();
services.AddScoped<ITachiyomiService, TachiyomiService>();
services.AddScoped<IPresenceTracker, PresenceTracker>();
services.AddScoped<IEventHub, EventHub>();

View File

@ -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<ChapterDto> GetLatestChapter(int seriesId, int userId);
Task<bool> MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber);
}
/// <summary>
/// All APIs are for Tachiyomi extension and app. They have hacks for our implementation and should not be used for any
/// other purposes.
/// </summary>
public class TachiyomiService : ITachiyomiService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<ReaderService> _logger;
private readonly IReaderService _readerService;
private static readonly CultureInfo EnglishCulture = CultureInfo.CreateSpecificCulture("en-US");
public TachiyomiService(IUnitOfWork unitOfWork, IMapper mapper, ILogger<ReaderService> logger, IReaderService readerService)
{
_unitOfWork = unitOfWork;
_readerService = readerService;
_mapper = mapper;
_logger = logger;
}
/// <summary>
/// Gets the latest chapter/volume read.
/// </summary>
/// <param name="seriesId"></param>
/// <param name="userId"></param>
/// <returns>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</returns>
public async Task<ChapterDto> 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<ChapterDto>(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<ChapterDto>(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;
}
/// <summary>
/// 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
/// </summary>
/// <param name="userWithProgress"></param>
/// <param name="seriesId"></param>
/// <param name="chapterNumber">Can also be a Tachiyomi encoded volume number</param>
public async Task<bool> MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber)
{
userWithProgress.Progresses ??= new List<AppUserProgress>();
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;
}
}