mirror of
				https://github.com/Kareadita/Kavita.git
				synced 2025-11-04 03:27:05 -05:00 
			
		
		
		
	* Started designing the backend localization service * Worked in Transloco for initial PoC * Worked in Transloco for initial PoC * Translated the login screen * translated dashboard screen * Started work on the backend * Fixed a logic bug * translated edit-user screen * Hooked up the backend for having a locale property. * Hooked up the ability to view the available locales and switch to them. * Made the localization service languages be derived from what's in langs/ directory. * Fixed up localization switching * Switched when we check for a license on UI bootstrap * Tweaked some code * Fixed the bug where dashboard wasn't loading and made it so language switching is working. * Fixed a bug on dashboard with languagePath * Converted user-scrobble-history.component.html * Converted spoiler.component.html * Converted review-series-modal.component.html * Converted review-card-modal.component.html * Updated the readme * Translated using Weblate (English) Currently translated at 100.0% (54 of 54 strings) Translation: Kavita/ui Translate-URL: https://hosted.weblate.org/projects/kavita/ui/en/ * Converted review-card.component.html * Deleted dead component * Converted want-to-read.component.html * Added translation using Weblate (Korean) * Translated using Weblate (Spanish) Currently translated at 40.7% (22 of 54 strings) Translation: Kavita/ui Translate-URL: https://hosted.weblate.org/projects/kavita/ui/es/ * Translated using Weblate (Korean) Currently translated at 62.9% (34 of 54 strings) Translation: Kavita/ui Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/ * Converted user-preferences.component.html * Translated using Weblate (Korean) Currently translated at 92.5% (50 of 54 strings) Translation: Kavita/ui Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/ * Converted user-holds.component.html * Converted theme-manager.component.html * Converted restriction-selector.component.html * Converted manage-devices.component.html * Converted edit-device.component.html * Converted change-password.component.html * Converted change-email.component.html * Converted change-age-restriction.component.html * Converted api-key.component.html * Converted anilist-key.component.html * Converted typeahead.component.html * Converted user-stats-info-cards.component.html * Converted user-stats.component.html * Converted top-readers.component.html * Converted some pipes and ensure translation is loaded before the app. * Finished all but one pipe for localization * Converted directory-picker.component.html * Converted library-access-modal.component.html * Converted a few components * Converted a few components * Converted a few components * Converted a few components * Converted a few components * Merged weblate in * ... -> … update * Updated the readme * Updateded all fonts to be woff2 * Cleaned up some strings to increase re-use * Removed an old flow (that doesn't exist in backend any longer) from when we introduced emails on Kavita. * Converted Series detail * Lots more converted * Lots more converted & hooked up the ability to flatten during prod build the language files. * Lots more converted * Lots more converted & fixed a bunch of broken pipes due to inject() * Lots more converted * Lots more converted * Lots more converted & fixed some bad keys * Lots more converted * Fixed some bugs with admin dasbhoard nested tabs not rendering on first load due to not using onpush change detection * Fixed up some localization errors and fixed forgot password error when the user doesn't have change password permission * Fixed a stupid build issue again * Started adding errors for interceptor and backend. * Finished off manga-reader * More translations * Few fixes * Fixed a bug where character tag badges weren't showing the name on chapter info * All components are translated * All toasts are translated * All confirm/alerts are translated * Trying something new for the backend * Migrated the localization strings for the backend into a new file. * Updated the localization service to be able to do backend localization with fallback to english. * Cleaned up some external reviews code to reduce looping * Localized AccountController.cs * 60% done with controllers * All controllers are done * All KavitaExceptions are covered * Some shakeout fixes * Prep for initial merge * Everything is done except options and basic shakeout proves response times are good. Unit tests are broken. * Fixed up the unit tests * All unit tests are now working * Removed some quantifier * I'm not sure I can support localization for some Volume/Chapter/Book strings within the codebase. --------- Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> Co-authored-by: majora2007 <kavitareader@gmail.com> Co-authored-by: expertjun <jtrobin@naver.com> Co-authored-by: ThePromidius <thepromidiusyt@gmail.com>
		
			
				
	
	
		
			766 lines
		
	
	
		
			32 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			766 lines
		
	
	
		
			32 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System;
 | 
						|
using System.Collections;
 | 
						|
using System.Collections.Generic;
 | 
						|
using System.IO;
 | 
						|
using System.Linq;
 | 
						|
using System.Threading.Tasks;
 | 
						|
using API.Comparators;
 | 
						|
using API.Data;
 | 
						|
using API.Data.Repositories;
 | 
						|
using API.DTOs;
 | 
						|
using API.DTOs.Reader;
 | 
						|
using API.Entities;
 | 
						|
using API.Entities.Enums;
 | 
						|
using API.Extensions;
 | 
						|
using API.Services.Plus;
 | 
						|
using API.Services.Tasks;
 | 
						|
using API.Services.Tasks.Scanner.Parser;
 | 
						|
using API.SignalR;
 | 
						|
using Hangfire;
 | 
						|
using Kavita.Common;
 | 
						|
using Microsoft.Extensions.Logging;
 | 
						|
 | 
						|
namespace API.Services;
 | 
						|
 | 
						|
public interface IReaderService
 | 
						|
{
 | 
						|
    Task MarkSeriesAsRead(AppUser user, int seriesId);
 | 
						|
    Task MarkSeriesAsUnread(AppUser user, int seriesId);
 | 
						|
    Task MarkChaptersAsRead(AppUser user, int seriesId, IList<Chapter> chapters);
 | 
						|
    Task MarkChaptersAsUnread(AppUser user, int seriesId, IList<Chapter> chapters);
 | 
						|
    Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
 | 
						|
    Task<Tuple<int, int>> CapPageToChapter(int chapterId, int page);
 | 
						|
    int CapPageToChapter(Chapter chapter, int page);
 | 
						|
    Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
 | 
						|
    Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
 | 
						|
    Task<ChapterDto> GetContinuePoint(int seriesId, int userId);
 | 
						|
    Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber);
 | 
						|
    Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber);
 | 
						|
    HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub);
 | 
						|
    IDictionary<int, int> GetPairs(IEnumerable<FileDimensionDto> dimensions);
 | 
						|
    Task<string> GetThumbnail(Chapter chapter, int pageNum, IEnumerable<string> cachedImages);
 | 
						|
}
 | 
						|
 | 
						|
public class ReaderService : IReaderService
 | 
						|
{
 | 
						|
    private readonly IUnitOfWork _unitOfWork;
 | 
						|
    private readonly ILogger<ReaderService> _logger;
 | 
						|
    private readonly IEventHub _eventHub;
 | 
						|
    private readonly IImageService _imageService;
 | 
						|
    private readonly IDirectoryService _directoryService;
 | 
						|
    private readonly IScrobblingService _scrobblingService;
 | 
						|
    private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
 | 
						|
    private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default;
 | 
						|
 | 
						|
    private const float MinWordsPerHour = 10260F;
 | 
						|
    private const float MaxWordsPerHour = 30000F;
 | 
						|
    public const float AvgWordsPerHour = (MaxWordsPerHour + MinWordsPerHour) / 2F;
 | 
						|
    private const float MinPagesPerMinute = 3.33F;
 | 
						|
    private const float MaxPagesPerMinute = 2.75F;
 | 
						|
    public const float AvgPagesPerMinute = (MaxPagesPerMinute + MinPagesPerMinute) / 2F; //3.04
 | 
						|
 | 
						|
 | 
						|
    public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger, IEventHub eventHub, IImageService imageService,
 | 
						|
        IDirectoryService directoryService, IScrobblingService scrobblingService)
 | 
						|
    {
 | 
						|
        _unitOfWork = unitOfWork;
 | 
						|
        _logger = logger;
 | 
						|
        _eventHub = eventHub;
 | 
						|
        _imageService = imageService;
 | 
						|
        _directoryService = directoryService;
 | 
						|
        _scrobblingService = scrobblingService;
 | 
						|
    }
 | 
						|
 | 
						|
    public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId)
 | 
						|
    {
 | 
						|
        return Tasks.Scanner.Parser.Parser.NormalizePath(Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}"));
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Does not commit. Marks all entities under the series as read.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="user"></param>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    public async Task MarkSeriesAsRead(AppUser user, int seriesId)
 | 
						|
    {
 | 
						|
        var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId);
 | 
						|
        user.Progresses ??= new List<AppUserProgress>();
 | 
						|
        foreach (var volume in volumes)
 | 
						|
        {
 | 
						|
            await MarkChaptersAsRead(user, seriesId, volume.Chapters);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Does not commit. Marks all entities under the series as unread.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="user"></param>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    public async Task MarkSeriesAsUnread(AppUser user, int seriesId)
 | 
						|
    {
 | 
						|
        var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId);
 | 
						|
        user.Progresses ??= new List<AppUserProgress>();
 | 
						|
        foreach (var volume in volumes)
 | 
						|
        {
 | 
						|
            await MarkChaptersAsUnread(user, seriesId, volume.Chapters);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit.
 | 
						|
    /// </summary>
 | 
						|
    /// <remarks>Emits events to the UI for each chapter progress and one for each volume progress</remarks>
 | 
						|
    /// <param name="user"></param>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <param name="chapters"></param>
 | 
						|
    public async Task MarkChaptersAsRead(AppUser user, int seriesId, IList<Chapter> chapters)
 | 
						|
    {
 | 
						|
        var seenVolume = new Dictionary<int, bool>();
 | 
						|
        var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
 | 
						|
        if (series == null) throw new KavitaException("series-doesnt-exist");
 | 
						|
        foreach (var chapter in chapters)
 | 
						|
        {
 | 
						|
            var userProgress = GetUserProgressForChapter(user, chapter);
 | 
						|
 | 
						|
            if (userProgress == null)
 | 
						|
            {
 | 
						|
                user.Progresses.Add(new AppUserProgress
 | 
						|
                {
 | 
						|
                    PagesRead = chapter.Pages,
 | 
						|
                    VolumeId = chapter.VolumeId,
 | 
						|
                    SeriesId = seriesId,
 | 
						|
                    ChapterId = chapter.Id,
 | 
						|
                    LibraryId = series.LibraryId
 | 
						|
                });
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                userProgress.PagesRead = chapter.Pages;
 | 
						|
                userProgress.SeriesId = seriesId;
 | 
						|
                userProgress.VolumeId = chapter.VolumeId;
 | 
						|
            }
 | 
						|
 | 
						|
            await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
 | 
						|
                MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, seriesId, chapter.VolumeId, chapter.Id, chapter.Pages));
 | 
						|
 | 
						|
            // Send out volume events for each distinct volume
 | 
						|
            if (!seenVolume.ContainsKey(chapter.VolumeId))
 | 
						|
            {
 | 
						|
                seenVolume[chapter.VolumeId] = true;
 | 
						|
                await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
 | 
						|
                    MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, seriesId,
 | 
						|
                        chapter.VolumeId, 0, chapters.Where(c => c.VolumeId == chapter.VolumeId).Sum(c => c.Pages)));
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        _unitOfWork.UserRepository.Update(user);
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Marks all Chapters as Unread by creating or updating UserProgress rows. Does not commit.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="user"></param>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <param name="chapters"></param>
 | 
						|
    public async Task MarkChaptersAsUnread(AppUser user, int seriesId, IList<Chapter> chapters)
 | 
						|
    {
 | 
						|
        var seenVolume = new Dictionary<int, bool>();
 | 
						|
        foreach (var chapter in chapters)
 | 
						|
        {
 | 
						|
            var userProgress = GetUserProgressForChapter(user, chapter);
 | 
						|
 | 
						|
            if (userProgress == null) continue;
 | 
						|
 | 
						|
            userProgress.PagesRead = 0;
 | 
						|
            userProgress.SeriesId = seriesId;
 | 
						|
            userProgress.VolumeId = chapter.VolumeId;
 | 
						|
 | 
						|
            await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
 | 
						|
                MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, userProgress.SeriesId, userProgress.VolumeId, userProgress.ChapterId, 0));
 | 
						|
 | 
						|
            // Send out volume events for each distinct volume
 | 
						|
            if (!seenVolume.ContainsKey(chapter.VolumeId))
 | 
						|
            {
 | 
						|
                seenVolume[chapter.VolumeId] = true;
 | 
						|
                await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
 | 
						|
                    MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, seriesId,
 | 
						|
                        chapter.VolumeId, 0, 0));
 | 
						|
            }
 | 
						|
        }
 | 
						|
        _unitOfWork.UserRepository.Update(user);
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Gets the User Progress for a given Chapter. This will handle any duplicates that might have occured in past versions and will delete them. Does not commit.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="user">Must have Progresses populated</param>
 | 
						|
    /// <param name="chapter"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    private static AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter)
 | 
						|
    {
 | 
						|
        AppUserProgress? userProgress = null;
 | 
						|
 | 
						|
        if (user.Progresses == null)
 | 
						|
        {
 | 
						|
            throw new KavitaException("progress-must-exist");
 | 
						|
        }
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            userProgress =
 | 
						|
                user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
 | 
						|
        }
 | 
						|
        catch (Exception)
 | 
						|
        {
 | 
						|
            // There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages
 | 
						|
            var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList();
 | 
						|
            if (progresses.Count > 1)
 | 
						|
            {
 | 
						|
                user.Progresses = new List<AppUserProgress>
 | 
						|
                {
 | 
						|
                    user.Progresses.First()
 | 
						|
                };
 | 
						|
                userProgress = user.Progresses.First();
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return userProgress;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Saves progress to DB
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="progressDto"></param>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId)
 | 
						|
    {
 | 
						|
        // Don't let user save past total pages.
 | 
						|
        var pageInfo = await CapPageToChapter(progressDto.ChapterId, progressDto.PageNum);
 | 
						|
        progressDto.PageNum = pageInfo.Item1;
 | 
						|
        var totalPages = pageInfo.Item2;
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            var userProgress =
 | 
						|
                await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId);
 | 
						|
 | 
						|
 | 
						|
            if (userProgress == null)
 | 
						|
            {
 | 
						|
                // Create a user object
 | 
						|
                var userWithProgress =
 | 
						|
                    await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress);
 | 
						|
                if (userWithProgress == null) return false;
 | 
						|
                userWithProgress.Progresses ??= new List<AppUserProgress>();
 | 
						|
                userWithProgress.Progresses.Add(new AppUserProgress
 | 
						|
                {
 | 
						|
                    PagesRead = progressDto.PageNum,
 | 
						|
                    VolumeId = progressDto.VolumeId,
 | 
						|
                    SeriesId = progressDto.SeriesId,
 | 
						|
                    ChapterId = progressDto.ChapterId,
 | 
						|
                    LibraryId = progressDto.LibraryId,
 | 
						|
                    BookScrollId = progressDto.BookScrollId
 | 
						|
                });
 | 
						|
                _unitOfWork.UserRepository.Update(userWithProgress);
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                userProgress.PagesRead = progressDto.PageNum;
 | 
						|
                userProgress.SeriesId = progressDto.SeriesId;
 | 
						|
                userProgress.VolumeId = progressDto.VolumeId;
 | 
						|
                userProgress.LibraryId = progressDto.LibraryId;
 | 
						|
                userProgress.BookScrollId = progressDto.BookScrollId;
 | 
						|
                _unitOfWork.AppUserProgressRepository.Update(userProgress);
 | 
						|
            }
 | 
						|
 | 
						|
            if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
 | 
						|
            {
 | 
						|
                var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
 | 
						|
                await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
 | 
						|
                    MessageFactory.UserProgressUpdateEvent(userId, user!.UserName!, progressDto.SeriesId,
 | 
						|
                        progressDto.VolumeId, progressDto.ChapterId, progressDto.PageNum));
 | 
						|
 | 
						|
                if (progressDto.PageNum >= totalPages)
 | 
						|
                {
 | 
						|
                    // Inform Scrobble service that a chapter is read
 | 
						|
                    BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, progressDto.SeriesId));
 | 
						|
                }
 | 
						|
 | 
						|
                BackgroundJob.Enqueue(() => _unitOfWork.SeriesRepository.ClearOnDeckRemoval(progressDto.SeriesId, userId));
 | 
						|
 | 
						|
                return true;
 | 
						|
            }
 | 
						|
        }
 | 
						|
        catch (Exception exception)
 | 
						|
        {
 | 
						|
            // This can happen when the reader sends 2 events at same time, so 2 threads are inserting and one fails.
 | 
						|
            if (exception.Message.StartsWith(
 | 
						|
                    "The database operation was expected to affect 1 row(s), but actually affected 0 row(s)"))
 | 
						|
                return true;
 | 
						|
            _logger.LogError(exception, "Could not save progress");
 | 
						|
            await _unitOfWork.RollbackAsync();
 | 
						|
        }
 | 
						|
 | 
						|
        return false;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Ensures that the page is within 0 and total pages for a chapter. Makes one DB call.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="chapterId"></param>
 | 
						|
    /// <param name="page"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<Tuple<int, int>> CapPageToChapter(int chapterId, int page)
 | 
						|
    {
 | 
						|
        if (page < 0)
 | 
						|
        {
 | 
						|
            page = 0;
 | 
						|
        }
 | 
						|
 | 
						|
        var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);
 | 
						|
        if (page > totalPages)
 | 
						|
        {
 | 
						|
            page = totalPages;
 | 
						|
        }
 | 
						|
 | 
						|
        return Tuple.Create(page, totalPages);
 | 
						|
    }
 | 
						|
 | 
						|
    public int CapPageToChapter(Chapter chapter, int page)
 | 
						|
    {
 | 
						|
        if (page > chapter.Pages)
 | 
						|
        {
 | 
						|
            page = chapter.Pages;
 | 
						|
        }
 | 
						|
 | 
						|
        if (page < 0)
 | 
						|
        {
 | 
						|
            page = 0;
 | 
						|
        }
 | 
						|
 | 
						|
        return page;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Tries to find the next logical Chapter
 | 
						|
    /// </summary>
 | 
						|
    /// <example>
 | 
						|
    /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → V0 chapter 1 -> V0 chapter 2 -> SP 01 → SP 02
 | 
						|
    /// </example>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <param name="volumeId"></param>
 | 
						|
    /// <param name="currentChapterId"></param>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <returns>-1 if nothing can be found</returns>
 | 
						|
    public async Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
 | 
						|
    {
 | 
						|
        var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))
 | 
						|
            .ToList();
 | 
						|
        var currentVolume = volumes.Single(v => v.Id == volumeId);
 | 
						|
        var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
 | 
						|
 | 
						|
        if (currentVolume.Number == 0)
 | 
						|
        {
 | 
						|
            // Handle specials by sorting on their Filename aka Range
 | 
						|
            var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range), currentChapter.Range, dto => dto.Range);
 | 
						|
            if (chapterId > 0) return chapterId;
 | 
						|
        }
 | 
						|
 | 
						|
        var currentVolumeNumber = float.Parse(currentVolume.Name);
 | 
						|
        var next = false;
 | 
						|
        foreach (var volume in volumes)
 | 
						|
        {
 | 
						|
            var volumeNumbersMatch = Math.Abs(float.Parse(volume.Name) - currentVolumeNumber) < 0.00001f;
 | 
						|
            if (volumeNumbersMatch && volume.Chapters.Count > 1)
 | 
						|
            {
 | 
						|
                // Handle Chapters within current Volume
 | 
						|
                // In this case, i need 0 first because 0 represents a full volume file.
 | 
						|
                var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer),
 | 
						|
                    currentChapter.Range, dto => dto.Range);
 | 
						|
                if (chapterId > 0) return chapterId;
 | 
						|
                next = true;
 | 
						|
                continue;
 | 
						|
            }
 | 
						|
 | 
						|
            if (volumeNumbersMatch)
 | 
						|
            {
 | 
						|
                next = true;
 | 
						|
                continue;
 | 
						|
            }
 | 
						|
 | 
						|
            if (!next) continue;
 | 
						|
 | 
						|
            // Handle Chapters within next Volume
 | 
						|
            // ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+
 | 
						|
            var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList();
 | 
						|
            if (currentChapter.Number.Equals(Parser.DefaultChapter) && chapters.Last().Number.Equals(Parser.DefaultChapter))
 | 
						|
            {
 | 
						|
                // We need to handle an extra check if the current chapter is the last special, as we should return -1
 | 
						|
                if (currentChapter.IsSpecial) return -1;
 | 
						|
 | 
						|
                return chapters.Last().Id;
 | 
						|
            }
 | 
						|
 | 
						|
            var firstChapter = chapters.FirstOrDefault();
 | 
						|
            if (firstChapter == null) break;
 | 
						|
            var isSpecial = firstChapter.IsSpecial || currentChapter.IsSpecial;
 | 
						|
            if (isSpecial)
 | 
						|
            {
 | 
						|
                var chapterId = GetNextChapterId(volume.Chapters.OrderByNatural(x => x.Number),
 | 
						|
                    currentChapter.Range, dto => dto.Range);
 | 
						|
                if (chapterId > 0) return chapterId;
 | 
						|
            } else if (double.Parse(firstChapter.Number) >= double.Parse(currentChapter.Number)) return firstChapter.Id;
 | 
						|
            // If we are the last chapter and next volume is there, we should try to use it (unless it's volume 0)
 | 
						|
            else if (double.Parse(firstChapter.Number) == 0) return firstChapter.Id;
 | 
						|
 | 
						|
            // If on last volume AND there are no specials left, then let's return -1
 | 
						|
            var anySpecials = volumes.Where(v => $"{v.Number}" == Parser.DefaultVolume)
 | 
						|
                .SelectMany(v => v.Chapters.Where(c => c.IsSpecial)).Any();
 | 
						|
            if (currentVolume.Number != 0 && !anySpecials)
 | 
						|
            {
 | 
						|
                return -1;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
 | 
						|
        // If we are the last volume and we didn't find any next volume, loop back to volume 0 and give the first chapter
 | 
						|
        // This has an added problem that it will loop up to the beginning always
 | 
						|
        // Should I change this to Max number? volumes.LastOrDefault()?.Number -> volumes.Max(v => v.Number)
 | 
						|
 | 
						|
        if (currentVolume.Number != 0 && currentVolume.Number == volumes.LastOrDefault()?.Number && volumes.Count > 1)
 | 
						|
        {
 | 
						|
            var chapterVolume = volumes.FirstOrDefault();
 | 
						|
            if (chapterVolume?.Number != 0) return -1;
 | 
						|
 | 
						|
            // This is my attempt at fixing a bug where we loop around to the beginning, but I just can't seem to figure it out
 | 
						|
            // var orderedVolumes = volumes.OrderBy(v => v.Number, SortComparerZeroLast.Default).ToList();
 | 
						|
            // if (currentVolume.Number == orderedVolumes.FirstOrDefault().Number)
 | 
						|
            // {
 | 
						|
            //     // We can move into loose leaf chapters
 | 
						|
            //     //var firstLooseLeaf = volumes.LastOrDefault().Chapters.MinBy(x => double.Parse(x.Number), _chapterSortComparer);
 | 
						|
            //     var nextChapterId = GetNextChapterId(
 | 
						|
            //         volumes.LastOrDefault().Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer),
 | 
						|
            //         "0", dto => dto.Range);
 | 
						|
            //     // CHECK if we need a IsSpecial check
 | 
						|
            //     if (nextChapterId > 0) return nextChapterId;
 | 
						|
            // }
 | 
						|
 | 
						|
 | 
						|
            var firstChapter = chapterVolume.Chapters.MinBy(x => double.Parse(x.Number), _chapterSortComparer);
 | 
						|
            if (firstChapter == null) return -1;
 | 
						|
 | 
						|
 | 
						|
            return firstChapter.Id;
 | 
						|
        }
 | 
						|
 | 
						|
        return -1;
 | 
						|
    }
 | 
						|
    /// <summary>
 | 
						|
    /// Tries to find the prev logical Chapter
 | 
						|
    /// </summary>
 | 
						|
    /// <example>
 | 
						|
    /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← V0 chapter 1 ← V0 chapter 2 ← SP 01 ← SP 02
 | 
						|
    /// </example>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <param name="volumeId"></param>
 | 
						|
    /// <param name="currentChapterId"></param>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <returns>-1 if nothing can be found</returns>
 | 
						|
    public async Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
 | 
						|
    {
 | 
						|
        var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList();
 | 
						|
        var currentVolume = volumes.Single(v => v.Id == volumeId);
 | 
						|
        var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
 | 
						|
 | 
						|
        if (currentVolume.Number == 0)
 | 
						|
        {
 | 
						|
            var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range).Reverse(), currentChapter.Range,
 | 
						|
                dto => dto.Range);
 | 
						|
            if (chapterId > 0) return chapterId;
 | 
						|
        }
 | 
						|
 | 
						|
        var next = false;
 | 
						|
        foreach (var volume in volumes)
 | 
						|
        {
 | 
						|
            if (volume.Number == currentVolume.Number)
 | 
						|
            {
 | 
						|
                var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(),
 | 
						|
                    currentChapter.Range, dto => dto.Range);
 | 
						|
                if (chapterId > 0) return chapterId;
 | 
						|
                next = true; // When the diff between volumes is more than 1, we need to explicitly tell that next volume is our use case
 | 
						|
                continue;
 | 
						|
            }
 | 
						|
            if (next)
 | 
						|
            {
 | 
						|
                if (currentVolume.Number - 1 == 0) break; // If we have walked all the way to chapter volume, then we should break so logic outside can work
 | 
						|
                var lastChapter = volume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
 | 
						|
                if (lastChapter == null) return -1;
 | 
						|
                return lastChapter.Id;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        var lastVolume = volumes.MaxBy(v => v.Number);
 | 
						|
        if (currentVolume.Number == 0 && currentVolume.Number != lastVolume?.Number && lastVolume?.Chapters.Count > 1)
 | 
						|
        {
 | 
						|
            var lastChapter = lastVolume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
 | 
						|
            if (lastChapter == null) return -1;
 | 
						|
            return lastChapter.Id;
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        return -1;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Finds the chapter to continue reading from. If a chapter has progress and not complete, return that. If not, progress in the
 | 
						|
    /// ordering (Volumes -> Loose Chapters -> Special) to find next chapter. If all are read, return first in order for series.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <param name="userId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<ChapterDto> GetContinuePoint(int seriesId, int userId)
 | 
						|
    {
 | 
						|
        var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
 | 
						|
 | 
						|
         if (!await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId))
 | 
						|
         {
 | 
						|
             // I think i need a way to sort volumes last
 | 
						|
             return volumes.OrderBy(v => double.Parse(v.Number + string.Empty), _chapterSortComparer).First().Chapters
 | 
						|
                 .OrderBy(c => float.Parse(c.Number)).First();
 | 
						|
         }
 | 
						|
 | 
						|
        // Loop through all chapters that are not in volume 0
 | 
						|
        var volumeChapters = volumes
 | 
						|
            .Where(v => v.Number != 0)
 | 
						|
            .SelectMany(v => v.Chapters)
 | 
						|
            .ToList();
 | 
						|
 | 
						|
        // NOTE: If volume 1 has chapter 1 and volume 2 is just chapter 0 due to being a full volume file, then this fails
 | 
						|
        // If there are any volumes that have progress, return those. If not, move on.
 | 
						|
        var currentlyReadingChapter = volumeChapters
 | 
						|
            .OrderBy(c => double.Parse(c.Number), _chapterSortComparer)
 | 
						|
            .FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0);
 | 
						|
        if (currentlyReadingChapter != null) return currentlyReadingChapter;
 | 
						|
 | 
						|
        // Order with volume 0 last so we prefer the natural order
 | 
						|
        return FindNextReadingChapter(volumes.OrderBy(v => v.Number, SortComparerZeroLast.Default)
 | 
						|
                                             .SelectMany(v => v.Chapters.OrderBy(c => double.Parse(c.Number))).ToList());
 | 
						|
    }
 | 
						|
 | 
						|
    private static ChapterDto FindNextReadingChapter(IList<ChapterDto> volumeChapters)
 | 
						|
    {
 | 
						|
        var chaptersWithProgress = volumeChapters.Where(c => c.PagesRead > 0).ToList();
 | 
						|
        if (chaptersWithProgress.Count <= 0) return volumeChapters[0];
 | 
						|
 | 
						|
 | 
						|
        var last = chaptersWithProgress.FindLastIndex(c => c.PagesRead > 0);
 | 
						|
        if (last + 1 < chaptersWithProgress.Count)
 | 
						|
        {
 | 
						|
            return chaptersWithProgress[last + 1];
 | 
						|
        }
 | 
						|
 | 
						|
        var lastChapter = chaptersWithProgress[last];
 | 
						|
        if (lastChapter.PagesRead < lastChapter.Pages)
 | 
						|
        {
 | 
						|
            return lastChapter;
 | 
						|
        }
 | 
						|
 | 
						|
        // If the last chapter didn't fit, then we need the next chapter without full progress
 | 
						|
        var firstChapterWithoutProgress = volumeChapters.FirstOrDefault(c => c.PagesRead < c.Pages && !c.IsSpecial);
 | 
						|
        if (firstChapterWithoutProgress != null)
 | 
						|
        {
 | 
						|
            return firstChapterWithoutProgress;
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        // chaptersWithProgress are all read, then we need to get the next chapter that doesn't have progress
 | 
						|
        var lastIndexWithProgress = volumeChapters.IndexOf(lastChapter);
 | 
						|
        if (lastIndexWithProgress + 1 < volumeChapters.Count)
 | 
						|
        {
 | 
						|
            return volumeChapters[lastIndexWithProgress + 1];
 | 
						|
        }
 | 
						|
 | 
						|
        return volumeChapters[0];
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    private static int GetNextChapterId(IEnumerable<ChapterDto> chapters, string currentChapterNumber, Func<ChapterDto, string> accessor)
 | 
						|
    {
 | 
						|
        var next = false;
 | 
						|
        var chaptersList = chapters.ToList();
 | 
						|
        foreach (var chapter in chaptersList)
 | 
						|
        {
 | 
						|
            if (next)
 | 
						|
            {
 | 
						|
                return chapter.Id;
 | 
						|
            }
 | 
						|
 | 
						|
            var chapterNum = accessor(chapter);
 | 
						|
            if (currentChapterNumber.Equals(chapterNum)) next = true;
 | 
						|
        }
 | 
						|
 | 
						|
        return -1;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read or Volumes with a single 0 chapter.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="user"></param>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <param name="chapterNumber"></param>
 | 
						|
    public async Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber)
 | 
						|
    {
 | 
						|
        var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
 | 
						|
        foreach (var volume in volumes.OrderBy(v => v.Number))
 | 
						|
        {
 | 
						|
            var chapters = volume.Chapters
 | 
						|
                .Where(c => !c.IsSpecial && Tasks.Scanner.Parser.Parser.MaxNumberFromRange(c.Range) <= chapterNumber)
 | 
						|
                .OrderBy(c => float.Parse(c.Number));
 | 
						|
            await MarkChaptersAsRead(user, volume.SeriesId, chapters.ToList());
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber)
 | 
						|
    {
 | 
						|
        var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
 | 
						|
        foreach (var volume in volumes.Where(v => v.Number <= volumeNumber && v.Number > 0).OrderBy(v => v.Number))
 | 
						|
        {
 | 
						|
            await MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub)
 | 
						|
    {
 | 
						|
        if (isEpub)
 | 
						|
        {
 | 
						|
            var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0);
 | 
						|
            var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0);
 | 
						|
            return new HourEstimateRangeDto
 | 
						|
            {
 | 
						|
                MinHours = Math.Min(minHours, maxHours),
 | 
						|
                MaxHours = Math.Max(minHours, maxHours),
 | 
						|
                AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
 | 
						|
            };
 | 
						|
        }
 | 
						|
 | 
						|
        var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0);
 | 
						|
        var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0);
 | 
						|
        return new HourEstimateRangeDto
 | 
						|
        {
 | 
						|
            MinHours = Math.Min(minHoursPages, maxHoursPages),
 | 
						|
            MaxHours = Math.Max(minHoursPages, maxHoursPages),
 | 
						|
            AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// This is used exclusively for double page renderer. The goal is to break up all files into pairs respecting the reader.
 | 
						|
    /// wide images should count as 2 pages.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="dimensions"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public IDictionary<int, int> GetPairs(IEnumerable<FileDimensionDto> dimensions)
 | 
						|
    {
 | 
						|
        var pairs = new Dictionary<int, int>();
 | 
						|
        var files = dimensions.ToList();
 | 
						|
        if (files.Count == 0) return pairs;
 | 
						|
 | 
						|
        var pairStart = true;
 | 
						|
        var previousPage = files[0];
 | 
						|
        pairs.Add(previousPage.PageNumber, previousPage.PageNumber);
 | 
						|
 | 
						|
        foreach(var dimension in files.Skip(1))
 | 
						|
        {
 | 
						|
            if (dimension.IsWide)
 | 
						|
            {
 | 
						|
                pairs.Add(dimension.PageNumber, dimension.PageNumber);
 | 
						|
                pairStart = true;
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                if (previousPage.IsWide || previousPage.PageNumber == 0)
 | 
						|
                {
 | 
						|
                    pairs.Add(dimension.PageNumber, dimension.PageNumber);
 | 
						|
                    pairStart = true;
 | 
						|
                }
 | 
						|
                else
 | 
						|
                {
 | 
						|
                    pairs.Add(dimension.PageNumber, pairStart ? dimension.PageNumber - 1 : dimension.PageNumber);
 | 
						|
                    pairStart = !pairStart;
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            previousPage = dimension;
 | 
						|
        }
 | 
						|
 | 
						|
        return pairs;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    ///
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="chapter"></param>
 | 
						|
    /// <param name="pageNum"></param>
 | 
						|
    /// <param name="cachedImages"></param>
 | 
						|
    /// <returns>Full path of thumbnail</returns>
 | 
						|
    public async Task<string> GetThumbnail(Chapter chapter, int pageNum, IEnumerable<string> cachedImages)
 | 
						|
    {
 | 
						|
        var outputDirectory =
 | 
						|
            _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, ImageService.GetThumbnailFormat(chapter.Id));
 | 
						|
        try
 | 
						|
        {
 | 
						|
            var encodeFormat =
 | 
						|
                (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
 | 
						|
 | 
						|
            if (!Directory.Exists(outputDirectory))
 | 
						|
            {
 | 
						|
                var outputtedThumbnails = cachedImages
 | 
						|
                    .Select((img, idx) =>
 | 
						|
                        _directoryService.FileSystem.Path.Join(outputDirectory,
 | 
						|
                            _imageService.WriteCoverThumbnail(img, $"{idx}", outputDirectory, encodeFormat)))
 | 
						|
                    .ToArray();
 | 
						|
                return CacheService.GetPageFromFiles(outputtedThumbnails, pageNum);
 | 
						|
            }
 | 
						|
 | 
						|
            var files = _directoryService.GetFilesWithExtension(outputDirectory,
 | 
						|
                Tasks.Scanner.Parser.Parser.ImageFileExtensions);
 | 
						|
            return CacheService.GetPageFromFiles(files, pageNum);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            _logger.LogError(ex, "There was an error when trying to get thumbnail for Chapter {ChapterId}, Page {PageNum}", chapter.Id, pageNum);
 | 
						|
            _directoryService.ClearAndDeleteDirectory(outputDirectory);
 | 
						|
            throw;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Formats a Chapter name based on the library it's in
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryType"></param>
 | 
						|
    /// <param name="includeHash">For comics only, includes a # which is used for numbering on cards</param>
 | 
						|
    /// <param name="includeSpace">Add a space at the end of the string. if includeHash and includeSpace are true, only hash will be at the end.</param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public static string FormatChapterName(LibraryType libraryType, bool includeHash = false, bool includeSpace = false)
 | 
						|
    {
 | 
						|
        switch(libraryType)
 | 
						|
        {
 | 
						|
            case LibraryType.Manga:
 | 
						|
                return "Chapter" + (includeSpace ? " " : string.Empty);
 | 
						|
            case LibraryType.Comic:
 | 
						|
                if (includeHash) {
 | 
						|
                    return "Issue #";
 | 
						|
                }
 | 
						|
                return "Issue" + (includeSpace ? " " : string.Empty);
 | 
						|
            case LibraryType.Book:
 | 
						|
                return "Book" + (includeSpace ? " " : string.Empty);
 | 
						|
            default:
 | 
						|
                throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
}
 |