.NET 7 + Spring Cleaning (#1677)

* Updated to net7.0

* Updated GA to .net 7

* Updated System.IO.Abstractions to use New factory.

* Converted Regex into SourceGenerator in Parser.

* Updated more regex to source generators.

* Enabled Nullability and more regex changes throughout codebase.

* Parser is 100% GeneratedRegexified

* Lots of nullability code

* Enabled nullability for all repositories.

* Fixed another unit test

* Refactored some code around and took care of some todos.

* Updating code for nullability and cleaning up methods that aren't used anymore. Refctored all uses of Parser.Normalize() to use new extension

* More nullability exercises. 500 warnings to go.

* Fixed a bug where custom file uploads for entities wouldn't save in webP.

* Nullability is done for all DTOs

* Fixed all unit tests and nullability for the project. Only OPDS is left which will be done with an upcoming OPDS enhancement.

* Use localization in book service after validating

* Code smells

* Switched to preview build of swashbuckle for .net7 support

* Fixed up merge issues

* Disable emulate comic book when on single page reader

* Fixed a regression where double page renderer wouldn't layout the images correctly

* Updated to swashbuckle which support .net 7

* Fixed a bad GA action

* Some code cleanup

* More code smells

* Took care of most of nullable issues

* Fixed a broken test due to having more than one test run in parallel

* I'm really not sure why the unit tests are failing or are so extremely slow on .net 7

* Updated all dependencies

* Fixed up build and removed hardcoded framework from build scripts. (this merge removes Regex Source generators). Unit tests are completely busted.

* Unit tests and code cleanup. Needs shakeout now.

* Adjusted Series model since a few fields are not-nullable. Removed dead imports on the project.

* Refactored to use Builder pattern for all unit tests.

* Switched nullability down to warnings. It wasn't possible to switch due to constraint issues in DB Migration.
This commit is contained in:
Joe Milazzo 2023-03-05 14:55:13 -06:00 committed by GitHub
parent 76fe3fd64a
commit 5d1dd7b3f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
283 changed files with 4221 additions and 4593 deletions

View file

@ -21,8 +21,9 @@ public interface IAccountService
Task<IEnumerable<ApiException>> ValidatePassword(AppUser user, string password);
Task<IEnumerable<ApiException>> ValidateUsername(string username);
Task<IEnumerable<ApiException>> ValidateEmail(string email);
Task<bool> HasBookmarkPermission(AppUser user);
Task<bool> HasDownloadPermission(AppUser user);
Task<bool> HasBookmarkPermission(AppUser? user);
Task<bool> HasDownloadPermission(AppUser? user);
Task<bool> HasChangeRestrictionRole(AppUser? user);
Task<bool> CheckIfAccessible(HttpRequest request);
Task<string> GenerateEmailLink(HttpRequest request, string token, string routePart, string email, bool withHost = true);
}
@ -137,8 +138,9 @@ public class AccountService : IAccountService
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
public async Task<bool> HasBookmarkPermission(AppUser user)
public async Task<bool> HasBookmarkPermission(AppUser? user)
{
if (user == null) return false;
var roles = await _userManager.GetRolesAsync(user);
return roles.Contains(PolicyConstants.BookmarkRole) || roles.Contains(PolicyConstants.AdminRole);
}
@ -148,8 +150,9 @@ public class AccountService : IAccountService
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
public async Task<bool> HasDownloadPermission(AppUser user)
public async Task<bool> HasDownloadPermission(AppUser? user)
{
if (user == null) return false;
var roles = await _userManager.GetRolesAsync(user);
return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole);
}
@ -159,8 +162,9 @@ public class AccountService : IAccountService
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
public async Task<bool> HasChangeRestrictionRole(AppUser user)
public async Task<bool> HasChangeRestrictionRole(AppUser? user)
{
if (user == null) return false;
var roles = await _userManager.GetRolesAsync(user);
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
}

View file

@ -22,7 +22,7 @@ public interface IArchiveService
int GetNumberOfPagesFromArchive(string archivePath);
string GetCoverImage(string archivePath, string fileName, string outputDirectory, bool saveAsWebP = false);
bool IsValidArchive(string archivePath);
ComicInfo GetComicInfo(string archivePath);
ComicInfo? GetComicInfo(string archivePath);
ArchiveLibrary CanOpen(string archivePath);
bool ArchiveNeedsFlattening(ZipArchive archive);
/// <summary>
@ -129,7 +129,7 @@ public class ArchiveService : IArchiveService
/// </summary>
/// <param name="entryFullNames"></param>
/// <returns>Entry name of match, null if no match</returns>
public static string FindFolderEntry(IEnumerable<string> entryFullNames)
public static string? FindFolderEntry(IEnumerable<string> entryFullNames)
{
var result = entryFullNames
.Where(path => !(Path.EndsInDirectorySeparator(path) || Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)))
@ -163,7 +163,7 @@ public class ArchiveService : IArchiveService
// Check the first folder and sort within that to see if we can find a file, else fallback to first file with basic sort.
// Get first folder, then sort within that
var firstDirectoryFile = fullNames.OrderByNatural(Path.GetDirectoryName).FirstOrDefault();
var firstDirectoryFile = fullNames.OrderByNatural(Path.GetDirectoryName!).FirstOrDefault();
if (!string.IsNullOrEmpty(firstDirectoryFile))
{
var firstDirectory = Path.GetDirectoryName(firstDirectoryFile);
@ -249,7 +249,7 @@ public class ArchiveService : IArchiveService
/// <param name="archivePath"></param>
/// <param name="entryNames"></param>
/// <returns></returns>
public static string FindCoverImageFilename(string archivePath, IEnumerable<string> entryNames)
public static string? FindCoverImageFilename(string archivePath, IEnumerable<string> entryNames)
{
var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath));
return entryName;
@ -281,7 +281,7 @@ public class ArchiveService : IArchiveService
var dateString = DateTime.UtcNow.ToShortDateString().Replace("/", "_");
var tempLocation = Path.Join(_directoryService.TempDirectory, $"{tempFolder}_{dateString}");
var potentialExistingFile = _directoryService.FileSystem.FileInfo.FromFileName(Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip"));
var potentialExistingFile = _directoryService.FileSystem.FileInfo.New(Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip"));
if (potentialExistingFile.Exists)
{
// A previous download exists, just return it immediately
@ -331,8 +331,9 @@ public class ArchiveService : IArchiveService
return false;
}
private static bool IsComicInfoArchiveEntry(string fullName, string name)
private static bool IsComicInfoArchiveEntry(string? fullName, string name)
{
if (fullName == null) return false;
return !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(fullName)
&& name.EndsWith(ComicInfoFilename, StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith);
@ -364,7 +365,7 @@ public class ArchiveService : IArchiveService
{
using var stream = entry.Open();
var serializer = new XmlSerializer(typeof(ComicInfo));
var info = (ComicInfo) serializer.Deserialize(stream);
var info = (ComicInfo?) serializer.Deserialize(stream);
ComicInfo.CleanComicInfo(info);
return info;
}
@ -382,7 +383,7 @@ public class ArchiveService : IArchiveService
{
using var stream = entry.OpenEntryStream();
var serializer = new XmlSerializer(typeof(ComicInfo));
var info = (ComicInfo) serializer.Deserialize(stream);
var info = (ComicInfo?) serializer.Deserialize(stream);
ComicInfo.CleanComicInfo(info);
return info;
}

View file

@ -33,8 +33,17 @@ public interface IBookService
{
int GetNumberOfPages(string filePath);
string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false);
ComicInfo GetComicInfo(string filePath);
ParserInfo ParseInfo(string filePath);
ComicInfo? GetComicInfo(string filePath);
ParserInfo? ParseInfo(string filePath);
/// <summary>
/// Scopes styles to .reading-section and replaces img src to the passed apiBase
/// </summary>
/// <param name="stylesheetHtml"></param>
/// <param name="apiBase"></param>
/// <param name="filename">If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath.</param>
/// <param name="book">Book Reference, needed for if you expect Import statements</param>
/// <returns></returns>
Task<string> ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book);
/// <summary>
/// Extracts a PDF file's pages as images to an target directory
/// </summary>
@ -64,6 +73,27 @@ public class BookService : IBookService
}
};
// Use when Rosyln fixed
// [GeneratedRegex(@"/\*[\d\D]*?\*/", RegexOptions.Compiled)]
// private static partial Regex CssComment();
//
// [GeneratedRegex(@"[a-zA-Z]+#", RegexOptions.Compiled)]
// private static partial Regex WhiteSpace1();
// [GeneratedRegex(@"[\n\r]+\s*", RegexOptions.Compiled)]
// private static partial Regex WhiteSpace2();
// [GeneratedRegex(@"\s+", RegexOptions.Compiled)]
// private static partial Regex WhiteSpace3();
// [GeneratedRegex(@"\s?([:,;{}])\s?", RegexOptions.Compiled)]
// private static partial Regex WhiteSpace4();
// [GeneratedRegex(@"([\s:]0)(px|pt|%|em)", RegexOptions.Compiled)]
// private static partial Regex UnitPadding();
//
// [GeneratedRegex(@"<script(.*)(/>)", RegexOptions.Compiled)]
// private static partial Regex StartingScriptTag();
// [GeneratedRegex(@"<title(.*)(/>)", RegexOptions.Compiled)]
// private static partial Regex StartingTitleTag();
public BookService(ILogger<BookService> logger, IDirectoryService directoryService, IImageService imageService)
{
_logger = logger;
@ -163,6 +193,7 @@ public class BookService : IBookService
// @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be Scoped
var prepend = filename.Length > 0 ? filename.Replace(Path.GetFileName(filename), string.Empty) : string.Empty;
var importBuilder = new StringBuilder();
//foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex().Matches(stylesheetHtml))
foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml))
{
if (!match.Success) continue;
@ -200,7 +231,7 @@ public class BookService : IBookService
foreach (var styleRule in stylesheet.StyleRules)
{
if (styleRule.Selector.Text == CssScopeClass) continue;
if (styleRule.Selector.Text.Contains(","))
if (styleRule.Selector.Text.Contains(','))
{
styleRule.Text = styleRule.Text.Replace(styleRule.SelectorText,
string.Join(", ",
@ -214,6 +245,7 @@ public class BookService : IBookService
private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend)
{
//foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex().Matches(stylesheetHtml))
foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml))
{
if (!match.Success) continue;
@ -224,6 +256,7 @@ public class BookService : IBookService
private static void EscapeFontFamilyReferences(ref string stylesheetHtml, string apiBase, string prepend)
{
//foreach (Match match in Tasks.Scanner.Parser.Parser.FontSrcUrlRegex().Matches(stylesheetHtml))
foreach (Match match in Tasks.Scanner.Parser.Parser.FontSrcUrlRegex.Matches(stylesheetHtml))
{
if (!match.Success) continue;
@ -234,6 +267,7 @@ public class BookService : IBookService
private static void EscapeCssImageReferences(ref string stylesheetHtml, string apiBase, EpubBookRef book)
{
//var matches = Tasks.Scanner.Parser.Parser.CssImageUrlRegex().Matches(stylesheetHtml);
var matches = Tasks.Scanner.Parser.Parser.CssImageUrlRegex.Matches(stylesheetHtml);
foreach (Match match in matches)
{
@ -260,7 +294,7 @@ public class BookService : IBookService
foreach (var image in images)
{
string key = null;
string? key = null;
if (image.Attributes["src"] != null)
{
key = "src";
@ -388,7 +422,7 @@ public class BookService : IBookService
}
}
public ComicInfo GetComicInfo(string filePath)
public ComicInfo? GetComicInfo(string filePath)
{
if (!IsValidFile(filePath) || Tasks.Scanner.Parser.Parser.IsPdf(filePath)) return null;
@ -474,7 +508,7 @@ public class BookService : IBookService
return null;
}
private static (int year, int month, int day) GetPublicationDate(string publicationDate)
{
var dateParsed = DateTime.TryParse(publicationDate, out var date);
@ -496,23 +530,19 @@ public class BookService : IBookService
return (year, month, day);
}
#nullable enable
private static string ValidateLanguage(string? language)
{
if (string.IsNullOrEmpty(language)) return string.Empty;
try
{
CultureInfo.GetCultureInfo(language);
return CultureInfo.GetCultureInfo(language).ToString();
}
catch (Exception)
{
return string.Empty;
}
return language;
}
#nullable disable
private bool IsValidFile(string filePath)
{
@ -553,6 +583,8 @@ public class BookService : IBookService
private static string EscapeTags(string content)
{
// content = StartingScriptTag().Replace(content, "<script$1></script>");
// content = StartingTitleTag().Replace(content, "<title$1></title>");
content = Regex.Replace(content, @"<script(.*)(/>)", "<script$1></script>", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
content = Regex.Replace(content, @"<title(.*)(/>)", "<title$1></title>", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
return content;
@ -588,7 +620,7 @@ public class BookService : IBookService
/// </summary>
/// <param name="filePath"></param>
/// <returns></returns>
public ParserInfo ParseInfo(string filePath)
public ParserInfo? ParseInfo(string filePath)
{
if (!Tasks.Scanner.Parser.Parser.IsEpub(filePath)) return null;
@ -656,7 +688,7 @@ public class BookService : IBookService
Edition = string.Empty,
Format = MangaFormat.Epub,
Filename = Path.GetFileName(filePath),
Title = specialName?.Trim(),
Title = specialName?.Trim() ?? string.Empty,
FullFilePath = filePath,
IsSpecial = false,
Series = series.Trim(),
@ -1037,6 +1069,12 @@ public class BookService : IBookService
}
// Remove comments from CSS
// body = CssComment().Replace(body, string.Empty);
//
// body = WhiteSpace1().Replace(body, "#");
// body = WhiteSpace2().Replace(body, string.Empty);
// body = WhiteSpace3().Replace(body, " ");
// body = WhiteSpace4().Replace(body, "$1");
body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty, RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
body = Regex.Replace(body, @"[a-zA-Z]+#", "#", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
@ -1052,6 +1090,7 @@ public class BookService : IBookService
//Swallow exception. Some css don't have style rules ending in ';'
}
//body = UnitPadding().Replace(body, "$1");
body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Reader;
using API.Entities;
@ -50,21 +49,23 @@ public class BookmarkService : IBookmarkService
/// Deletes the files associated with the list of Bookmarks passed. Will clean up empty folders.
/// </summary>
/// <param name="bookmarks"></param>
public async Task DeleteBookmarkFiles(IEnumerable<AppUserBookmark> bookmarks)
public async Task DeleteBookmarkFiles(IEnumerable<AppUserBookmark?> bookmarks)
{
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var bookmarkFilesToDelete = bookmarks.Select(b => Tasks.Scanner.Parser.Parser.NormalizePath(
_directoryService.FileSystem.Path.Join(bookmarkDirectory,
b.FileName))).ToList();
var bookmarkFilesToDelete = bookmarks
.Where(b => b != null)
.Select(b => Tasks.Scanner.Parser.Parser.NormalizePath(
_directoryService.FileSystem.Path.Join(bookmarkDirectory, b!.FileName)))
.ToList();
if (bookmarkFilesToDelete.Count == 0) return;
_directoryService.DeleteFiles(bookmarkFilesToDelete);
// Delete any leftover folders
foreach (var directory in _directoryService.FileSystem.Directory.GetDirectories(bookmarkDirectory, "", SearchOption.AllDirectories))
foreach (var directory in _directoryService.FileSystem.Directory.GetDirectories(bookmarkDirectory, string.Empty, SearchOption.AllDirectories))
{
if (_directoryService.FileSystem.Directory.GetFiles(directory, "", SearchOption.AllDirectories).Length == 0 &&
_directoryService.FileSystem.Directory.GetDirectories(directory).Length == 0)
@ -92,7 +93,7 @@ public class BookmarkService : IBookmarkService
return true;
}
var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(imageToBookmark);
var fileInfo = _directoryService.FileSystem.FileInfo.New(imageToBookmark);
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var targetFolderStem = BookmarkStem(userWithBookmarks.Id, bookmarkDto.SeriesId, bookmarkDto.ChapterId);
var targetFilepath = Path.Join(settings.BookmarksDirectory, targetFolderStem);
@ -136,7 +137,6 @@ public class BookmarkService : IBookmarkService
/// <returns></returns>
public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto)
{
if (userWithBookmarks.Bookmarks == null) return true;
var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x =>
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page);
try
@ -215,6 +215,8 @@ public class BookmarkService : IBookmarkService
var count = 1F;
foreach (var chapter in chapters)
{
if (string.IsNullOrEmpty(chapter.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, chapter.CoverImage, coverDirectory);
chapter.CoverImage = Path.GetFileName(newFile);
_unitOfWork.ChapterRepository.Update(chapter);

View file

@ -4,7 +4,6 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.DTOs.Reader;
using API.Entities;
@ -25,7 +24,7 @@ public interface ICacheService
/// <param name="chapterId"></param>
/// <param name="extractPdfToImages">Extracts a PDF into images for a different reading experience</param>
/// <returns>Chapter for the passed chapterId. Side-effect from ensuring cache.</returns>
Task<Chapter> Ensure(int chapterId, bool extractPdfToImages = false);
Task<Chapter?> Ensure(int chapterId, bool extractPdfToImages = false);
/// <summary>
/// Clears cache directory of all volumes. This can be invoked from deleting a library or a series.
/// </summary>
@ -132,14 +131,21 @@ public class CacheService : ICacheService
{
var extractPath = GetCachePath(chapter.Id);
var path = Path.Join(extractPath, _directoryService.FileSystem.Path.GetFileName(chapter.Files.First().FilePath));
if (!(_directoryService.FileSystem.FileInfo.FromFileName(path).Exists))
if (!(_directoryService.FileSystem.FileInfo.New(path).Exists))
{
path = chapter.Files.First().FilePath;
}
return path;
}
public async Task<Chapter> Ensure(int chapterId, bool extractPdfToImages = false)
/// <summary>
/// Caches the files for the given chapter to CacheDirectory
/// </summary>
/// <param name="chapterId"></param>
/// <param name="extractPdfToImages">Defaults to false. Extract pdf file into images rather than copying just the pdf file</param>
/// <returns>This will always return the Chapter for the chapterId</returns>
public async Task<Chapter?> Ensure(int chapterId, bool extractPdfToImages = false)
{
_directoryService.ExistOrCreate(_directoryService.CacheDirectory);
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
@ -160,12 +166,13 @@ public class CacheService : ICacheService
/// <param name="files"></param>
/// <param name="extractPdfImages">Defaults to false, if true, will extract the images from the PDF renderer and not move the pdf file</param>
/// <returns></returns>
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files, bool extractPdfImages = false)
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile>? files, bool extractPdfImages = false)
{
if (files == null) return;
var removeNonImages = true;
var fileCount = files.Count;
var extraPath = string.Empty;
var extractDi = _directoryService.FileSystem.DirectoryInfo.FromDirectoryName(extractPath);
var extractDi = _directoryService.FileSystem.DirectoryInfo.New(extractPath);
if (files.Count > 0 && files[0].Format == MangaFormat.Image)
{

View file

@ -3,12 +3,12 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.CollectionTags;
using API.Entities;
using API.Entities.Metadata;
using API.SignalR;
using Kavita.Common;
using Microsoft.Extensions.Logging;
namespace API.Services;
@ -17,10 +17,10 @@ public interface ICollectionTagService
{
Task<bool> TagExistsByName(string name);
Task<bool> UpdateTag(CollectionTagDto dto);
Task<bool> AddTagToSeries(CollectionTag tag, IEnumerable<int> seriesIds);
Task<bool> RemoveTagFromSeries(CollectionTag tag, IEnumerable<int> seriesIds);
Task<bool> AddTagToSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
Task<CollectionTag> GetTagOrCreate(int tagId, string title);
void AddTagToSeriesMetadata(CollectionTag tag, SeriesMetadata metadata);
void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata);
CollectionTag CreateTag(string title);
Task<bool> RemoveTagsWithoutSeries();
}
@ -93,8 +93,9 @@ public class CollectionTagService : ICollectionTagService
/// <param name="tag">A full Tag</param>
/// <param name="seriesIds"></param>
/// <returns></returns>
public async Task<bool> AddTagToSeries(CollectionTag tag, IEnumerable<int> seriesIds)
public async Task<bool> AddTagToSeries(CollectionTag? tag, IEnumerable<int> seriesIds)
{
if (tag == null) return false;
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(seriesIds);
foreach (var metadata in metadatas)
{
@ -112,8 +113,9 @@ public class CollectionTagService : ICollectionTagService
/// <param name="tag"></param>
/// <param name="metadata"></param>
/// <returns></returns>
public void AddTagToSeriesMetadata(CollectionTag tag, SeriesMetadata metadata)
public void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata)
{
if (tag == null) return;
metadata.CollectionTags ??= new List<CollectionTag>();
if (metadata.CollectionTags.Any(t => t.NormalizedTitle.Equals(tag.NormalizedTitle, StringComparison.InvariantCulture))) return;
@ -124,8 +126,9 @@ public class CollectionTagService : ICollectionTagService
}
}
public async Task<bool> RemoveTagFromSeries(CollectionTag tag, IEnumerable<int> seriesIds)
public async Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds)
{
if (tag == null) return false;
foreach (var seriesIdToRemove in seriesIds)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
@ -150,7 +153,7 @@ public class CollectionTagService : ICollectionTagService
/// <returns></returns>
public async Task<CollectionTag> GetTagOrCreate(int tagId, string title)
{
return await _unitOfWork.CollectionTagRepository.GetFullTagAsync(tagId) ?? CreateTag(title);
return await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata) ?? CreateTag(title);
}
/// <summary>

View file

@ -8,7 +8,6 @@ using API.DTOs.Email;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.Device;
using API.SignalR;
using Kavita.Common;
using Microsoft.Extensions.Logging;
@ -16,8 +15,8 @@ namespace API.Services;
public interface IDeviceService
{
Task<Device> Create(CreateDeviceDto dto, AppUser userWithDevices);
Task<Device> Update(UpdateDeviceDto dto, AppUser userWithDevices);
Task<Device?> Create(CreateDeviceDto dto, AppUser userWithDevices);
Task<Device?> Update(UpdateDeviceDto dto, AppUser userWithDevices);
Task<bool> Delete(AppUser userWithDevices, int deviceId);
Task<bool> SendTo(IReadOnlyList<int> chapterIds, int deviceId);
}
@ -34,13 +33,13 @@ public class DeviceService : IDeviceService
_logger = logger;
_emailService = emailService;
}
#nullable enable
public async Task<Device?> Create(CreateDeviceDto dto, AppUser userWithDevices)
{
try
{
userWithDevices.Devices ??= new List<Device>();
var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Name.Equals(dto.Name));
var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Name!.Equals(dto.Name));
if (existingDevice != null) throw new KavitaException("A device with this name already exists");
existingDevice = DbFactory.Device(dto.Name);
@ -85,7 +84,6 @@ public class DeviceService : IDeviceService
return null;
}
#nullable disable
public async Task<bool> Delete(AppUser userWithDevices, int deviceId)
{
@ -119,7 +117,7 @@ public class DeviceService : IDeviceService
await _unitOfWork.CommitAsync();
var success = await _emailService.SendFilesToEmail(new SendToDto()
{
DestinationEmail = device.EmailAddress,
DestinationEmail = device.EmailAddress!,
FilePaths = files.Select(m => m.FilePath)
});

View file

@ -1,17 +1,14 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.DTOs.System;
using API.Entities.Enums;
using API.Extensions;
using Kavita.Common.Helpers;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.Logging;
namespace API.Services;
@ -47,33 +44,25 @@ public interface IDirectoryService
void ClearDirectory(string directoryPath);
void ClearAndDeleteDirectory(string directoryPath);
string[] GetFilesWithExtension(string path, string searchPatternExpression = "");
bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "");
bool CopyDirectoryToDirectory(string? sourceDirName, string destDirName, string searchPattern = "");
Dictionary<string, string> FindHighestDirectoriesFromFiles(IEnumerable<string> libraryFolders,
IList<string> filePaths);
IEnumerable<string> GetFoldersTillRoot(string rootPath, string fullPath);
IEnumerable<string> GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly);
bool ExistOrCreate(string directoryPath);
void DeleteFiles(IEnumerable<string> files);
void RemoveNonImages(string directoryName);
void Flatten(string directoryName);
Task<bool> CheckWriteAccess(string directoryName);
IEnumerable<string> GetFilesWithCertainExtensions(string path,
string searchPatternExpression = "",
SearchOption searchOption = SearchOption.TopDirectoryOnly);
IEnumerable<string> GetDirectories(string folderPath);
IEnumerable<string> GetDirectories(string folderPath, GlobMatcher matcher);
IEnumerable<string> GetDirectories(string folderPath, GlobMatcher? matcher);
string GetParentDirectoryName(string fileOrFolder);
#nullable enable
IList<string> ScanFiles(string folderPath, GlobMatcher? matcher = null);
DateTime GetLastWriteTime(string folderPath);
GlobMatcher CreateMatcherFromFile(string filePath);
#nullable disable
GlobMatcher? CreateMatcherFromFile(string filePath);
}
public class DirectoryService : IDirectoryService
{
@ -87,6 +76,16 @@ public class DirectoryService : IDirectoryService
public string BookmarkDirectory { get; }
public string SiteThemeDirectory { get; }
private readonly ILogger<DirectoryService> _logger;
private const int RegexTimeoutMs = 5000000;
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
// [GeneratedRegex(@"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle",
// MatchOptions, matchTimeoutMilliseconds: RegexTimeoutMs)]
// private static partial Regex ExcludeDirectoriesRegex();
//
// [GeneratedRegex(@"\(\d+\)",
// MatchOptions, matchTimeoutMilliseconds: RegexTimeoutMs)]
// private static partial Regex FileCopyAppendRegex();
private static readonly Regex ExcludeDirectories = new Regex(
@"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb",
@ -130,7 +129,7 @@ public class DirectoryService : IDirectoryService
SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
if (!FileSystem.Directory.Exists(path)) return ImmutableList<string>.Empty;
var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase);
var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase, Tasks.Scanner.Parser.Parser.RegexTimeout);
return FileSystem.Directory.EnumerateFiles(path, "*", searchOption)
.Where(file =>
@ -172,7 +171,7 @@ public class DirectoryService : IDirectoryService
while (FileSystem.Path.GetDirectoryName(path) != Path.GetDirectoryName(root))
{
var folder = FileSystem.DirectoryInfo.FromDirectoryName(path).Name;
var folder = FileSystem.DirectoryInfo.New(path).Name;
paths.Add(folder);
path = path.Substring(0, path.LastIndexOf(separator));
}
@ -187,7 +186,7 @@ public class DirectoryService : IDirectoryService
/// <returns></returns>
public bool Exists(string directory)
{
var di = FileSystem.DirectoryInfo.FromDirectoryName(directory);
var di = FileSystem.DirectoryInfo.New(directory);
return di.Exists;
}
@ -230,7 +229,7 @@ public class DirectoryService : IDirectoryService
{
try
{
var fileInfo = FileSystem.FileInfo.FromFileName(fullFilePath);
var fileInfo = FileSystem.FileInfo.New(fullFilePath);
if (!fileInfo.Exists) return;
ExistOrCreate(targetDirectory);
@ -250,12 +249,12 @@ public class DirectoryService : IDirectoryService
/// <param name="searchPattern">Defaults to all files</param>
/// <returns>If was successful</returns>
/// <exception cref="DirectoryNotFoundException">Thrown when source directory does not exist</exception>
public bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "")
public bool CopyDirectoryToDirectory(string? sourceDirName, string destDirName, string searchPattern = "")
{
if (string.IsNullOrEmpty(sourceDirName)) return false;
// Get the subdirectories for the specified directory.
var dir = FileSystem.DirectoryInfo.FromDirectoryName(sourceDirName);
var dir = FileSystem.DirectoryInfo.New(sourceDirName);
if (!dir.Exists)
{
@ -270,7 +269,7 @@ public class DirectoryService : IDirectoryService
ExistOrCreate(destDirName);
// Get the files in the directory and copy them to the new location.
var files = GetFilesWithExtension(dir.FullName, searchPattern).Select(n => FileSystem.FileInfo.FromFileName(n));
var files = GetFilesWithExtension(dir.FullName, searchPattern).Select(n => FileSystem.FileInfo.New(n));
foreach (var file in files)
{
var tempPath = FileSystem.Path.Combine(destDirName, file.Name);
@ -294,7 +293,7 @@ public class DirectoryService : IDirectoryService
/// <returns></returns>
public bool IsDriveMounted(string path)
{
return FileSystem.DirectoryInfo.FromDirectoryName(FileSystem.Path.GetPathRoot(path) ?? string.Empty).Exists;
return FileSystem.DirectoryInfo.New(FileSystem.Path.GetPathRoot(path) ?? string.Empty).Exists;
}
@ -325,7 +324,7 @@ public class DirectoryService : IDirectoryService
/// <returns>Total bytes</returns>
public long GetTotalSize(IEnumerable<string> paths)
{
return paths.Sum(path => FileSystem.FileInfo.FromFileName(path).Length);
return paths.Sum(path => FileSystem.FileInfo.New(path).Length);
}
/// <summary>
@ -335,7 +334,7 @@ public class DirectoryService : IDirectoryService
/// <returns></returns>
public bool ExistOrCreate(string directoryPath)
{
var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath);
var di = FileSystem.DirectoryInfo.New(directoryPath);
if (di.Exists) return true;
try
{
@ -356,7 +355,7 @@ public class DirectoryService : IDirectoryService
{
if (!FileSystem.Directory.Exists(directoryPath)) return;
var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath);
var di = FileSystem.DirectoryInfo.New(directoryPath);
ClearDirectory(directoryPath);
@ -370,7 +369,7 @@ public class DirectoryService : IDirectoryService
/// <returns></returns>
public void ClearDirectory(string directoryPath)
{
var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath);
var di = FileSystem.DirectoryInfo.New(directoryPath);
if (!di.Exists) return;
try
{
@ -401,7 +400,7 @@ public class DirectoryService : IDirectoryService
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
{
ExistOrCreate(directoryPath);
string currentFile = null;
string? currentFile = null;
try
{
foreach (var file in filePaths)
@ -413,8 +412,8 @@ public class DirectoryService : IDirectoryService
_logger.LogError("Unable to copy {File} to {DirectoryPath} as it doesn't exist", file, directoryPath);
continue;
}
var fileInfo = FileSystem.FileInfo.FromFileName(file);
var targetFile = FileSystem.FileInfo.FromFileName(RenameFileForCopy(file, directoryPath, prepend));
var fileInfo = FileSystem.FileInfo.New(file);
var targetFile = FileSystem.FileInfo.New(RenameFileForCopy(file, directoryPath, prepend));
fileInfo.CopyTo(FileSystem.Path.Join(directoryPath, targetFile.Name));
}
@ -439,7 +438,7 @@ public class DirectoryService : IDirectoryService
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, IList<string> newFilenames)
{
ExistOrCreate(directoryPath);
string currentFile = null;
string? currentFile = null;
var index = 0;
try
{
@ -452,8 +451,8 @@ public class DirectoryService : IDirectoryService
_logger.LogError("Unable to copy {File} to {DirectoryPath} as it doesn't exist", file, directoryPath);
continue;
}
var fileInfo = FileSystem.FileInfo.FromFileName(file);
var targetFile = FileSystem.FileInfo.FromFileName(RenameFileForCopy(newFilenames[index] + fileInfo.Extension, directoryPath));
var fileInfo = FileSystem.FileInfo.New(file);
var targetFile = FileSystem.FileInfo.New(RenameFileForCopy(newFilenames[index] + fileInfo.Extension, directoryPath));
fileInfo.CopyTo(FileSystem.Path.Join(directoryPath, targetFile.Name));
index++;
@ -480,18 +479,20 @@ public class DirectoryService : IDirectoryService
{
while (true)
{
var fileInfo = FileSystem.FileInfo.FromFileName(fileToCopy);
var fileInfo = FileSystem.FileInfo.New(fileToCopy);
var filename = prepend + fileInfo.Name;
var targetFile = FileSystem.FileInfo.FromFileName(FileSystem.Path.Join(directoryPath, filename));
var targetFile = FileSystem.FileInfo.New(FileSystem.Path.Join(directoryPath, filename));
if (!targetFile.Exists)
{
return targetFile.FullName;
}
var noExtension = FileSystem.Path.GetFileNameWithoutExtension(fileInfo.Name);
//if (FileCopyAppendRegex().IsMatch(noExtension))
if (FileCopyAppend.IsMatch(noExtension))
{
//var match = FileCopyAppendRegex().Match(noExtension).Value;
var match = FileCopyAppend.Match(noExtension).Value;
var matchNumber = match.Replace("(", string.Empty).Replace(")", string.Empty);
noExtension = noExtension.Replace(match, $"({int.Parse(matchNumber) + 1})");
@ -515,7 +516,7 @@ public class DirectoryService : IDirectoryService
{
if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList<DirectoryDto>.Empty;
var di = FileSystem.DirectoryInfo.FromDirectoryName(rootPath);
var di = FileSystem.DirectoryInfo.New(rootPath);
var dirs = di.GetDirectories()
.Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System)))
.Select(d => new DirectoryDto()
@ -595,13 +596,13 @@ public class DirectoryService : IDirectoryService
/// <param name="folderPath"></param>
/// <param name="matcher">A set of glob rules that will filter directories out</param>
/// <returns>List of directory paths, empty if path doesn't exist</returns>
public IEnumerable<string> GetDirectories(string folderPath, GlobMatcher matcher)
public IEnumerable<string> GetDirectories(string folderPath, GlobMatcher? matcher)
{
if (matcher == null) return GetDirectories(folderPath);
return GetDirectories(folderPath)
.Where(folder => !matcher.ExcludeMatches(
$"{FileSystem.DirectoryInfo.FromDirectoryName(folder).Name}{FileSystem.Path.AltDirectorySeparatorChar}"));
$"{FileSystem.DirectoryInfo.New(folder).Name}{FileSystem.Path.AltDirectorySeparatorChar}"));
}
/// <summary>
@ -681,7 +682,7 @@ public class DirectoryService : IDirectoryService
{
var foundFiles = GetFilesWithCertainExtensions(folderPath,
Tasks.Scanner.Parser.Parser.SupportedExtensions)
.Where(file => !matcher.ExcludeMatches(FileSystem.FileInfo.FromFileName(file).Name));
.Where(file => !matcher.ExcludeMatches(FileSystem.FileInfo.New(file).Name));
files.AddRange(foundFiles);
}
@ -707,7 +708,7 @@ public class DirectoryService : IDirectoryService
/// </summary>
/// <param name="filePath"></param>
/// <returns></returns>
public GlobMatcher CreateMatcherFromFile(string filePath)
public GlobMatcher? CreateMatcherFromFile(string filePath)
{
if (!FileSystem.File.Exists(filePath))
{
@ -831,7 +832,7 @@ public class DirectoryService : IDirectoryService
{
try
{
FileSystem.FileInfo.FromFileName(file).Delete();
FileSystem.FileInfo.New(file).Delete();
}
catch (Exception)
{
@ -934,7 +935,7 @@ public class DirectoryService : IDirectoryService
{
if (string.IsNullOrEmpty(directoryName) || !FileSystem.Directory.Exists(directoryName)) return;
var directory = FileSystem.DirectoryInfo.FromDirectoryName(directoryName);
var directory = FileSystem.DirectoryInfo.New(directoryName);
var index = 0;
FlattenDirectory(directory, directory, ref index);

View file

@ -2,9 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.StaticFiles;
namespace API.Services;
@ -53,7 +51,7 @@ public class DownloadService : IDownloadService
};
}
return contentType;
return contentType!;
}

View file

@ -78,13 +78,13 @@ public class EmailService : IEmailService
public async Task<bool> IsDefaultEmailService()
{
return (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value
return (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl))!.Value!
.Equals(DefaultApiUrl);
}
public async Task SendEmailChangeEmail(ConfirmationEmailDto data)
{
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl))!.Value;
var success = await SendEmailWithPost(emailLink + "/api/account/email-change", data);
if (!success)
{

View file

@ -17,9 +17,10 @@ public interface IImageService
/// </summary>
/// <param name="encodedImage">base64 encoded image</param>
/// <param name="fileName"></param>
/// <param name="saveAsWebP">Convert and save as webp</param>
/// <param name="thumbnailWidth">Width of thumbnail</param>
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
string CreateThumbnailFromBase64(string encodedImage, string fileName, int thumbnailWidth = 0);
string CreateThumbnailFromBase64(string encodedImage, string fileName, bool saveAsWebP = false, int thumbnailWidth = 320);
string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, bool saveAsWebP = false);
/// <summary>
@ -42,7 +43,6 @@ public class ImageService : IImageService
public const string CollectionTagCoverImageRegex = @"tag\d+";
public const string ReadingListCoverImageRegex = @"readinglist\d+";
/// <summary>
/// Width of the Thumbnail generation
/// </summary>
@ -58,8 +58,9 @@ public class ImageService : IImageService
_directoryService = directoryService;
}
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1)
public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1)
{
if (string.IsNullOrEmpty(fileFilePath)) return;
_directoryService.ExistOrCreate(targetDirectory);
if (fileCount == 1)
{
@ -67,7 +68,7 @@ public class ImageService : IImageService
}
else
{
_directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(fileFilePath), targetDirectory,
_directoryService.CopyDirectoryToDirectory(_directoryService.FileSystem.Path.GetDirectoryName(fileFilePath), targetDirectory,
Tasks.Scanner.Parser.Parser.ImageFileExtensions);
}
}
@ -115,7 +116,7 @@ public class ImageService : IImageService
public Task<string> ConvertToWebP(string filePath, string outputPath)
{
var file = _directoryService.FileSystem.FileInfo.FromFileName(filePath);
var file = _directoryService.FileSystem.FileInfo.New(filePath);
var fileName = file.Name.Replace(file.Extension, string.Empty);
var outputFile = Path.Join(outputPath, fileName + ".webp");
@ -143,12 +144,12 @@ public class ImageService : IImageService
/// <inheritdoc />
public string CreateThumbnailFromBase64(string encodedImage, string fileName, int thumbnailWidth = ThumbnailWidth)
public string CreateThumbnailFromBase64(string encodedImage, string fileName, bool saveAsWebP = false, int thumbnailWidth = ThumbnailWidth)
{
try
{
using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), ThumbnailWidth);
var filename = fileName + ".png";
using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth);
var filename = fileName + (saveAsWebP ? ".webp" : ".png");
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName + ".png"));
return filename;
}

View file

@ -5,18 +5,11 @@ using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.Data.Metadata;
using API.Data.Repositories;
using API.Data.Scanner;
using API.DTOs.Metadata;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services.Tasks.Metadata;
using API.SignalR;
using Hangfire;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Services;
@ -74,11 +67,13 @@ public class MetadataService : IMetadataService
private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, bool convertToWebPOnWrite)
{
var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null) return Task.FromResult(false);
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked))
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage),
firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked))
return Task.FromResult(false);
if (firstFile == null) return Task.FromResult(false);
_logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath);
@ -102,7 +97,7 @@ public class MetadataService : IMetadataService
/// </summary>
/// <param name="volume"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
private Task<bool> UpdateVolumeCoverImage(Volume volume, bool forceUpdate)
private Task<bool> UpdateVolumeCoverImage(Volume? volume, bool forceUpdate)
{
// We need to check if Volume coverImage matches first chapters if forceUpdate is false
if (volume == null || !_cacheHelper.ShouldUpdateCoverImage(
@ -125,7 +120,7 @@ public class MetadataService : IMetadataService
/// </summary>
/// <param name="series"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
private Task UpdateSeriesCoverImage(Series series, bool forceUpdate)
private Task UpdateSeriesCoverImage(Series? series, bool forceUpdate)
{
if (series == null) return Task.CompletedTask;
@ -199,6 +194,7 @@ public class MetadataService : IMetadataService
public async Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false)
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
if (library == null) return;
_logger.LogInformation("[MetadataService] Beginning cover generation refresh of {LibraryName}", library.Name);
_updateEvents.Clear();

View file

@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@ -105,6 +104,7 @@ public class ReaderService : IReaderService
{
var seenVolume = new Dictionary<int, bool>();
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
if (series == null) throw new KavitaException("Series suddenly doesn't exist, cannot mark as read");
foreach (var chapter in chapters)
{
var userProgress = GetUserProgressForChapter(user, chapter);
@ -128,14 +128,14 @@ public class ReaderService : IReaderService
}
await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName, seriesId, chapter.VolumeId, chapter.Id, chapter.Pages));
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,
MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, seriesId,
chapter.VolumeId, 0, chapters.Where(c => c.VolumeId == chapter.VolumeId).Sum(c => c.Pages)));
}
@ -164,14 +164,14 @@ public class ReaderService : IReaderService
userProgress.VolumeId = chapter.VolumeId;
await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName, userProgress.SeriesId, userProgress.VolumeId, userProgress.ChapterId, 0));
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,
MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, seriesId,
chapter.VolumeId, 0, 0));
}
}
@ -184,9 +184,9 @@ public class ReaderService : IReaderService
/// <param name="user">Must have Progresses populated</param>
/// <param name="chapter"></param>
/// <returns></returns>
private static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter)
private static AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter)
{
AppUserProgress userProgress = null;
AppUserProgress? userProgress = null;
if (user.Progresses == null)
{
@ -227,7 +227,7 @@ public class ReaderService : IReaderService
try
{
// TODO: Rewrite this code to just pull user object with progress for that particiular appuserprogress, else create it
// TODO: Rewrite this code to just pull user object with progress for that particular appuserprogress, else create it
var userProgress =
await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId);
@ -237,6 +237,7 @@ public class ReaderService : IReaderService
// 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
{
@ -263,7 +264,7 @@ public class ReaderService : IReaderService
{
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));
MessageFactory.UserProgressUpdateEvent(userId, user!.UserName!, progressDto.SeriesId, progressDto.VolumeId, progressDto.ChapterId, progressDto.PageNum));
return true;
}
}

View file

@ -8,12 +8,11 @@ namespace API.Services;
public interface IReadingItemService
{
ComicInfo GetComicInfo(string filePath);
ComicInfo? GetComicInfo(string filePath);
int GetNumberOfPages(string filePath, MangaFormat format);
string GetCoverImage(string filePath, string fileName, MangaFormat format, bool saveAsWebP);
void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1);
ParserInfo Parse(string path, string rootPath, LibraryType type);
ParserInfo ParseFile(string path, string rootPath, LibraryType type);
ParserInfo? ParseFile(string path, string rootPath, LibraryType type);
}
public class ReadingItemService : IReadingItemService
@ -60,7 +59,7 @@ public class ReadingItemService : IReadingItemService
/// <param name="path">Path of a file</param>
/// <param name="rootPath"></param>
/// <param name="type">Library type to determine parsing to perform</param>
public ParserInfo ParseFile(string path, string rootPath, LibraryType type)
public ParserInfo? ParseFile(string path, string rootPath, LibraryType type)
{
var info = Parse(path, rootPath, type);
if (info == null)
@ -216,7 +215,7 @@ public class ReadingItemService : IReadingItemService
/// <param name="rootPath"></param>
/// <param name="type"></param>
/// <returns></returns>
public ParserInfo Parse(string path, string rootPath, LibraryType type)
private ParserInfo? Parse(string path, string rootPath, LibraryType type)
{
return Tasks.Scanner.Parser.Parser.IsEpub(path) ? _bookService.ParseInfo(path) : _defaultParser.Parse(path, rootPath, type);
}

View file

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
@ -7,7 +6,6 @@ using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.ReadingLists;
using API.DTOs.ReadingLists.CBL;
using API.Entities;
@ -182,6 +180,7 @@ public class ReadingListService : IReadingListService
_unitOfWork.ReadingListRepository.BulkRemove(listItems);
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
if (readingList == null) return true;
await CalculateReadingListAgeRating(readingList);
if (!_unitOfWork.HasChanges()) return true;
@ -205,8 +204,11 @@ public class ReadingListService : IReadingListService
{
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList();
var item = items.Find(r => r.Id == dto.ReadingListItemId);
items.Remove(item);
items.Insert(dto.ToPosition, item);
if (item != null)
{
items.Remove(item);
items.Insert(dto.ToPosition, item);
}
for (var i = 0; i < items.Count; i++)
{
@ -226,6 +228,7 @@ public class ReadingListService : IReadingListService
public async Task<bool> DeleteReadingListItem(UpdateReadingListPosition dto)
{
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
if (readingList == null) return false;
readingList.Items = readingList.Items.Where(r => r.Id != dto.ReadingListItemId).OrderBy(r => r.Order).ToList();
var index = 0;
@ -260,7 +263,8 @@ public class ReadingListService : IReadingListService
private async Task CalculateReadingListAgeRating(ReadingList readingList, IEnumerable<int> seriesIds)
{
var ageRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds);
readingList.AgeRating = ageRating;
if (ageRating == null) readingList.AgeRating = AgeRating.Unknown;
else readingList.AgeRating = (AgeRating) ageRating;
}
/// <summary>
@ -274,7 +278,7 @@ public class ReadingListService : IReadingListService
// We need full reading list with items as this is used by many areas that manipulate items
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username,
AppUserIncludes.ReadingListsWithItems);
if (!await UserHasReadingListAccess(readingListId, user))
if (user == null || !await UserHasReadingListAccess(readingListId, user))
{
return null;
}
@ -302,6 +306,7 @@ public class ReadingListService : IReadingListService
public async Task<bool> DeleteReadingList(int readingListId, AppUser user)
{
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
if (readingList == null) return true;
user.ReadingLists.Remove(readingList);
if (!_unitOfWork.HasChanges()) return true;
@ -322,7 +327,7 @@ public class ReadingListService : IReadingListService
var lastOrder = 0;
if (readingList.Items.Any())
{
lastOrder = readingList.Items.DefaultIfEmpty().Max(rli => rli.Order);
lastOrder = readingList.Items.DefaultIfEmpty().Max(rli => rli!.Order);
}
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();

View file

@ -8,14 +8,12 @@ using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Helpers;
using API.SignalR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Services;
@ -52,7 +50,7 @@ public class SeriesService : ISeriesService
/// <param name="series"></param>
/// <param name="isBookLibrary"></param>
/// <returns></returns>
public static Chapter GetFirstChapterForMetadata(Series series, bool isBookLibrary)
public static Chapter? GetFirstChapterForMetadata(Series series, bool isBookLibrary)
{
return series.Volumes.OrderBy(v => v.Number, ChapterSortComparer.Default)
.SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default))
@ -65,12 +63,13 @@ public class SeriesService : ISeriesService
{
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
if (series == null) return false;
var allCollectionTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList();
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresAsync()).ToList();
var allPeople = (await _unitOfWork.PersonRepository.GetAllPeople()).ToList();
var allTags = (await _unitOfWork.TagRepository.GetAllTagsAsync()).ToList();
series.Metadata ??= DbFactory.SeriesMetadata(updateSeriesMetadataDto.CollectionTags
series.Metadata ??= DbFactory.SeriesMetadata((updateSeriesMetadataDto.CollectionTags ?? new List<CollectionTagDto>())
.Select(dto => DbFactory.CollectionTag(dto.Id, dto.Title, dto.Summary, dto.Promoted)).ToList());
if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating)
@ -104,13 +103,13 @@ public class SeriesService : ISeriesService
if (series.Metadata.Summary != updateSeriesMetadataDto.SeriesMetadata.Summary.Trim())
{
series.Metadata.Summary = updateSeriesMetadataDto.SeriesMetadata?.Summary.Trim();
series.Metadata.Summary = updateSeriesMetadataDto.SeriesMetadata?.Summary.Trim() ?? string.Empty;
series.Metadata.SummaryLocked = true;
}
if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata?.Language)
{
series.Metadata.Language = updateSeriesMetadataDto.SeriesMetadata?.Language;
series.Metadata.Language = updateSeriesMetadataDto.SeriesMetadata?.Language ?? string.Empty;
series.Metadata.LanguageLocked = true;
}
@ -121,13 +120,13 @@ public class SeriesService : ISeriesService
});
series.Metadata.Genres ??= new List<Genre>();
UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, (genre) =>
GenreHelper.UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, (genre) =>
{
series.Metadata.Genres.Add(genre);
}, () => series.Metadata.GenresLocked = true);
series.Metadata.Tags ??= new List<Tag>();
UpdateTagList(updateSeriesMetadataDto.SeriesMetadata.Tags, series, allTags, (tag) =>
TagHelper.UpdateTagList(updateSeriesMetadataDto.SeriesMetadata?.Tags, series, allTags, (tag) =>
{
series.Metadata.Tags.Add(tag);
}, () => series.Metadata.TagsLocked = true);
@ -139,25 +138,25 @@ public class SeriesService : ISeriesService
}
series.Metadata.People ??= new List<Person>();
UpdatePeopleList(PersonRole.Writer, updateSeriesMetadataDto.SeriesMetadata.Writers, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.Writer, updateSeriesMetadataDto.SeriesMetadata!.Writers, series, allPeople,
HandleAddPerson, () => series.Metadata.WriterLocked = true);
UpdatePeopleList(PersonRole.Character, updateSeriesMetadataDto.SeriesMetadata.Characters, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.Character, updateSeriesMetadataDto.SeriesMetadata.Characters, series, allPeople,
HandleAddPerson, () => series.Metadata.CharacterLocked = true);
UpdatePeopleList(PersonRole.Colorist, updateSeriesMetadataDto.SeriesMetadata.Colorists, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.Colorist, updateSeriesMetadataDto.SeriesMetadata.Colorists, series, allPeople,
HandleAddPerson, () => series.Metadata.ColoristLocked = true);
UpdatePeopleList(PersonRole.Editor, updateSeriesMetadataDto.SeriesMetadata.Editors, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.Editor, updateSeriesMetadataDto.SeriesMetadata.Editors, series, allPeople,
HandleAddPerson, () => series.Metadata.EditorLocked = true);
UpdatePeopleList(PersonRole.Inker, updateSeriesMetadataDto.SeriesMetadata.Inkers, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.Inker, updateSeriesMetadataDto.SeriesMetadata.Inkers, series, allPeople,
HandleAddPerson, () => series.Metadata.InkerLocked = true);
UpdatePeopleList(PersonRole.Letterer, updateSeriesMetadataDto.SeriesMetadata.Letterers, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.Letterer, updateSeriesMetadataDto.SeriesMetadata.Letterers, series, allPeople,
HandleAddPerson, () => series.Metadata.LettererLocked = true);
UpdatePeopleList(PersonRole.Penciller, updateSeriesMetadataDto.SeriesMetadata.Pencillers, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.Penciller, updateSeriesMetadataDto.SeriesMetadata.Pencillers, series, allPeople,
HandleAddPerson, () => series.Metadata.PencillerLocked = true);
UpdatePeopleList(PersonRole.Publisher, updateSeriesMetadataDto.SeriesMetadata.Publishers, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.Publisher, updateSeriesMetadataDto.SeriesMetadata.Publishers, series, allPeople,
HandleAddPerson, () => series.Metadata.PublisherLocked = true);
UpdatePeopleList(PersonRole.Translator, updateSeriesMetadataDto.SeriesMetadata.Translators, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.Translator, updateSeriesMetadataDto.SeriesMetadata.Translators, series, allPeople,
HandleAddPerson, () => series.Metadata.TranslatorLocked = true);
UpdatePeopleList(PersonRole.CoverArtist, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, series, allPeople,
PersonHelper.UpdatePeopleList(PersonRole.CoverArtist, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, series, allPeople,
HandleAddPerson, () => series.Metadata.CoverArtistLocked = true);
series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked;
@ -183,7 +182,7 @@ public class SeriesService : ISeriesService
return true;
}
if (await _unitOfWork.CommitAsync())
if (await _unitOfWork.CommitAsync() && updateSeriesMetadataDto.CollectionTags != null)
{
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
{
@ -210,10 +209,10 @@ public class SeriesService : ISeriesService
}
public static void UpdateCollectionsList(ICollection<CollectionTagDto> tags, Series series, IReadOnlyCollection<CollectionTag> allTags,
public static void UpdateCollectionsList(ICollection<CollectionTagDto>? tags, Series series, IReadOnlyCollection<CollectionTag> allTags,
Action<CollectionTag> handleAdd)
{
// TODO: Move UpdateRelatedList to a helper so we can easily test
// TODO: Move UpdateCollectionsList to a helper so we can easily test
if (tags == null) return;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.CollectionTags.ToList();
@ -245,142 +244,13 @@ public class SeriesService : ISeriesService
}
}
private static void UpdateGenreList(ICollection<GenreTagDto> tags, Series series, IReadOnlyCollection<Genre> allTags, Action<Genre> handleAdd, Action onModified)
{
if (tags == null) return;
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.Genres.ToList();
foreach (var existing in existingTags)
{
// NOTE: Why don't I use a NormalizedName here (outside of memory pressure from string creation)?
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null)
{
// Remove tag
series.Metadata.Genres.Remove(existing);
isModified = true;
}
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tagTitle in tags.Select(t => t.Title))
{
var normalizedTitle = Tasks.Scanner.Parser.Parser.Normalize(tagTitle);
var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle == normalizedTitle);
if (existingTag != null)
{
if (series.Metadata.Genres.All(t => t.NormalizedTitle != normalizedTitle))
{
handleAdd(existingTag);
isModified = true;
}
}
else
{
// Add new tag
handleAdd(DbFactory.Genre(tagTitle));
isModified = true;
}
}
if (isModified)
{
onModified();
}
}
private static void UpdateTagList(ICollection<TagDto> tags, Series series, IReadOnlyCollection<Tag> allTags, Action<Tag> handleAdd, Action onModified)
{
if (tags == null) return;
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.Tags.ToList();
foreach (var existing in existingTags.Where(existing => tags.SingleOrDefault(t => t.Id == existing.Id) == null))
{
// Remove tag
series.Metadata.Tags.Remove(existing);
isModified = true;
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tagTitle in tags.Select(t => t.Title))
{
var normalizedTitle = Tasks.Scanner.Parser.Parser.Normalize(tagTitle);
var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle));
if (existingTag != null)
{
if (series.Metadata.Tags.All(t => t.NormalizedTitle != normalizedTitle))
{
handleAdd(existingTag);
isModified = true;
}
}
else
{
// Add new tag
handleAdd(DbFactory.Tag(tagTitle));
isModified = true;
}
}
if (isModified)
{
onModified();
}
}
private static void UpdatePeopleList(PersonRole role, ICollection<PersonDto> tags, Series series, IReadOnlyCollection<Person> allTags,
Action<Person> handleAdd, Action onModified)
{
if (tags == null) return;
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList();
foreach (var existing in existingTags)
{
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role
{
// Remove tag
series.Metadata.People.Remove(existing);
isModified = true;
}
}
// At this point, all tags that aren't in dto have been removed.
foreach (var tag in tags)
{
var existingTag = allTags.SingleOrDefault(t => t.Name == tag.Name && t.Role == tag.Role);
if (existingTag != null)
{
if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => !t.Name.Equals(tag.Name)))
{
handleAdd(existingTag);
isModified = true;
}
}
else
{
// Add new tag
handleAdd(DbFactory.Person(tag.Name, role));
isModified = true;
}
}
if (isModified)
{
onModified();
}
}
/// <summary>
///
/// </summary>
/// <param name="user">User with Ratings includes</param>
/// <param name="updateSeriesRatingDto"></param>
/// <returns></returns>
public async Task<bool> UpdateRating(AppUser user, UpdateSeriesRatingDto updateSeriesRatingDto)
public async Task<bool> UpdateRating(AppUser? user, UpdateSeriesRatingDto updateSeriesRatingDto)
{
if (user == null)
{
@ -478,10 +348,10 @@ public class SeriesService : ISeriesService
throw new UnauthorizedAccessException("User does not have access to the library this series belongs to");
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user.AgeRestriction != AgeRating.NotApplicable)
if (user!.AgeRestriction != AgeRating.NotApplicable)
{
var seriesMetadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId);
if (seriesMetadata.AgeRating > user.AgeRestriction)
if (seriesMetadata!.AgeRating > user.AgeRestriction)
throw new UnauthorizedAccessException("User is not allowed to view this series due to age restrictions");
}
@ -592,8 +462,10 @@ public class SeriesService : ISeriesService
}
private static string FormatChapterTitle(bool isSpecial, LibraryType libraryType, string chapterTitle, bool withHash)
private static string FormatChapterTitle(bool isSpecial, LibraryType libraryType, string? chapterTitle, bool withHash)
{
if (string.IsNullOrEmpty(chapterTitle)) throw new ArgumentException("Chapter Title cannot be null");
if (isSpecial)
{
return Tasks.Scanner.Parser.Parser.CleanSpecialTitle(chapterTitle);
@ -649,6 +521,7 @@ public class SeriesService : ISeriesService
public async Task<bool> UpdateRelatedSeries(UpdateRelatedSeriesDto dto)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Related);
if (series == null) return false;
UpdateRelationForKind(dto.Adaptations, series.Relations.Where(r => r.RelationKind == RelationKind.Adaptation).ToList(), series, RelationKind.Adaptation);
UpdateRelationForKind(dto.Characters, series.Relations.Where(r => r.RelationKind == RelationKind.Character).ToList(), series, RelationKind.Character);

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
@ -12,7 +11,6 @@ using API.Extensions;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Services;
@ -593,13 +591,14 @@ public class StatisticService : IStatisticService
user[userChapter.User.Id] = libraryTimes;
}
return user.Keys.Select(userId => new TopReadDto()
{
UserId = userId,
Username = users.First(u => u.Id == userId).UserName,
BooksTime = user[userId].ContainsKey(LibraryType.Book) ? user[userId][LibraryType.Book] : 0,
ComicsTime = user[userId].ContainsKey(LibraryType.Comic) ? user[userId][LibraryType.Comic] : 0,
MangaTime = user[userId].ContainsKey(LibraryType.Manga) ? user[userId][LibraryType.Manga] : 0,
BooksTime = user[userId].TryGetValue(LibraryType.Book, out var bookTime) ? bookTime : 0,
ComicsTime = user[userId].TryGetValue(LibraryType.Comic, out var comicTime) ? comicTime : 0,
MangaTime = user[userId].TryGetValue(LibraryType.Manga, out var mangaTime) ? mangaTime : 0,
})
.ToList();
}

View file

@ -15,7 +15,7 @@ namespace API.Services;
public interface ITachiyomiService
{
Task<ChapterDto> GetLatestChapter(int seriesId, int userId);
Task<ChapterDto?> GetLatestChapter(int seriesId, int userId);
Task<bool> MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber);
}
@ -49,10 +49,8 @@ public class TachiyomiService : ITachiyomiService
/// 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)
public async Task<ChapterDto?> GetLatestChapter(int seriesId, int userId)
{
var currentChapter = await _readerService.GetContinuePoint(seriesId, userId);
var prevChapterId =
@ -95,7 +93,8 @@ public class TachiyomiService : ITachiyomiService
}
// 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 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)

View file

@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Data;
using API.Entities.Enums;
@ -322,6 +321,7 @@ public class TaskScheduler : ITaskScheduler
public async Task CheckForUpdate()
{
var update = await _versionUpdaterService.CheckForUpdate();
if (update == null) return;
await _versionUpdaterService.PushUpdate(update);
}

View file

@ -6,12 +6,9 @@ using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Entities.Enums;
using API.Extensions;
using API.Logging;
using API.SignalR;
using Hangfire;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace API.Services.Tasks;
@ -62,7 +59,7 @@ public class BackupService : IBackupService
public IEnumerable<string> GetLogFiles(bool rollFiles = LogLevelOptions.LogRollingEnabled)
{
var multipleFileRegex = rollFiles ? @"\d*" : string.Empty;
var fi = _directoryService.FileSystem.FileInfo.FromFileName(LogLevelOptions.LogFile);
var fi = _directoryService.FileSystem.FileInfo.New(LogLevelOptions.LogFile);
var files = rollFiles
? _directoryService.GetFiles(_directoryService.LogDirectory,

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
@ -11,7 +10,6 @@ using API.Entities.Enums;
using API.Helpers;
using API.SignalR;
using Hangfire;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Services.Tasks;
@ -175,7 +173,7 @@ public class CleanupService : ICleanupService
var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(dayThreshold));
var allBackups = _directoryService.GetFiles(backupDirectory).ToList();
var expiredBackups = allBackups.Select(filename => _directoryService.FileSystem.FileInfo.FromFileName(filename))
var expiredBackups = allBackups.Select(filename => _directoryService.FileSystem.FileInfo.New(filename))
.Where(f => f.CreationTime < deltaTime)
.ToList();
@ -198,7 +196,7 @@ public class CleanupService : ICleanupService
var dayThreshold = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).TotalLogs;
var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(dayThreshold));
var allLogs = _directoryService.GetFiles(_directoryService.LogDirectory).ToList();
var expiredLogs = allLogs.Select(filename => _directoryService.FileSystem.FileInfo.FromFileName(filename))
var expiredLogs = allLogs.Select(filename => _directoryService.FileSystem.FileInfo.New(filename))
.Where(f => f.CreationTime < deltaTime)
.ToList();

View file

@ -3,7 +3,6 @@ using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
@ -50,7 +49,8 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
public async Task ScanLibrary(int libraryId, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None);
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
if (library == null) return;
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 0F, ProgressEventType.Started, string.Empty));

View file

@ -18,24 +18,24 @@ public class ParsedSeries
/// <summary>
/// Name of the Series
/// </summary>
public string Name { get; init; }
public required string Name { get; init; }
/// <summary>
/// Normalized Name of the Series
/// </summary>
public string NormalizedName { get; init; }
public required string NormalizedName { get; init; }
/// <summary>
/// Format of the Series
/// </summary>
public MangaFormat Format { get; init; }
public required MangaFormat Format { get; init; }
}
public class SeriesModified
{
public string FolderPath { get; set; }
public string SeriesName { get; set; }
public required string FolderPath { get; set; }
public required string SeriesName { get; set; }
public DateTime LastScanned { get; set; }
public MangaFormat Format { get; set; }
public IEnumerable<string> LibraryRoots { get; set; }
public IEnumerable<string> LibraryRoots { get; set; } = ArraySegment<string>.Empty;
}
/// <summary>
@ -166,16 +166,16 @@ public class ParseScannedFiles
/// </summary>
/// <param name="scannedSeries">A localized list of a series' parsed infos</param>
/// <param name="info"></param>
private void TrackSeries(ConcurrentDictionary<ParsedSeries, List<ParserInfo>> scannedSeries, ParserInfo info)
private void TrackSeries(ConcurrentDictionary<ParsedSeries, List<ParserInfo>> scannedSeries, ParserInfo? info)
{
if (info.Series == string.Empty) return;
if (info == null || info.Series == string.Empty) return;
// Check if normalized info.Series already exists and if so, update info to use that name instead
info.Series = MergeName(scannedSeries, info);
var normalizedSeries = Parser.Parser.Normalize(info.Series);
var normalizedSortSeries = Parser.Parser.Normalize(info.SeriesSort);
var normalizedLocalizedSeries = Parser.Parser.Normalize(info.LocalizedSeries);
var normalizedSeries = info.Series.ToNormalized();
var normalizedSortSeries = info.SeriesSort.ToNormalized();
var normalizedLocalizedSeries = info.LocalizedSeries.ToNormalized();
try
{
@ -224,19 +224,19 @@ public class ParseScannedFiles
/// <returns>Series Name to group this info into</returns>
private string MergeName(ConcurrentDictionary<ParsedSeries, List<ParserInfo>> scannedSeries, ParserInfo info)
{
var normalizedSeries = Parser.Parser.Normalize(info.Series);
var normalizedLocalSeries = Parser.Parser.Normalize(info.LocalizedSeries);
var normalizedSeries = info.Series.ToNormalized();
var normalizedLocalSeries = info.LocalizedSeries.ToNormalized();
try
{
var existingName =
scannedSeries.SingleOrDefault(p =>
(Parser.Parser.Normalize(p.Key.NormalizedName).Equals(normalizedSeries) ||
Parser.Parser.Normalize(p.Key.NormalizedName).Equals(normalizedLocalSeries)) &&
(p.Key.NormalizedName.ToNormalized().Equals(normalizedSeries) ||
p.Key.NormalizedName.ToNormalized().Equals(normalizedLocalSeries)) &&
p.Key.Format == info.Format)
.Key;
if (existingName != null && !string.IsNullOrEmpty(existingName.Name))
if (!string.IsNullOrEmpty(existingName.Name))
{
return existingName.Name;
}
@ -245,8 +245,8 @@ public class ParseScannedFiles
{
_logger.LogCritical(ex, "[ScannerService] Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath);
var values = scannedSeries.Where(p =>
(Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries ||
Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) &&
(p.Key.NormalizedName.ToNormalized() == normalizedSeries ||
p.Key.NormalizedName.ToNormalized() == normalizedLocalSeries) &&
p.Key.Format == info.Format);
foreach (var pair in values)
{
@ -272,7 +272,7 @@ public class ParseScannedFiles
/// <returns></returns>
public async Task ScanLibrariesForSeries(LibraryType libraryType,
IEnumerable<string> folders, string libraryName, bool isLibraryScan,
IDictionary<string, IList<SeriesModified>> seriesPaths, Func<Tuple<bool, IList<ParserInfo>>, Task> processSeriesInfos, bool forceCheck = false)
IDictionary<string, IList<SeriesModified>> seriesPaths, Func<Tuple<bool, IList<ParserInfo>>, Task>? processSeriesInfos, bool forceCheck = false)
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", libraryName, ProgressEventType.Started));
@ -287,7 +287,8 @@ public class ParseScannedFiles
Series = fp.SeriesName,
Format = fp.Format,
}).ToList();
await processSeriesInfos.Invoke(new Tuple<bool, IList<ParserInfo>>(true, parsedInfos));
if (processSeriesInfos != null)
await processSeriesInfos.Invoke(new Tuple<bool, IList<ParserInfo>>(true, parsedInfos));
_logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed since last scan", folder);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.FileScanProgressEvent("Skipped " + normalizedFolder, libraryName, ProgressEventType.Updated));
@ -310,7 +311,7 @@ public class ParseScannedFiles
.ToList();
MergeLocalizedSeriesWithSeries(infos);
MergeLocalizedSeriesWithSeries(infos!);
foreach (var info in infos)
{
@ -322,7 +323,7 @@ public class ParseScannedFiles
{
_logger.LogError(ex,
"[ScannerService] There was an exception that occurred during tracking {FilePath}. Skipping this file",
info.FullFilePath);
info?.FullFilePath);
}
}
@ -390,7 +391,7 @@ public class ParseScannedFiles
if (string.IsNullOrEmpty(localizedSeries)) return;
// NOTE: If we have multiple series in a folder with a localized title, then this will fail. It will group into one series. User needs to fix this themselves.
string nonLocalizedSeries;
string? nonLocalizedSeries;
// Normalize this as many of the cases is a capitalization difference
var nonLocalizedSeriesFound = infos
.Where(i => !i.IsSpecial)
@ -409,11 +410,11 @@ public class ParseScannedFiles
nonLocalizedSeries = nonLocalizedSeriesFound.FirstOrDefault(s => !s.Equals(localizedSeries));
}
if (string.IsNullOrEmpty(nonLocalizedSeries)) return;
if (nonLocalizedSeries == null) return;
var normalizedNonLocalizedSeries = Parser.Parser.Normalize(nonLocalizedSeries);
var normalizedNonLocalizedSeries = nonLocalizedSeries.ToNormalized();
foreach (var infoNeedingMapping in infos.Where(i =>
!Parser.Parser.Normalize(i.Series).Equals(normalizedNonLocalizedSeries)))
!i.Series.ToNormalized().Equals(normalizedNonLocalizedSeries)))
{
infoNeedingMapping.Series = nonLocalizedSeries;
infoNeedingMapping.LocalizedSeries = localizedSeries;

View file

@ -7,7 +7,7 @@ namespace API.Services.Tasks.Scanner.Parser;
public interface IDefaultParser
{
ParserInfo Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga);
ParserInfo? Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga);
void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret);
}
@ -31,7 +31,7 @@ public class DefaultParser : IDefaultParser
/// <param name="rootPath">Root folder</param>
/// <param name="type">Defaults to Manga. Allows different Regex to be used for parsing.</param>
/// <returns><see cref="ParserInfo"/> or null if Series was empty</returns>
public ParserInfo Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga)
public ParserInfo? Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga)
{
var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
// TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this.
@ -134,7 +134,7 @@ public class DefaultParser : IDefaultParser
if (fallbackFolders.Count == 0)
{
var rootFolderName = _directoryService.FileSystem.DirectoryInfo.FromDirectoryName(rootPath).Name;
var rootFolderName = _directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
var series = Parser.ParseSeries(rootFolderName);
if (string.IsNullOrEmpty(series))

View file

@ -11,11 +11,13 @@ public static class Parser
{
public const string DefaultChapter = "0";
public const string DefaultVolume = "0";
private const int RegexTimeoutMs = 5000000; // 500 ms
public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500);
public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif)";
public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt";
private const string BookFileExtensions = @"\.epub|\.pdf";
private const string XmlRegexExtensions = @"\.xml";
public const string MacOsMetadataFileStartsWith = @"._";
public const string SupportedExtensions =
@ -24,6 +26,37 @@ public static class Parser
private const RegexOptions MatchOptions =
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant;
private static readonly ImmutableArray<string> FormatTagSpecialKeywords = ImmutableArray.Create(
"Special", "Reference", "Director's Cut", "Box Set", "Box-Set", "Annual", "Anthology", "Epilogue",
"One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel",
"GN", "FCBD");
private static readonly char[] LeadingZeroesTrimChars = new[] { '0' };
private static readonly char[] SpacesAndSeparators = { '\0', '\t', '\r', ' ', '-', ','};
private const string Number = @"\d+(\.\d)?";
private const string NumberRange = Number + @"(-" + Number + @")?";
/// <summary>
/// non greedy matching of a string where parenthesis are balanced
/// </summary>
public const string BalancedParen = @"(?:[^()]|(?<open>\()|(?<-open>\)))*?(?(open)(?!))";
/// <summary>
/// non greedy matching of a string where square brackets are balanced
/// </summary>
public const string BalancedBracket = @"(?:[^\[\]]|(?<open>\[)|(?<-open>\]))*?(?(open)(?!))";
/// <summary>
/// Matches [Complete], release tags like [kmts] but not [ Complete ] or [kmts ]
/// </summary>
private const string TagsInBrackets = $@"\[(?!\s){BalancedBracket}(?<!\s)\]";
/// <summary>
/// Common regex patterns present in both Comics and Mangas
/// </summary>
private const string CommonSpecial = @"Specials?|One[- ]?Shot|Extra(?:\sChapter)?(?=\s)|Art Collection|Side Stories|Bonus";
/// <summary>
/// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data
/// </summary>
@ -44,7 +77,6 @@ public static class Parser
MatchOptions, RegexTimeout);
private const string XmlRegexExtensions = @"\.xml";
private static readonly Regex ImageRegex = new Regex(ImageFileExtensions,
MatchOptions, RegexTimeout);
private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions,
@ -67,14 +99,6 @@ public static class Parser
private static readonly Regex SpecialTokenRegex = new Regex(@"SP\d+",
MatchOptions, RegexTimeout);
private const string Number = @"\d+(\.\d)?";
private const string NumberRange = Number + @"(-" + Number + @")?";
// Some generic reusage regex patterns:
// - non greedy matching of a string where parenthesis are balanced
public const string BalancedParen = @"(?:[^()]|(?<open>\()|(?<-open>\)))*?(?(open)(?!))";
// - non greedy matching of a string where square brackets are balanced
public const string BalancedBrack = @"(?:[^\[\]]|(?<open>\[)|(?<-open>\]))*?(?(open)(?!))";
private static readonly Regex[] MangaVolumeRegex = new[]
{
@ -86,7 +110,6 @@ public static class Parser
new Regex(
@"(?<Series>.*)(\b|_)(?!\[)(vol\.?)(?<Volume>\d+(-\d+)?)(?!\])",
MatchOptions, RegexTimeout),
// TODO: In .NET 7, update this to use raw literal strings and apply the NumberRange everywhere
// Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17
new Regex(
@"(?<Series>.*)(\b|_)(?!\[)v(?<Volume>" + NumberRange + @")(?!\])",
@ -576,18 +599,12 @@ public static class Parser
MatchOptions, RegexTimeout
);
// Matches [Complete], release tags like [kmts] but not [ Complete ] or [kmts ]
private const string TagsInBrackets = $@"\[(?!\s){BalancedBrack}(?<!\s)\]";
// Matches anything between balanced parenthesis, tags between brackets, {} and {Complete}
private static readonly Regex CleanupRegex = new Regex(
$@"(?:\({BalancedParen}\)|{TagsInBrackets}|\{{\}}|\{{Complete\}})",
MatchOptions, RegexTimeout
);
// Common regex patterns present in both Comics and Mangas
private const string CommonSpecial = @"Specials?|One[- ]?Shot|Extra(?:\sChapter)?(?=\s)|Art Collection|Side Stories|Bonus";
private static readonly Regex MangaSpecialRegex = new Regex(
// All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle.
$@"\b(?:{CommonSpecial}|Omake)\b",
@ -601,11 +618,12 @@ public static class Parser
);
private static readonly Regex EuropeanComicRegex = new Regex(
// All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle.
// All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle.
@"\b(?:Bd[-\s]Fr)\b",
MatchOptions, RegexTimeout
);
// If SP\d+ is in the filename, we force treat it as a special regardless if volume or chapter might have been found.
private static readonly Regex SpecialMarkerRegex = new Regex(
@"SP\d+",
@ -617,14 +635,7 @@ public static class Parser
MatchOptions, RegexTimeout
);
private static readonly ImmutableArray<string> FormatTagSpecialKeywords = ImmutableArray.Create(
"Special", "Reference", "Director's Cut", "Box Set", "Box-Set", "Annual", "Anthology", "Epilogue",
"One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel",
"GN", "FCBD");
private static readonly char[] LeadingZeroesTrimChars = new[] { '0' };
private static readonly char[] SpacesAndSeparators = { '\0', '\t', '\r', ' ', '-', ','};
public static MangaFormat ParseFormat(string filePath)
{
@ -669,11 +680,10 @@ public static class Parser
foreach (var regex in MangaSeriesRegex)
{
var matches = regex.Matches(filename);
foreach (var group in matches.Select(match => match.Groups["Series"])
.Where(group => group.Success && group != Match.Empty))
{
return CleanTitle(group.Value);
}
var group = matches
.Select(match => match.Groups["Series"])
.FirstOrDefault(group => group.Success && group != Match.Empty);
if (group != null) return CleanTitle(group.Value);
}
return string.Empty;
@ -683,11 +693,10 @@ public static class Parser
foreach (var regex in ComicSeriesRegex)
{
var matches = regex.Matches(filename);
foreach (var group in matches.Select(match => match.Groups["Series"])
.Where(group => group.Success && group != Match.Empty))
{
return CleanTitle(group.Value, true);
}
var group = matches
.Select(match => match.Groups["Series"])
.FirstOrDefault(group => group.Success && group != Match.Empty);
if (group != null) return CleanTitle(group.Value, true);
}
return string.Empty;
@ -1028,9 +1037,9 @@ public static class Parser
/// <example>/manga/1\1 -> /manga/1/1</example>
/// <param name="path"></param>
/// <returns></returns>
public static string NormalizePath(string path)
public static string NormalizePath(string? path)
{
return path.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
return string.IsNullOrEmpty(path) ? string.Empty : path.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Replace(@"//", Path.AltDirectorySeparatorChar + string.Empty);
}
@ -1044,5 +1053,8 @@ public static class Parser
return FormatTagSpecialKeywords.Contains(comicInfoFormat);
}
private static string ReplaceUnderscores(string name) => name?.Replace("_", " ");
private static string ReplaceUnderscores(string name)
{
return string.IsNullOrEmpty(name) ? string.Empty : name.Replace('_', ' ');
}
}

View file

@ -17,7 +17,7 @@ public class ParserInfo
/// <summary>
/// Represents the parsed series from the file or folder
/// </summary>
public string Series { get; set; } = string.Empty;
public required string Series { get; set; } = string.Empty;
/// <summary>
/// This can be filled in from ComicInfo.xml/Epub during scanning. Will update the SortName field on <see cref="Entities.Series"/>
/// </summary>
@ -80,14 +80,14 @@ public class ParserInfo
/// This will contain any EXTRA comicInfo information parsed from the epub or archive. If there is an archive with comicInfo.xml AND it contains
/// series, volume information, that will override what we parsed.
/// </summary>
public ComicInfo ComicInfo { get; set; }
public ComicInfo? ComicInfo { get; set; }
/// <summary>
/// Merges non empty/null properties from info2 into this entity.
/// </summary>
/// <remarks>This does not merge ComicInfo as they should always be the same</remarks>
/// <param name="info2"></param>
public void Merge(ParserInfo info2)
public void Merge(ParserInfo? info2)
{
if (info2 == null) return;
Chapters = string.IsNullOrEmpty(Chapters) || Chapters == "0" ? info2.Chapters: Chapters;

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Data;
using API.Data.Metadata;
@ -68,6 +67,11 @@ public class ProcessSeries : IProcessSeries
_metadataService = metadataService;
_wordCountAnalyzerService = wordCountAnalyzerService;
_collectionTagService = collectionTagService;
_genres = new Dictionary<string, Genre>();
_people = new List<Person>();
_tags = new Dictionary<string, Tag>();
_collectionTags = new Dictionary<string, CollectionTag>();
}
/// <summary>
@ -96,7 +100,7 @@ public class ProcessSeries : IProcessSeries
// Check if there is a Series
var firstInfo = parsedInfos.First();
Series series;
Series? series;
try
{
series =
@ -131,7 +135,7 @@ public class ProcessSeries : IProcessSeries
UpdateVolumes(series, parsedInfos, forceUpdate);
series.Pages = series.Volumes.Sum(v => v.Pages);
series.NormalizedName = Parser.Parser.Normalize(series.Name);
series.NormalizedName = series.Name.ToNormalized();
series.OriginalName ??= firstParsedInfo.Series;
if (series.Format == MangaFormat.Unknown)
{
@ -156,7 +160,7 @@ public class ProcessSeries : IProcessSeries
if (!series.LocalizedNameLocked && !string.IsNullOrEmpty(localizedSeries))
{
series.LocalizedName = localizedSeries;
series.NormalizedLocalizedName = Parser.Parser.Normalize(series.LocalizedName);
series.NormalizedLocalizedName = series.LocalizedName.ToNormalized();
}
UpdateSeriesMetadata(series, library);
@ -299,17 +303,17 @@ public class ProcessSeries : IProcessSeries
}
}
if (!string.IsNullOrEmpty(firstChapter.Summary) && !series.Metadata.SummaryLocked)
if (!string.IsNullOrEmpty(firstChapter?.Summary) && !series.Metadata.SummaryLocked)
{
series.Metadata.Summary = firstChapter.Summary;
}
if (!string.IsNullOrEmpty(firstChapter.Language) && !series.Metadata.LanguageLocked)
if (!string.IsNullOrEmpty(firstChapter?.Language) && !series.Metadata.LanguageLocked)
{
series.Metadata.Language = firstChapter.Language;
}
if (!string.IsNullOrEmpty(firstChapter.SeriesGroup) && library.ManageCollections)
if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections)
{
_logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name);
@ -487,7 +491,7 @@ public class ProcessSeries : IProcessSeries
foreach (var volumeNumber in distinctVolumes)
{
_logger.LogDebug("[ScannerService] Looking up volume for {VolumeNumber}", volumeNumber);
Volume volume;
Volume? volume;
try
{
volume = series.Volumes.SingleOrDefault(s => s.Name == volumeNumber);
@ -568,7 +572,7 @@ public class ProcessSeries : IProcessSeries
{
// Specials go into their own chapters with Range being their filename and IsSpecial = True. Non-Specials with Vol and Chap as 0
// also are treated like specials for UI grouping.
Chapter chapter;
Chapter? chapter;
try
{
chapter = volume.Chapters.GetChapterByRange(info);
@ -625,7 +629,7 @@ public class ProcessSeries : IProcessSeries
{
chapter.Files ??= new List<MangaFile>();
var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath);
var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(info.FullFilePath);
var fileInfo = _directoryService.FileSystem.FileInfo.New(info.FullFilePath);
if (existingFile != null)
{
existingFile.Format = info.Format;
@ -645,7 +649,6 @@ public class ProcessSeries : IProcessSeries
}
}
#nullable enable
private void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? info)
{
var firstFile = chapter.Files.MinBy(x => x.Chapter);
@ -813,7 +816,6 @@ public class ProcessSeries : IProcessSeries
}
return ImmutableList<string>.Empty;
}
#nullable disable
/// <summary>
/// Given a list of all existing people, this will check the new names and roles and if it doesn't exist in allPeople, will create and
@ -830,9 +832,9 @@ public class ProcessSeries : IProcessSeries
foreach (var name in names)
{
var normalizedName = Parser.Parser.Normalize(name);
var normalizedName = name.ToNormalized();
var person = allPeopleTypeRole.FirstOrDefault(p =>
p.NormalizedName.Equals(normalizedName));
p.NormalizedName != null && p.NormalizedName.Equals(normalizedName));
if (person == null)
{
person = DbFactory.Person(name, role);
@ -855,7 +857,7 @@ public class ProcessSeries : IProcessSeries
{
foreach (var name in names)
{
var normalizedName = Parser.Parser.Normalize(name);
var normalizedName = name.ToNormalized();
if (string.IsNullOrEmpty(normalizedName)) continue;
_genres.TryGetValue(normalizedName, out var genre);
@ -885,7 +887,7 @@ public class ProcessSeries : IProcessSeries
{
if (string.IsNullOrEmpty(name.Trim())) continue;
var normalizedName = Parser.Parser.Normalize(name);
var normalizedName = name.ToNormalized();
_tags.TryGetValue(normalizedName, out var tag);
var added = tag == null;

View file

@ -9,6 +9,7 @@ using API.Data;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Parser;
using API.Services.Tasks.Metadata;
@ -115,7 +116,7 @@ public class ScannerService : IScannerService
foreach (var file in missingExtensions)
{
var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(file.FilePath);
var fileInfo = _directoryService.FileSystem.FileInfo.New(file.FilePath);
if (!fileInfo.Exists)continue;
file.Extension = fileInfo.Extension.ToLowerInvariant();
file.Bytes = fileInfo.Length;
@ -134,7 +135,7 @@ public class ScannerService : IScannerService
/// <param name="folder"></param>
public async Task ScanFolder(string folder)
{
Series series = null;
Series? series = null;
try
{
series = await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(folder, SeriesIncludes.Library);
@ -193,6 +194,7 @@ public class ScannerService : IScannerService
if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update
var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders);
if (library == null) return;
var libraryPaths = library.Folders.Select(f => f.Path).ToList();
if (await ShouldScanSeries(seriesId, library, libraryPaths, series, true) != ScanCancelReason.NoCancel)
{
@ -216,7 +218,7 @@ public class ScannerService : IScannerService
folderPath = seriesDirs.Keys.FirstOrDefault();
// We should check if folderPath is a library folder path and if so, return early and tell user to correct their setup.
if (libraryPaths.Contains(folderPath))
if (!string.IsNullOrEmpty(folderPath) && libraryPaths.Contains(folderPath))
{
_logger.LogCritical("[ScannerSeries] {SeriesName} scan aborted. Files for series are not in a nested folder under library path. Correct this and rescan", series.Name);
await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent($"{series.Name} scan aborted", "Files for series are not in a nested folder under library path. Correct this and rescan."));
@ -246,12 +248,12 @@ public class ScannerService : IScannerService
var foundParsedSeries = new ParsedSeries()
{
Name = parsedFiles.First().Series,
NormalizedName = Scanner.Parser.Parser.Normalize(parsedFiles.First().Series),
NormalizedName = parsedFiles.First().Series.ToNormalized(),
Format = parsedFiles.First().Format
};
// For Scan Series, we need to filter out anything that isn't our Series
if (!foundParsedSeries.NormalizedName.Equals(series.NormalizedName) && !foundParsedSeries.NormalizedName.Equals(Scanner.Parser.Parser.Normalize(series.OriginalName)))
if (!foundParsedSeries.NormalizedName.Equals(series.NormalizedName) && !foundParsedSeries.NormalizedName.Equals(series.OriginalName?.ToNormalized()))
{
return;
}
@ -275,9 +277,7 @@ public class ScannerService : IScannerService
if (parsedSeries.Count == 0)
{
var seriesFiles = (await _unitOfWork.SeriesRepository.GetFilesForSeries(series.Id));
var anyFilesExist = seriesFiles.Where(f => f.FilePath.Contains(series.FolderPath)).Any(m => File.Exists(m.FilePath));
if (!anyFilesExist)
if (!string.IsNullOrEmpty(series.FolderPath) && !seriesFiles.Where(f => f.FilePath.Contains(series.FolderPath)).Any(m => File.Exists(m.FilePath)))
{
try
{
@ -320,7 +320,8 @@ public class ScannerService : IScannerService
private async Task<ScanCancelReason> ShouldScanSeries(int seriesId, Library library, IList<string> libraryPaths, Series series, bool bypassFolderChecks = false)
{
var seriesFolderPaths = (await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId))
.Select(f => _directoryService.FileSystem.FileInfo.FromFileName(f.FilePath).Directory.FullName)
.Select(f => _directoryService.FileSystem.FileInfo.New(f.FilePath).Directory?.FullName ?? string.Empty)
.Where(f => !string.IsNullOrEmpty(f))
.Distinct()
.ToList();
@ -463,7 +464,7 @@ public class ScannerService : IScannerService
{
var sw = Stopwatch.StartNew();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders);
var libraryFolderPaths = library.Folders.Select(fp => fp.Path).ToList();
var libraryFolderPaths = library!.Folders.Select(fp => fp.Path).ToList();
if (!await CheckMounts(library.Name, libraryFolderPaths)) return;
@ -589,7 +590,7 @@ public class ScannerService : IScannerService
}
private async Task<long> ScanFiles(Library library, IEnumerable<string> dirs,
bool isLibraryScan, Func<Tuple<bool, IList<ParserInfo>>, Task> processSeriesInfos = null, bool forceChecks = false)
bool isLibraryScan, Func<Tuple<bool, IList<ParserInfo>>, Task>? processSeriesInfos = null, bool forceChecks = false)
{
var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService, _eventHub);
var scanWatch = Stopwatch.StartNew();
@ -602,26 +603,6 @@ public class ScannerService : IScannerService
return scanElapsedTime;
}
/// <summary>
/// Remove any user progress rows that no longer exist since scan library ran and deleted series/volumes/chapters
/// </summary>
private async Task CleanupAbandonedChapters()
{
var cleanedUp = await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
_logger.LogInformation("Removed {Count} abandoned progress rows", cleanedUp);
}
/// <summary>
/// Cleans up any abandoned rows due to removals from Scan loop
/// </summary>
private async Task CleanupDbEntities()
{
await CleanupAbandonedChapters();
var cleanedUp = await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
_logger.LogInformation("Removed {Count} abandoned collection tags", cleanedUp);
}
public static IEnumerable<Series> FindSeriesNotOnDisk(IEnumerable<Series> existingSeries, Dictionary<ParsedSeries, IList<ParserInfo>> parsedSeries)
{
return existingSeries.Where(es => !ParserInfoHelpers.SeriesHasMatchingParserInfoFormat(es, parsedSeries));

View file

@ -4,6 +4,7 @@ using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Entities.Enums.Theme;
using API.Extensions;
using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
@ -56,7 +57,7 @@ public class ThemeService : IThemeService
var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList();
var themeFiles = _directoryService
.GetFilesWithExtension(Scanner.Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css")
.Where(name => !reservedNames.Contains(Scanner.Parser.Parser.Normalize(name))).ToList();
.Where(name => !reservedNames.Contains(name.ToNormalized())).ToList();
var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList();
@ -78,7 +79,7 @@ public class ThemeService : IThemeService
foreach (var themeFile in themeFiles)
{
var themeName =
Scanner.Parser.Parser.Normalize(_directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile));
_directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile).ToNormalized();
if (allThemeNames.Contains(themeName)) continue;
_unitOfWork.SiteThemeRepository.Add(new SiteTheme()

View file

@ -161,11 +161,11 @@ public class StatsService : IStatsService
if (firstAdminUser != null)
{
var firstAdminUserPref = (await _unitOfWork.UserRepository.GetPreferencesAsync(firstAdminUser.UserName));
var activeTheme = firstAdminUserPref.Theme ?? Seed.DefaultThemes.First(t => t.IsDefault);
var firstAdminUserPref = (await _unitOfWork.UserRepository.GetPreferencesAsync(firstAdminUser.UserName!));
var activeTheme = firstAdminUserPref?.Theme ?? Seed.DefaultThemes.First(t => t.IsDefault);
serverInfo.ActiveSiteTheme = activeTheme.Name;
serverInfo.MangaReaderMode = firstAdminUserPref.ReaderMode;
if (firstAdminUserPref != null) serverInfo.MangaReaderMode = firstAdminUserPref.ReaderMode;
}
return serverInfo;
@ -242,7 +242,7 @@ public class StatsService : IStatsService
// If first time flow, just return 0
if (!await _context.Series.AnyAsync()) return 0;
return await _context.Series
.Select(s => _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series).Count())
.Select(s => _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series!).Count())
.MaxAsync();
}
@ -254,7 +254,7 @@ public class StatsService : IStatsService
.Select(v => new
{
v.SeriesId,
Count = _context.Series.Where(s => s.Id == v.SeriesId).SelectMany(s => s.Volumes).Count()
Count = _context.Series.Where(s => s.Id == v.SeriesId).SelectMany(s => s.Volumes!).Count()
})
.AsNoTracking()
.AsSplitQuery()
@ -268,9 +268,9 @@ public class StatsService : IStatsService
return await _context.Series
.AsNoTracking()
.AsSplitQuery()
.MaxAsync(s => s.Volumes
.MaxAsync(s => s.Volumes!
.Where(v => v.Number == 0)
.SelectMany(v => v.Chapters)
.SelectMany(v => v.Chapters!)
.Count());
}
@ -292,13 +292,14 @@ public class StatsService : IStatsService
private IEnumerable<FileFormatDto> AllFormats()
{
// TODO: Rewrite this with new migration code in feature/basic-stats
var results = _context.MangaFile
.AsNoTracking()
.AsEnumerable()
.Select(m => new FileFormatDto()
{
Format = m.Format,
Extension = Path.GetExtension(m.FilePath)?.ToLowerInvariant()
Extension = Path.GetExtension(m.FilePath)?.ToLowerInvariant()!
})
.DistinctBy(f => f.Extension)
.ToList();

View file

@ -4,48 +4,46 @@ using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Update;
using API.SignalR;
using API.SignalR.Presence;
using Flurl.Http;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using MarkdownDeep;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace API.Services.Tasks;
internal class GithubReleaseMetadata
internal abstract class GithubReleaseMetadata
{
/// <summary>
/// Name of the Tag
/// <example>v0.4.3</example>
/// </summary>
// ReSharper disable once InconsistentNaming
public string Tag_Name { get; init; }
public required string Tag_Name { get; init; }
/// <summary>
/// Name of the Release
/// </summary>
public string Name { get; init; }
public required string Name { get; init; }
/// <summary>
/// Body of the Release
/// </summary>
public string Body { get; init; }
public required string Body { get; init; }
/// <summary>
/// Url of the release on Github
/// </summary>
// ReSharper disable once InconsistentNaming
public string Html_Url { get; init; }
public required string Html_Url { get; init; }
/// <summary>
/// Date Release was Published
/// </summary>
// ReSharper disable once InconsistentNaming
public string Published_At { get; init; }
public required string Published_At { get; init; }
}
public interface IVersionUpdaterService
{
Task<UpdateNotificationDto> CheckForUpdate();
Task<UpdateNotificationDto?> CheckForUpdate();
Task PushUpdate(UpdateNotificationDto update);
Task<IEnumerable<UpdateNotificationDto>> GetAllReleases();
}
@ -79,16 +77,17 @@ public class VersionUpdaterService : IVersionUpdaterService
{
var update = await GetGithubRelease();
var dto = CreateDto(update);
if (dto == null) return null;
return new Version(dto.UpdateVersion) <= new Version(dto.CurrentVersion) ? null : dto;
}
public async Task<IEnumerable<UpdateNotificationDto>> GetAllReleases()
{
var updates = await GetGithubReleases();
return updates.Select(CreateDto);
return updates.Select(CreateDto).Where(d => d != null)!;
}
private UpdateNotificationDto CreateDto(GithubReleaseMetadata update)
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
{
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty));
@ -106,7 +105,7 @@ public class VersionUpdaterService : IVersionUpdaterService
};
}
public async Task PushUpdate(UpdateNotificationDto update)
public async Task PushUpdate(UpdateNotificationDto? update)
{
if (update == null) return;

View file

@ -19,7 +19,7 @@ namespace API.Services;
public interface ITokenService
{
Task<string> CreateToken(AppUser user);
Task<TokenRequestDto> ValidateRefreshToken(TokenRequestDto request);
Task<TokenRequestDto?> ValidateRefreshToken(TokenRequestDto request);
Task<string> CreateRefreshToken(AppUser user);
}
@ -33,14 +33,14 @@ public class TokenService : ITokenService
{
_userManager = userManager;
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]));
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"] ?? string.Empty));
}
public async Task<string> CreateToken(AppUser user)
{
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Name, user.UserName),
new Claim(JwtRegisteredClaimNames.Name, user.UserName!),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
};
@ -71,11 +71,12 @@ public class TokenService : ITokenService
return refreshToken;
}
public async Task<TokenRequestDto> ValidateRefreshToken(TokenRequestDto request)
public async Task<TokenRequestDto?> ValidateRefreshToken(TokenRequestDto request)
{
var tokenHandler = new JwtSecurityTokenHandler();
var tokenContent = tokenHandler.ReadJwtToken(request.Token);
var username = tokenContent.Claims.FirstOrDefault(q => q.Type == JwtRegisteredClaimNames.NameId)?.Value;
if (string.IsNullOrEmpty(username)) return null;
var user = await _userManager.FindByNameAsync(username);
if (user == null) return null; // This forces a logout
await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "RefreshToken", request.RefreshToken);