Feature/enhancements and more (#1166)
* Moved libraryType into chapter info * Fixed a bug where you could not reset cover on a series * Patched in relevant changes from another polish branch * Refactored invite user setup to shift the checking for accessibility to the backend and always show the link. This will help with users who have some unique setups in docker. * Refactored invite user to always print the url to setup a new account. * Single page renderer uses canvasImage rather than re-requesting and relying on cache * Fixed a rendering issue where fit to split on single on a cover wouldn't force width scaling just for that image * Fixed a rendering bug with split image functionality * Added title to copy button * Fixed a bug in GetContinuePoint when a chapter is added to an already read volume and a new chapter is added loose leaf. The loose leaf would be prioritized over the volume chapter. Refactored 2 methods from controller into service and unit tested. * Fixed a bug on opening a volume in series detail that had a chapter added to it after the volume (0 chapter) was read would cause a loose leaf chapter to be opened. * Added mark as read/actionables on Files in volume detail modal. Fixed a bug where we were showing the wrong page count in a volume detail modal. * Removed OnDeck page and replaced it with a pre-filtered All-Series. Hooked up the ability to pass read state to the filter via query params. Fixed some spacing on filter post bootstrap update. * Fixed up some poor documentation on FilterDto. * Some string equals enhancements to reduce extra allocations * Fixed an issue when trying to download via a url, to remove query parameters to get the format * Made an optimization to Normalize method to reduce memory pressure by 100MB over the course of a scan (16k files) * Adjusted the styles on dashboard for first time setup and used a routerlink rather than href to avoid a fresh load. * Use framgment on router link * Hooked in the ability to search by release year (along with series optionally) and series will be returned back. * Fixed a bug in the filter format code where it was sending the wrong type * Only show clear all on typeahead when there are at least one selected item * Cleaned up the styles of the styles of the typeahead * Removed some dead code * Implemented the ability to filter against a series name. * Fixed filter top offset * Ensure that when we add or remove libraries, the side nav of users gets updated. * Tweaked the width on the mobile side nav * Close side nav on clicking overlay on mobile viewport * Don't show a pointer if the carousel section title is not actually selectable * Removed the User profile on the side nav so home is always first. Tweaked styles to match * Fixed up some poor documentation on FilterDto. * Fixed a bug where Latest read date wasn't being set due to an early short circuit. * When sending the chapter file, format the title of the FeedEntry more like Series Detail. * Removed dead code
This commit is contained in:
parent
67d8d3d808
commit
4a93b5c715
68 changed files with 663 additions and 451 deletions
|
@ -338,6 +338,12 @@ namespace API.Controllers
|
|||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no
|
||||
/// email will be sent.
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("invite")]
|
||||
public async Task<ActionResult<string>> InviteUser(InviteUserDto dto)
|
||||
|
@ -417,7 +423,9 @@ namespace API.Controllers
|
|||
|
||||
var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email);
|
||||
_logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
|
||||
if (dto.SendEmail)
|
||||
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
|
||||
var accessible = await _emailService.CheckIfAccessible(host);
|
||||
if (accessible)
|
||||
{
|
||||
await _emailService.SendConfirmationEmail(new ConfirmationEmailDto()
|
||||
{
|
||||
|
@ -426,7 +434,11 @@ namespace API.Controllers
|
|||
ServerConfirmationLink = emailLink
|
||||
});
|
||||
}
|
||||
return Ok(emailLink);
|
||||
return Ok(new InviteUserResponse
|
||||
{
|
||||
EmailLink = emailLink,
|
||||
EmailSent = accessible
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
|
|
@ -65,6 +65,8 @@ namespace API.Controllers
|
|||
return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Authorize(Policy="RequireDownloadRole")]
|
||||
[HttpGet("volume")]
|
||||
public async Task<ActionResult> DownloadVolume(int volumeId)
|
||||
|
|
|
@ -11,6 +11,7 @@ using API.Entities;
|
|||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -26,16 +27,18 @@ namespace API.Controllers
|
|||
private readonly IMapper _mapper;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
public LibraryController(IDirectoryService directoryService,
|
||||
ILogger<LibraryController> logger, IMapper mapper, ITaskScheduler taskScheduler,
|
||||
IUnitOfWork unitOfWork)
|
||||
IUnitOfWork unitOfWork, IEventHub eventHub)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
_taskScheduler = taskScheduler;
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -73,6 +76,8 @@ namespace API.Controllers
|
|||
|
||||
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
|
||||
_taskScheduler.ScanLibrary(library.Id);
|
||||
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
||||
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
@ -191,6 +196,9 @@ namespace API.Controllers
|
|||
await _unitOfWork.CommitAsync();
|
||||
_taskScheduler.CleanupChapters(chapterIds);
|
||||
}
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
||||
MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false);
|
||||
return Ok(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
@ -745,9 +745,18 @@ public class OpdsController : BaseApiController
|
|||
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
|
||||
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty);
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey));
|
||||
|
||||
|
||||
var title = $"{series.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}";
|
||||
var title = $"{series.Name} - ";
|
||||
if (volume.Chapters.Count == 1)
|
||||
{
|
||||
SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType);
|
||||
title += $"{volume.Name}";
|
||||
}
|
||||
else
|
||||
{
|
||||
title = $"{series.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}";
|
||||
}
|
||||
|
||||
// Chunky requires a file at the end. Our API ignores this
|
||||
var accLink =
|
||||
|
|
|
@ -109,14 +109,7 @@ namespace API.Controllers
|
|||
public async Task<ActionResult> MarkRead(MarkReadDto markReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
_readerService.MarkChaptersAsRead(user, markReadDto.SeriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
|
@ -137,14 +130,7 @@ namespace API.Controllers
|
|||
public async Task<ActionResult> MarkUnread(MarkReadDto markReadDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
_readerService.MarkChaptersAsUnread(user, markReadDto.SeriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
|
|
|
@ -46,7 +46,7 @@ namespace API.Controllers
|
|||
public async Task<ActionResult<string>> GetImageFromFile(UploadUrlDto dto)
|
||||
{
|
||||
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(dto.Url).Replace(".", "");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", "");
|
||||
var path = await dto.Url
|
||||
.DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}");
|
||||
|
||||
|
|
|
@ -16,6 +16,4 @@ public class InviteUserDto
|
|||
/// A list of libraries to grant access to
|
||||
/// </summary>
|
||||
public IList<int> Libraries { get; init; }
|
||||
|
||||
public bool SendEmail { get; init; } = true;
|
||||
}
|
||||
|
|
13
API/DTOs/Account/InviteUserResponse.cs
Normal file
13
API/DTOs/Account/InviteUserResponse.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
namespace API.DTOs.Account;
|
||||
|
||||
public class InviteUserResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Email link used to setup the user account
|
||||
/// </summary>
|
||||
public string EmailLink { get; set; }
|
||||
/// <summary>
|
||||
/// Was an email sent (ie is this server accessible)
|
||||
/// </summary>
|
||||
public bool EmailSent { get; set; }
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
|
@ -25,51 +26,51 @@ namespace API.DTOs.Filtering
|
|||
/// </summary>
|
||||
public IList<int> Genres { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Writers to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Writers to restrict search to. Defaults to all Writers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Writers { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Penciller ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Penciller ids to restrict search to. Defaults to all Pencillers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Penciller { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Inker ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Inker ids to restrict search to. Defaults to all Inkers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Inker { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Colorist ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Colorist ids to restrict search to. Defaults to all Colorists by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Colorist { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Letterer ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Letterer ids to restrict search to. Defaults to all Letterers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Letterer { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of CoverArtist ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of CoverArtist ids to restrict search to. Defaults to all CoverArtists by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> CoverArtist { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Editor ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Editor ids to restrict search to. Defaults to all Editors by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Editor { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Publisher ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Publisher ids to restrict search to. Defaults to all Publishers by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Publisher { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Character ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Character ids to restrict search to. Defaults to all Characters by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Character { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Translator ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Translator ids to restrict search to. Defaults to all Translatorss by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Translators { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Collection Tag ids to restrict search to. Defaults to all Collection Tags by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> CollectionTags { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
/// A list of Tag ids to restrict search to. Defaults to all genres by passing an empty list
|
||||
/// A list of Tag ids to restrict search to. Defaults to all Tags by passing an empty list
|
||||
/// </summary>
|
||||
public IList<int> Tags { get; init; } = new List<int>();
|
||||
/// <summary>
|
||||
|
@ -94,5 +95,10 @@ namespace API.DTOs.Filtering
|
|||
/// </summary>
|
||||
public IList<PublicationStatus> PublicationStatus { get; init; } = new List<PublicationStatus>();
|
||||
|
||||
/// <summary>
|
||||
/// An optional name string to filter by. Empty string will ignore.
|
||||
/// </summary>
|
||||
public string SeriesNameQuery { get; init; } = string.Empty;
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ namespace API.DTOs.Reader
|
|||
public MangaFormat SeriesFormat { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
public int LibraryId { get; set; }
|
||||
public LibraryType LibraryType { get; set; }
|
||||
public string ChapterTitle { get; set; } = string.Empty;
|
||||
public int Pages { get; set; }
|
||||
public string FileName { get; set; }
|
||||
|
|
|
@ -81,7 +81,8 @@ public class ChapterRepository : IChapterRepository
|
|||
data.TitleName,
|
||||
SeriesFormat = series.Format,
|
||||
SeriesName = series.Name,
|
||||
series.LibraryId
|
||||
series.LibraryId,
|
||||
LibraryType = series.Library.Type
|
||||
})
|
||||
.Select(data => new ChapterInfoDto()
|
||||
{
|
||||
|
@ -89,12 +90,13 @@ public class ChapterRepository : IChapterRepository
|
|||
VolumeNumber = data.VolumeNumber + string.Empty,
|
||||
VolumeId = data.VolumeId,
|
||||
IsSpecial = data.IsSpecial,
|
||||
SeriesId =data.SeriesId,
|
||||
SeriesId = data.SeriesId,
|
||||
SeriesFormat = data.SeriesFormat,
|
||||
SeriesName = data.SeriesName,
|
||||
LibraryId = data.LibraryId,
|
||||
Pages = data.Pages,
|
||||
ChapterTitle = data.TitleName
|
||||
ChapterTitle = data.TitleName,
|
||||
LibraryType = data.LibraryType
|
||||
})
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Scanner;
|
||||
using API.DTOs;
|
||||
|
@ -79,8 +80,8 @@ public interface ISeriesRepository
|
|||
/// <returns></returns>
|
||||
Task AddSeriesModifiers(int userId, List<SeriesDto> series);
|
||||
Task<string> GetSeriesCoverImageAsync(int seriesId);
|
||||
Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo
|
||||
Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true);
|
||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
|
||||
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
|
||||
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
|
||||
|
@ -283,17 +284,22 @@ public class SeriesRepository : ISeriesRepository
|
|||
|
||||
result.Libraries = await _context.Library
|
||||
.Where(l => libraryIds.Contains(l.Id))
|
||||
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%"))
|
||||
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
|
||||
.OrderBy(l => l.Name)
|
||||
.AsSplitQuery()
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
var justYear = Regex.Match(searchQuery, @"\d{4}").Value;
|
||||
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
|
||||
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
|
||||
|
||||
result.Series = await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")
|
||||
|| EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
|
||||
|| EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")
|
||||
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
|
||||
.Include(s => s.Library)
|
||||
.OrderBy(s => s.SortName)
|
||||
.AsNoTracking()
|
||||
|
@ -301,6 +307,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
result.ReadingLists = await _context.ReadingList
|
||||
.Where(rl => rl.AppUserId == userId || rl.Promoted)
|
||||
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
|
||||
|
@ -466,10 +473,16 @@ public class SeriesRepository : ISeriesRepository
|
|||
{
|
||||
s.PagesRead = userProgress.Where(p => p.SeriesId == s.Id).Sum(p => p.PagesRead);
|
||||
var rating = userRatings.SingleOrDefault(r => r.SeriesId == s.Id);
|
||||
if (rating == null) continue;
|
||||
s.UserRating = rating.Rating;
|
||||
s.UserReview = rating.Review;
|
||||
s.LatestReadDate = userProgress.Max(p => p.LastModified);
|
||||
if (rating != null)
|
||||
{
|
||||
s.UserRating = rating.Rating;
|
||||
s.UserReview = rating.Review;
|
||||
}
|
||||
|
||||
if (userProgress.Count > 0)
|
||||
{
|
||||
s.LatestReadDate = userProgress.Max(p => p.LastModified);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -508,7 +521,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
|
||||
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
|
||||
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter,
|
||||
out bool hasLanguageFilter, out bool hasPublicationFilter)
|
||||
out bool hasLanguageFilter, out bool hasPublicationFilter, out bool hasSeriesNameFilter)
|
||||
{
|
||||
var formats = filter.GetSqlFilter();
|
||||
|
||||
|
@ -581,6 +594,8 @@ public class SeriesRepository : ISeriesRepository
|
|||
.ToList();
|
||||
}
|
||||
|
||||
hasSeriesNameFilter = !string.IsNullOrEmpty(filter.SeriesNameQuery);
|
||||
|
||||
return formats;
|
||||
}
|
||||
|
||||
|
@ -593,11 +608,11 @@ public class SeriesRepository : ISeriesRepository
|
|||
/// <param name="userParams">Pagination information</param>
|
||||
/// <param name="filter">Optional (default null) filter on query</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
|
||||
public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true)
|
||||
{
|
||||
//var allSeriesWithProgress = await _context.AppUserProgresses.Select(p => p.SeriesId).ToListAsync();
|
||||
//var allChapters = await GetChapterIdsForSeriesAsync(allSeriesWithProgress);
|
||||
var cuttoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30);
|
||||
var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30);
|
||||
var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter))
|
||||
.Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) =>
|
||||
new
|
||||
|
@ -612,8 +627,12 @@ public class SeriesRepository : ISeriesRepository
|
|||
// This is only taking into account chapters that have progress on them, not all chapters in said series
|
||||
LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created)
|
||||
//LastChapterCreated = _context.Chapter.Where(c => allChapters.Contains(c.Id)).Max(c => c.Created)
|
||||
})
|
||||
.Where(d => d.LastReadingProgress >= cuttoffProgressPoint);
|
||||
});
|
||||
if (cutoffOnDate)
|
||||
{
|
||||
query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint);
|
||||
}
|
||||
|
||||
// I think I need another Join statement. The problem is the chapters are still limited to progress
|
||||
|
||||
|
||||
|
@ -638,7 +657,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
|
||||
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
|
||||
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
|
||||
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter);
|
||||
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter);
|
||||
|
||||
var query = _context.Series
|
||||
.Where(s => userLibraries.Contains(s.LibraryId)
|
||||
|
@ -652,8 +671,11 @@ public class SeriesRepository : ISeriesRepository
|
|||
&& (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating))
|
||||
&& (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
|
||||
&& (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language))
|
||||
&& (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))
|
||||
)
|
||||
&& (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)))
|
||||
.Where(s => !hasSeriesNameFilter ||
|
||||
EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
|
||||
|| EF.Functions.Like(s.OriginalName, $"%{filter.SeriesNameQuery}%")
|
||||
|| EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%"))
|
||||
.AsNoTracking();
|
||||
|
||||
// If no sort options, default to using SortName
|
||||
|
|
|
@ -220,7 +220,8 @@ public class UserRepository : IUserRepository
|
|||
|
||||
public async Task<AppUser> GetUserByEmailAsync(string email)
|
||||
{
|
||||
return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(email.ToLower()));
|
||||
var lowerEmail = email.ToLower();
|
||||
return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(lowerEmail));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAllUsers()
|
||||
|
|
|
@ -57,7 +57,7 @@ namespace API.Parser
|
|||
private static readonly Regex CoverImageRegex = new Regex(@"(?<![[a-z]\d])(?:!?)((?<!back)cover|folder)(?![\w\d])",
|
||||
MatchOptions, RegexTimeout);
|
||||
|
||||
private static readonly Regex NormalizeRegex = new Regex(@"[^a-zA-Z0-9\+]",
|
||||
private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+]",
|
||||
MatchOptions, RegexTimeout);
|
||||
|
||||
|
||||
|
@ -966,8 +966,7 @@ namespace API.Parser
|
|||
|
||||
public static string Normalize(string name)
|
||||
{
|
||||
var normalized = NormalizeRegex.Replace(name, string.Empty).ToLower();
|
||||
return string.IsNullOrEmpty(normalized) ? name : normalized;
|
||||
return NormalizeRegex.Replace(name, string.Empty).ToLower();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -1009,12 +1008,12 @@ namespace API.Parser
|
|||
|
||||
public static bool IsEpub(string filePath)
|
||||
{
|
||||
return Path.GetExtension(filePath).ToLower() == ".epub";
|
||||
return Path.GetExtension(filePath).Equals(".epub", StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsPdf(string filePath)
|
||||
{
|
||||
return Path.GetExtension(filePath).ToLower() == ".pdf";
|
||||
return Path.GetExtension(filePath).Equals(".pdf", StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -1025,8 +1024,7 @@ namespace API.Parser
|
|||
/// <returns></returns>
|
||||
public static string CleanAuthor(string author)
|
||||
{
|
||||
if (string.IsNullOrEmpty(author)) return string.Empty;
|
||||
return author.Trim();
|
||||
return string.IsNullOrEmpty(author) ? string.Empty : author.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -98,7 +98,7 @@ public class EmailService : IEmailService
|
|||
return await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data);
|
||||
}
|
||||
|
||||
private static async Task<bool> SendEmailWithGet(string url)
|
||||
private static async Task<bool> SendEmailWithGet(string url, int timeoutSecs = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -108,7 +108,7 @@ public class EmailService : IEmailService
|
|||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(30))
|
||||
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
|
||||
.GetStringAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(response) && bool.Parse(response))
|
||||
|
@ -124,7 +124,7 @@ public class EmailService : IEmailService
|
|||
}
|
||||
|
||||
|
||||
private static async Task<bool> SendEmailWithPost(string url, object data)
|
||||
private static async Task<bool> SendEmailWithPost(string url, object data, int timeoutSecs = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -134,7 +134,7 @@ public class EmailService : IEmailService
|
|||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(30))
|
||||
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
|
||||
.PostJsonAsync(data);
|
||||
|
||||
if (response.StatusCode != StatusCodes.Status200OK)
|
||||
|
|
|
@ -16,6 +16,8 @@ namespace API.Services;
|
|||
|
||||
public interface IReaderService
|
||||
{
|
||||
Task MarkSeriesAsRead(AppUser user, int seriesId);
|
||||
Task MarkSeriesAsUnread(AppUser user, int seriesId);
|
||||
void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
|
||||
|
@ -45,6 +47,40 @@ public class ReaderService : IReaderService
|
|||
return 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)
|
||||
{
|
||||
MarkChaptersAsRead(user, seriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
MarkChaptersAsUnread(user, seriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit.
|
||||
/// </summary>
|
||||
|
@ -367,7 +403,7 @@ public class ReaderService : IReaderService
|
|||
.ToList();
|
||||
|
||||
// If there are any volumes that have progress, return those. If not, move on.
|
||||
var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0);
|
||||
var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages); // (removed for GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress), not sure if needed && chapter.PagesRead > 0
|
||||
if (currentlyReadingChapter != null) return currentlyReadingChapter;
|
||||
|
||||
// Check loose leaf chapters (and specials). First check if there are any
|
||||
|
|
|
@ -445,20 +445,7 @@ public class SeriesService : ISeriesService
|
|||
var firstChapter = volume.Chapters.First();
|
||||
// On Books, skip volumes that are specials, since these will be shown
|
||||
if (firstChapter.IsSpecial) continue;
|
||||
if (string.IsNullOrEmpty(firstChapter.TitleName))
|
||||
{
|
||||
if (!firstChapter.Range.Equals(Parser.Parser.DefaultVolume))
|
||||
{
|
||||
var title = Path.GetFileNameWithoutExtension(firstChapter.Range);
|
||||
if (string.IsNullOrEmpty(title)) continue;
|
||||
volume.Name += $" - {title}";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
volume.Name += $" - {firstChapter.TitleName}";
|
||||
}
|
||||
|
||||
RenameVolumeName(firstChapter, volume, libraryType);
|
||||
processedVolumes.Add(volume);
|
||||
}
|
||||
}
|
||||
|
@ -517,48 +504,64 @@ public class SeriesService : ISeriesService
|
|||
return !c.IsSpecial && !c.Number.Equals(Parser.Parser.DefaultChapter);
|
||||
}
|
||||
|
||||
public static string FormatChapterTitle(ChapterDto chapter, LibraryType libraryType)
|
||||
public static void RenameVolumeName(ChapterDto firstChapter, VolumeDto volume, LibraryType libraryType)
|
||||
{
|
||||
if (chapter.IsSpecial)
|
||||
if (libraryType == LibraryType.Book)
|
||||
{
|
||||
return Parser.Parser.CleanSpecialTitle(chapter.Title);
|
||||
if (string.IsNullOrEmpty(firstChapter.TitleName))
|
||||
{
|
||||
if (firstChapter.Range.Equals(Parser.Parser.DefaultVolume)) return;
|
||||
var title = Path.GetFileNameWithoutExtension(firstChapter.Range);
|
||||
if (string.IsNullOrEmpty(title)) return;
|
||||
volume.Name += $" - {title}";
|
||||
}
|
||||
else
|
||||
{
|
||||
volume.Name += $" - {firstChapter.TitleName}";
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
volume.Name = $"Volume {volume.Name}";
|
||||
}
|
||||
|
||||
|
||||
private static string FormatChapterTitle(bool isSpecial, LibraryType libraryType, string chapterTitle, bool withHash)
|
||||
{
|
||||
if (isSpecial)
|
||||
{
|
||||
return Parser.Parser.CleanSpecialTitle(chapterTitle);
|
||||
}
|
||||
|
||||
var hashSpot = withHash ? "#" : string.Empty;
|
||||
return libraryType switch
|
||||
{
|
||||
LibraryType.Book => $"Book {chapter.Title}",
|
||||
LibraryType.Comic => $"Issue #{chapter.Title}",
|
||||
LibraryType.Manga => $"Chapter {chapter.Title}",
|
||||
LibraryType.Book => $"Book {chapterTitle}",
|
||||
LibraryType.Comic => $"Issue {hashSpot}{chapterTitle}",
|
||||
LibraryType.Manga => $"Chapter {chapterTitle}",
|
||||
_ => "Chapter "
|
||||
};
|
||||
}
|
||||
|
||||
public static string FormatChapterTitle(Chapter chapter, LibraryType libraryType)
|
||||
public static string FormatChapterTitle(ChapterDto chapter, LibraryType libraryType, bool withHash = true)
|
||||
{
|
||||
if (chapter.IsSpecial)
|
||||
{
|
||||
return Parser.Parser.CleanSpecialTitle(chapter.Title);
|
||||
}
|
||||
return libraryType switch
|
||||
{
|
||||
LibraryType.Book => $"Book {chapter.Title}",
|
||||
LibraryType.Comic => $"Issue #{chapter.Title}",
|
||||
LibraryType.Manga => $"Chapter {chapter.Title}",
|
||||
_ => "Chapter "
|
||||
};
|
||||
return FormatChapterTitle(chapter.IsSpecial, libraryType, chapter.Title, withHash);
|
||||
}
|
||||
|
||||
public static string FormatChapterTitle(Chapter chapter, LibraryType libraryType, bool withHash = true)
|
||||
{
|
||||
return FormatChapterTitle(chapter.IsSpecial, libraryType, chapter.Title, withHash);
|
||||
}
|
||||
|
||||
public static string FormatChapterName(LibraryType libraryType, bool withHash = false)
|
||||
{
|
||||
switch (libraryType)
|
||||
return libraryType switch
|
||||
{
|
||||
case LibraryType.Manga:
|
||||
return "Chapter";
|
||||
case LibraryType.Comic:
|
||||
return withHash ? "Issue #" : "Issue";
|
||||
case LibraryType.Book:
|
||||
return "Book";
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null);
|
||||
}
|
||||
LibraryType.Manga => "Chapter",
|
||||
LibraryType.Comic => withHash ? "Issue #" : "Issue",
|
||||
LibraryType.Book => "Book",
|
||||
_ => "Chapter"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,6 +74,10 @@ namespace API.SignalR
|
|||
/// When DB updates are occuring during a library/series scan
|
||||
/// </summary>
|
||||
private const string ScanProgress = "ScanProgress";
|
||||
/// <summary>
|
||||
/// When a library is created/deleted in the Server
|
||||
/// </summary>
|
||||
public const string LibraryModified = "LibraryModified";
|
||||
|
||||
|
||||
public static SignalRMessage ScanSeriesEvent(int seriesId, string seriesName)
|
||||
|
@ -225,6 +229,22 @@ namespace API.SignalR
|
|||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage LibraryModifiedEvent(int libraryId, string action)
|
||||
{
|
||||
return new SignalRMessage
|
||||
{
|
||||
Name = LibraryModified,
|
||||
Title = "Library modified",
|
||||
Progress = ProgressType.None,
|
||||
EventType = ProgressEventType.Single,
|
||||
Body = new
|
||||
{
|
||||
LibrayId = libraryId,
|
||||
Action = action,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage DownloadProgressEvent(string username, string downloadName, float progress, string eventType = "updated")
|
||||
{
|
||||
return new SignalRMessage()
|
||||
|
@ -270,27 +290,8 @@ namespace API.SignalR
|
|||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage DbUpdateProgressEvent(Series series, string eventType)
|
||||
{
|
||||
// TODO: I want this as a detail of a Scanning Series and we can put more information like Volume or Chapter here
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = ScanProgress,
|
||||
Title = $"Scanning {series.Library.Name}",
|
||||
SubTitle = series.Name,
|
||||
EventType = eventType,
|
||||
Progress = ProgressType.Indeterminate,
|
||||
Body = new
|
||||
{
|
||||
Title = "Updating Series",
|
||||
SubTitle = series.Name
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage LibraryScanProgressEvent(string libraryName, string eventType, string seriesName = "")
|
||||
{
|
||||
// TODO: I want this as a detail of a Scanning Series and we can put more information like Volume or Chapter here
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = ScanProgress,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue