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 API.Extensions; 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.Find(v => v.Number == 0); if (looseLeafChapterVolume == null) { var volumeChapter = _mapper.Map(volumes [^1].Chapters .OrderBy(c => c.Number.AsFloat(), 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 => double.Parse(c.Number, CultureInfo.InvariantCulture), 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; } }