diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 34bb2e94e..e3f17ec3e 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -174,6 +174,9 @@ namespace API.Controllers var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, userParams); + // Apply progress/rating information (I can't work out how to do this in initial query) + await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 580ac29f1..f981f2dec 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -82,15 +82,44 @@ namespace API.Controllers userRating.Rating = updateSeriesRatingDto.UserRating; userRating.Review = updateSeriesRatingDto.UserReview; userRating.SeriesId = updateSeriesRatingDto.SeriesId; - - _unitOfWork.UserRepository.AddRatingTracking(userRating); - user.Ratings ??= new List(); - user.Ratings.Add(userRating); - + + if (userRating.Id == 0) + { + user.Ratings ??= new List(); + user.Ratings.Add(userRating); + } + + _unitOfWork.UserRepository.Update(user); if (!await _unitOfWork.Complete()) return BadRequest("There was a critical error."); return Ok(); } + + [HttpPost] + public async Task UpdateSeries(UpdateSeriesDto updateSeries) + { + _logger.LogInformation("{UserName} is updating Series {SeriesName}", User.GetUsername(), updateSeries.Name); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(updateSeries.Id); + + if (series == null) return BadRequest("Series does not exist"); + + // TODO: Support changing series properties once "locking" is implemented. + // series.Name = updateSeries.Name; + // series.OriginalName = updateSeries.OriginalName; + // series.SortName = updateSeries.SortName; + series.Summary = updateSeries.Summary; + //series.CoverImage = updateSeries.CoverImage; + + _unitOfWork.SeriesRepository.Update(series); + + if (await _unitOfWork.Complete()) + { + return Ok(); + } + + return BadRequest("There was an error with updating the series"); + } } } \ No newline at end of file diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs new file mode 100644 index 000000000..c25e99764 --- /dev/null +++ b/API/DTOs/UpdateSeriesDto.cs @@ -0,0 +1,14 @@ +namespace API.DTOs +{ + public class UpdateSeriesDto + { + public int Id { get; init; } + public string Name { get; init; } + public string OriginalName { get; init; } + public string SortName { get; init; } + public string Summary { get; init; } + public byte[] CoverImage { get; init; } + public int UserRating { get; set; } + public string UserReview { get; set; } + } +} \ No newline at end of file diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index c501fdc61..5e0b55d29 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -67,9 +67,6 @@ namespace API.Data public async Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams) { var sw = Stopwatch.StartNew(); - - - var query = _context.Series .Where(s => s.LibraryId == libraryId) .OrderBy(s => s.SortName) @@ -77,10 +74,6 @@ namespace API.Data .AsNoTracking(); - // TODO: Refactor this into JOINs - //await AddSeriesModifiers(userId, series); - - _logger.LogDebug("Processed GetSeriesDtoForLibraryIdAsync in {ElapsedMilliseconds} milliseconds", sw.ElapsedMilliseconds); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } @@ -222,7 +215,7 @@ namespace API.Data return chapterIds.ToArray(); } - private async Task AddSeriesModifiers(int userId, List series) + public async Task AddSeriesModifiers(int userId, List series) { var userProgress = await _context.AppUserProgresses .Where(p => p.AppUserId == userId && series.Select(s => s.Id).Contains(p.SeriesId)) diff --git a/API/Extensions/LeftJoinExtensions.cs b/API/Extensions/LeftJoinExtensions.cs new file mode 100644 index 000000000..c4ea979a4 --- /dev/null +++ b/API/Extensions/LeftJoinExtensions.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; + +namespace API.Extensions +{ + public static class LeftJoinExtensions +{ + public static IQueryable LeftJoin( + this IQueryable outer, + IQueryable inner, + Expression> outerKeySelector, + Expression> innerKeySelector, + Expression> resultSelector) + { + MethodInfo groupJoin = typeof (Queryable).GetMethods() + .Single(m => m.ToString() == "System.Linq.IQueryable`1[TResult] GroupJoin[TOuter,TInner,TKey,TResult](System.Linq.IQueryable`1[TOuter], System.Collections.Generic.IEnumerable`1[TInner], System.Linq.Expressions.Expression`1[System.Func`2[TOuter,TKey]], System.Linq.Expressions.Expression`1[System.Func`2[TInner,TKey]], System.Linq.Expressions.Expression`1[System.Func`3[TOuter,System.Collections.Generic.IEnumerable`1[TInner],TResult]])") + .MakeGenericMethod(typeof (TOuter), typeof (TInner), typeof (TKey), typeof (LeftJoinIntermediate)); + MethodInfo selectMany = typeof (Queryable).GetMethods() + .Single(m => m.ToString() == "System.Linq.IQueryable`1[TResult] SelectMany[TSource,TCollection,TResult](System.Linq.IQueryable`1[TSource], System.Linq.Expressions.Expression`1[System.Func`2[TSource,System.Collections.Generic.IEnumerable`1[TCollection]]], System.Linq.Expressions.Expression`1[System.Func`3[TSource,TCollection,TResult]])") + .MakeGenericMethod(typeof (LeftJoinIntermediate), typeof (TInner), typeof (TResult)); + + var groupJoinResultSelector = (Expression, LeftJoinIntermediate>>) + ((oneOuter, manyInners) => new LeftJoinIntermediate {OneOuter = oneOuter, ManyInners = manyInners}); + + MethodCallExpression exprGroupJoin = Expression.Call(groupJoin, outer.Expression, inner.Expression, outerKeySelector, innerKeySelector, groupJoinResultSelector); + + var selectManyCollectionSelector = (Expression, IEnumerable>>) + (t => t.ManyInners.DefaultIfEmpty()); + + ParameterExpression paramUser = resultSelector.Parameters.First(); + + ParameterExpression paramNew = Expression.Parameter(typeof (LeftJoinIntermediate), "t"); + MemberExpression propExpr = Expression.Property(paramNew, "OneOuter"); + + LambdaExpression selectManyResultSelector = Expression.Lambda(new Replacer(paramUser, propExpr).Visit(resultSelector.Body), paramNew, resultSelector.Parameters.Skip(1).First()); + + MethodCallExpression exprSelectMany = Expression.Call(selectMany, exprGroupJoin, selectManyCollectionSelector, selectManyResultSelector); + + return outer.Provider.CreateQuery(exprSelectMany); + } + + private class LeftJoinIntermediate + { + public TOuter OneOuter { get; set; } + public IEnumerable ManyInners { get; set; } + } + + private class Replacer : ExpressionVisitor + { + private readonly ParameterExpression _oldParam; + private readonly Expression _replacement; + + public Replacer(ParameterExpression oldParam, Expression replacement) + { + _oldParam = oldParam; + _replacement = replacement; + } + + public override Expression Visit(Expression exp) + { + if (exp == _oldParam) + { + return _replacement; + } + + return base.Visit(exp); + } + } +} +} \ No newline at end of file diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index 3fc58824a..6a9845975 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -39,5 +39,12 @@ namespace API.Interfaces Task GetVolumeByIdAsync(int volumeId); Task GetSeriesByIdAsync(int seriesId); Task GetChapterIdsForSeriesAsync(int[] seriesIds); + /// + /// Used to add Progress/Rating information to series list. + /// + /// + /// + /// + Task AddSeriesModifiers(int userId, List series); } } \ No newline at end of file diff --git a/API/Interfaces/Services/IArchiveService.cs b/API/Interfaces/Services/IArchiveService.cs index b150dd761..73452859f 100644 --- a/API/Interfaces/Services/IArchiveService.cs +++ b/API/Interfaces/Services/IArchiveService.cs @@ -1,4 +1,5 @@ using System.IO.Compression; +using API.Entities; namespace API.Interfaces.Services { @@ -9,5 +10,6 @@ namespace API.Interfaces.Services int GetNumberOfPagesFromArchive(string archivePath); byte[] GetCoverImage(string filepath, bool createThumbnail = false); bool IsValidArchive(string archivePath); + string GetSummaryInfo(string archivePath); } } \ No newline at end of file diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index cc2ac7b5e..828f7cc86 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -10,8 +10,10 @@ namespace API.Parser { public static readonly string MangaFileExtensions = @"\.cbz|\.zip"; // |\.rar|\.cbr public static readonly string ImageFileExtensions = @"\.png|\.jpeg|\.jpg|\.gif"; + private static readonly string XmlRegexExtensions = @"\.xml"; private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex MangaFileRegex = new Regex(MangaFileExtensions, RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions, RegexOptions.IgnoreCase | RegexOptions.Compiled); //?: is a non-capturing group in C#, else anything in () will be a group private static readonly Regex[] MangaVolumeRegex = new[] @@ -406,6 +408,12 @@ namespace API.Parser return ImageRegex.IsMatch(fileInfo.Extension); } + public static bool IsXml(string filePath) + { + var fileInfo = new FileInfo(filePath); + return XmlRegex.IsMatch(fileInfo.Extension); + } + public static float MinimumNumberFromRange(string range) { var tokens = range.Split("-"); @@ -416,5 +424,7 @@ namespace API.Parser { return name.ToLower().Replace("-", "").Replace(" ", ""); } + + } } \ No newline at end of file diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index fe3d93e1b..f1c52a0d0 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -3,6 +3,9 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; +using System.Xml; +using System.Xml.Linq; +using System.Xml.Serialization; using API.Extensions; using API.Interfaces; using API.Interfaces.Services; @@ -77,7 +80,7 @@ namespace API.Services { using var stream = entry.Open(); using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth); - return thumbnail.WriteToBuffer(".jpg"); + return thumbnail.WriteToBuffer(".jpg"); // TODO: Validate this code works with .png files } catch (Exception ex) { @@ -144,6 +147,38 @@ namespace API.Services } + public string GetSummaryInfo(string archivePath) + { + var summary = ""; + if (!IsValidArchive(archivePath)) return summary; + + using var archive = ZipFile.OpenRead(archivePath); + if (!archive.HasFiles()) return summary; + + var info = archive.Entries.SingleOrDefault(x => Path.GetFileNameWithoutExtension(x.Name).ToLower() == "comicinfo" && Parser.Parser.IsXml(x.FullName)); + if (info == null) return summary; + + // Parse XML file + try + { + using var stream = info.Open(); + var serializer = new XmlSerializer(typeof(ComicInfo)); + ComicInfo comicInfo = + (ComicInfo)serializer.Deserialize(stream); + + if (comicInfo != null) + { + return comicInfo.Summary; + } + } + catch (AggregateException ex) + { + _logger.LogError(ex, "There was an issue parsing ComicInfo.xml from {ArchivePath}", archivePath); + } + + return summary; + } + /// /// Extracts an archive to a temp cache directory. Returns path to new directory. If temp cache directory already exists, /// will return that without performing an extraction. Returns empty string if there are any invalidations which would diff --git a/API/Services/ComicInfo.cs b/API/Services/ComicInfo.cs new file mode 100644 index 000000000..1f994d224 --- /dev/null +++ b/API/Services/ComicInfo.cs @@ -0,0 +1,15 @@ +namespace API.Services +{ + public class ComicInfo + { + public string Summary; + public string Title; + public string Series; + public string Notes; + public string Publisher; + public string Genre; + public int PageCount; + public string LanguageISO; + public string Web; + } +} \ No newline at end of file diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index e8411e212..c762bdbf7 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -65,10 +65,14 @@ namespace API.Services } series.CoverImage = firstCover?.CoverImage; } + + if (!string.IsNullOrEmpty(series.Summary) && !forceUpdate) return; - if (string.IsNullOrEmpty(series.Summary) || forceUpdate) + var firstVolume = series.Volumes.FirstOrDefault(v => v.Chapters.Any() && v.Number == 1); + var firstChapter = firstVolume?.Chapters.FirstOrDefault(c => c.Files.Any()); + if (firstChapter != null) { - series.Summary = ""; + series.Summary = _archiveService.GetSummaryInfo(firstChapter.Files.FirstOrDefault()?.FilePath); } } diff --git a/API/appsettings.Development.json b/API/appsettings.Development.json index d2bf464c4..67e0049ba 100644 --- a/API/appsettings.Development.json +++ b/API/appsettings.Development.json @@ -6,7 +6,7 @@ "Logging": { "LogLevel": { "Default": "Debug", - "Microsoft": "Error", + "Microsoft": "Information", "Microsoft.Hosting.Lifetime": "Error", "Hangfire": "Information" },