diff --git a/API/Controllers/FilterController.cs b/API/Controllers/FilterController.cs index d997aa932..eeffb10b7 100644 --- a/API/Controllers/FilterController.cs +++ b/API/Controllers/FilterController.cs @@ -46,8 +46,6 @@ public class FilterController : BaseApiController return BadRequest("You cannot use the name of a system provided stream"); } - // I might just want to use DashboardStream instead of a separate entity. It will drastically simplify implementation - var existingFilter = user.SmartFilters.FirstOrDefault(f => f.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase)); if (existingFilter != null) diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 0fe0a0f78..eeb52e89f 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -610,4 +610,18 @@ public class SeriesController : BaseApiController } } + /// + /// Based on the delta times between when chapters are added, for series that are not Completed/Cancelled/Hiatus, forecast the next + /// date when it will be available. + /// + /// + /// + [HttpGet("next-expected")] + public async Task> GetNextExpectedChapter(int seriesId) + { + var userId = User.GetUserId(); + + return Ok(await _seriesService.GetEstimatedChapterCreationDate(seriesId, userId)); + } + } diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index c42db3524..563c8e4a0 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -44,6 +44,6 @@ public enum FilterField /// /// Last time User Read /// - ReadingDate = 27 + ReadingDate = 27, } diff --git a/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs new file mode 100644 index 000000000..df4cc1a07 --- /dev/null +++ b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs @@ -0,0 +1,17 @@ +using System; + +namespace API.DTOs.SeriesDetail; + +public class NextExpectedChapterDto +{ + public float ChapterNumber { get; set; } + public int VolumeNumber { get; set; } + /// + /// Null if not applicable + /// + public DateTime? ExpectedDate { get; set; } + /// + /// The localized title to render on the card + /// + public string Title { get; set; } +} diff --git a/API/DTOs/SeriesDetail/SeriesDetailDto.cs b/API/DTOs/SeriesDetail/SeriesDetailDto.cs index 9fc067803..65d657c67 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailDto.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; namespace API.DTOs.SeriesDetail; +#nullable enable /// /// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout. @@ -32,5 +33,4 @@ public class SeriesDetailDto /// How many chapters are there /// public int TotalCount { get; set; } - } diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index acfdca1eb..5ef57e907 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -1,5 +1,7 @@ using System; +using System.Globalization; using System.Linq; +using System.Threading; using API.Entities; using API.Entities.Enums; using API.Services; @@ -168,6 +170,11 @@ public class ComicInfo info.Isbn = info.GTIN; } } + + if (!string.IsNullOrEmpty(info.Number)) + { + info.Number = info.Number.Replace(",", "."); // Corrective measure for non English OSes + } } /// @@ -176,13 +183,21 @@ public class ComicInfo /// public int CalculatedCount() { - if (!string.IsNullOrEmpty(Number) && float.Parse(Number) > 0) + try { - return (int) Math.Floor(float.Parse(Number)); + if (float.TryParse(Number, out var chpCount) && chpCount > 0) + { + return (int) Math.Floor(chpCount); + } + + if (float.TryParse(Volume, out var volCount) && volCount > 0) + { + return (int) Math.Floor(volCount); + } } - if (!string.IsNullOrEmpty(Volume) && float.Parse(Volume) > 0) + catch (Exception) { - return Math.Max(Count, (int) Math.Floor(float.Parse(Volume))); + return 0; } return 0; diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 132aa9310..ec3af56a7 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -205,5 +205,4 @@ public class AppUserProgressRepository : IAppUserProgressRepository .Where(p => p.ChapterId == chapterId && p.AppUserId == userId) .FirstOrDefaultAsync(); } - #nullable disable } diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index bc28b9e1b..106de2386 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -40,6 +40,7 @@ public interface IChapterRepository Task> GetAllChaptersWithCoversInDifferentEncoding(EncodeFormat format); Task> GetCoverImagesForLockedChaptersAsync(); Task AddChapterModifiers(int userId, ChapterDto chapter); + IEnumerable GetChaptersForSeries(int seriesId); } public class ChapterRepository : IChapterRepository { @@ -264,4 +265,12 @@ public class ChapterRepository : IChapterRepository return chapter; } + + public IEnumerable GetChaptersForSeries(int seriesId) + { + return _context.Chapter + .Where(c => c.Volume.SeriesId == seriesId) + .Include(c => c.Volume) + .AsEnumerable(); + } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 8d90fa1e1..298d63610 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -347,9 +348,9 @@ public class SeriesRepository : ISeriesRepository .Where(l => libraryIds.Contains(l.Id)) .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) .IsRestricted(QueryContext.Search) - .OrderBy(l => l.Name.ToLower()) .AsSplitQuery() .Take(maxRecords) + .OrderBy(l => l.Name.ToLower()) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -366,10 +367,10 @@ public class SeriesRepository : ISeriesRepository || (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison)) .RestrictAgainstAgeRestriction(userRating) .Include(s => s.Library) - .OrderBy(s => s.SortName!.ToLower()) .AsNoTracking() .AsSplitQuery() .Take(maxRecords) + .OrderBy(s => s.SortName!.ToLower()) .ProjectTo(_mapper.ConfigurationProvider) .AsEnumerable(); @@ -379,6 +380,7 @@ public class SeriesRepository : ISeriesRepository .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .Take(maxRecords) + .OrderBy(r => r.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -399,8 +401,9 @@ public class SeriesRepository : ISeriesRepository .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%"))) .AsSplitQuery() - .Take(maxRecords) .Distinct() + .Take(maxRecords) + .OrderBy(p => p.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -408,9 +411,9 @@ public class SeriesRepository : ISeriesRepository .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) .AsSplitQuery() - .OrderBy(t => t.NormalizedTitle) .Distinct() .Take(maxRecords) + .OrderBy(t => t.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -418,9 +421,9 @@ public class SeriesRepository : ISeriesRepository .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) .AsSplitQuery() - .OrderBy(t => t.NormalizedTitle) .Distinct() .Take(maxRecords) + .OrderBy(t => t.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -435,6 +438,7 @@ public class SeriesRepository : ISeriesRepository .Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id)) .AsSplitQuery() .Take(maxRecords) + .OrderBy(f => f.FilePath) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -447,12 +451,19 @@ public class SeriesRepository : ISeriesRepository .Where(c => c.Files.All(f => fileIds.Contains(f.Id))) .AsSplitQuery() .Take(maxRecords) + .OrderBy(c => c.TitleName) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); return result; } + /// + /// Includes Progress for the user + /// + /// + /// + /// public async Task GetSeriesDtoByIdAsync(int seriesId, int userId) { var series = await _context.Series.Where(x => x.Id == seriesId) @@ -955,7 +966,7 @@ public class SeriesRepository : ISeriesRepository return ApplyLimit(query - .Sort(filter.SortOptions) + .Sort(userId, filter.SortOptions) .AsSplitQuery(), filter.LimitTo); } @@ -1108,7 +1119,7 @@ public class SeriesRepository : ISeriesRepository || EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%")) .Where(s => userLibraries.Contains(s.LibraryId) && formats.Contains(s.Format)) - .Sort(filter.SortOptions) + .Sort(userId, filter.SortOptions) .AsNoTracking(); return query.AsSplitQuery(); @@ -1971,4 +1982,5 @@ public class SeriesRepository : ISeriesRepository .IsRestricted(queryContext) .Select(lib => lib.Id); } + } diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 00406fa3d..30732fd99 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using API.Data.Repositories; using API.Entities; +using API.Services; using AutoMapper; using Microsoft.AspNetCore.Identity; @@ -40,6 +41,7 @@ public class UnitOfWork : IUnitOfWork private readonly DataContext _context; private readonly IMapper _mapper; private readonly UserManager _userManager; + private readonly ILocalizationService _localizationService; public UnitOfWork(DataContext context, IMapper mapper, UserManager userManager) { @@ -99,6 +101,16 @@ public class UnitOfWork : IUnitOfWork return _context.ChangeTracker.HasChanges(); } + public async Task BeginTransactionAsync() + { + await _context.Database.BeginTransactionAsync(); + } + + public async Task CommitTransactionAsync() + { + await _context.Database.CommitTransactionAsync(); + } + /// /// Rollback transaction /// diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index 5b85b6224..ffb88cafb 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -67,13 +67,13 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate /// public string? Language { get; set; } /// - /// Total number of issues or volumes in the series + /// Total number of issues or volumes in the series. This is straight from ComicInfo /// - /// Users may use Volume count or issue count. Kavita performs some light logic to help Count match up with TotalCount public int TotalCount { get; set; } = 0; /// /// Number of the Total Count (progress the Series is complete) /// + /// This is either the highest of ComicInfo Count field and (nonparsed volume/chapter number) public int Count { get; set; } = 0; /// /// SeriesGroup tag in ComicInfo diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 16d9a52bc..f3ccebc93 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -36,7 +36,7 @@ public class SeriesMetadata : IHasConcurrencyToken /// public string Language { get; set; } = string.Empty; /// - /// Total number of issues/volumes in the series + /// Total expected number of issues/volumes in the series from ComicInfo.xml /// public int TotalCount { get; set; } = 0; /// diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs index 1fdc0111c..52c41c4ee 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs @@ -11,7 +11,7 @@ public static class SeriesSort /// /// /// - public static IQueryable Sort(this IQueryable query, SortOptions? sortOptions) + public static IQueryable Sort(this IQueryable query, int userId, SortOptions? sortOptions) { // If no sort options, default to using SortName sortOptions ??= new SortOptions() @@ -28,7 +28,9 @@ public static class SeriesSort SortField.LastChapterAdded => query.DoOrderBy(s => s.LastChapterAdded, sortOptions), SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, sortOptions), SortField.ReleaseYear => query.DoOrderBy(s => s.Metadata.ReleaseYear, sortOptions), - SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max(), sortOptions), + SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id && p.AppUserId == userId) + .Select(p => p.LastModified) + .Max(), sortOptions), _ => query }; diff --git a/API/Helpers/SmartFilterHelper.cs b/API/Helpers/SmartFilterHelper.cs index 450303e6f..db30decf4 100644 --- a/API/Helpers/SmartFilterHelper.cs +++ b/API/Helpers/SmartFilterHelper.cs @@ -80,7 +80,7 @@ public static class SmartFilterHelper if (statements == null || statements.Count == 0) return string.Empty; - var encodedStatements = StatementsKey + HttpUtility.UrlEncode(string.Join(",", statements.Select(EncodeFilterStatementDto))); + var encodedStatements = StatementsKey + Uri.EscapeDataString(string.Join(",", statements.Select(EncodeFilterStatementDto))); return encodedStatements; } @@ -88,7 +88,7 @@ public static class SmartFilterHelper { var encodedComparison = $"comparison={(int) statement.Comparison}"; var encodedField = $"field={(int) statement.Field}"; - var encodedValue = $"value={HttpUtility.UrlEncode(statement.Value)}"; + var encodedValue = $"value={Uri.EscapeDataString(statement.Value)}"; return $"{encodedComparison}&{encodedField}&{encodedValue}"; } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index de280abf9..7a80d62d1 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -1026,16 +1026,26 @@ public class BookService : IBookService if (chaptersList.Count != 0) return chaptersList; // Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist) - var tocPage = book.Content.Html.Local.Select(s => s.Key).FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) || + var tocPage = book.Content.Html.Local.Select(s => s.Key) + .FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) || k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase)); if (string.IsNullOrEmpty(tocPage)) return chaptersList; + // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList; var content = await file.ReadContentAsync(); var doc = new HtmlDocument(); doc.LoadHtml(content); + + // TODO: We may want to check if there is a toc.ncs file to better handle nested toc + // We could do a fallback first with ol/lis + //var sections = doc.DocumentNode.SelectNodes("//ol"); + //if (sections == null) + + + var anchors = doc.DocumentNode.SelectNodes("//a"); if (anchors == null) return chaptersList; diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 53ff876a3..cdee8932e 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -206,8 +206,7 @@ public class ScrobblingService : IScrobblingService ScrobbleEventType.Review); if (existingEvt is {IsProcessed: false}) { - _logger.LogDebug("Overriding scrobble event for {Series} from Review {Tagline}/{Body} -> {UpdatedTagline}{UpdatedBody}", - existingEvt.Series.Name, existingEvt.ReviewTitle, existingEvt.ReviewBody, reviewTitle, reviewBody); + _logger.LogDebug("Overriding Review scrobble event for {Series}", existingEvt.Series.Name); existingEvt.ReviewBody = reviewBody; existingEvt.ReviewTitle = reviewTitle; _unitOfWork.ScrobbleRepository.Update(existingEvt); diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index f70e6291f..3ef7a14b5 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -17,6 +18,7 @@ using API.Helpers.Builders; using API.Services.Plus; using API.SignalR; using Hangfire; +using Kavita.Common; using Microsoft.Extensions.Logging; namespace API.Services; @@ -36,6 +38,7 @@ public interface ISeriesService Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string? chapterTitle, bool withHash); Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false); + Task GetEstimatedChapterCreationDate(int seriesId, int userId); } public class SeriesService : ISeriesService @@ -399,6 +402,7 @@ public class SeriesService : ISeriesService public async Task GetSeriesDetail(int seriesId, int userId) { var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); var libraryIds = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId); if (!libraryIds.Contains(series.LibraryId)) throw new UnauthorizedAccessException("user-no-access-library-from-series"); @@ -488,7 +492,7 @@ public class SeriesService : ISeriesService Volumes = processedVolumes, StorylineChapters = storylineChapters, TotalCount = chapters.Count, - UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages) + UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages), }; } @@ -642,4 +646,88 @@ public class SeriesService : ISeriesService _unitOfWork.SeriesRepository.Update(series); } } + + public async Task GetEstimatedChapterCreationDate(int seriesId, int userId) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); + if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); + var libraryIds = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId); + if (!libraryIds.Contains(series.LibraryId)) //// TODO: Rewrite this to use a new method which checks permissions all in the DB to be streamlined and less memory + throw new UnauthorizedAccessException("user-no-access-library-from-series"); + if (series?.Metadata.PublicationStatus is not (PublicationStatus.OnGoing or PublicationStatus.Ended) || series.Library.Type == LibraryType.Book) + { + return new NextExpectedChapterDto() + { + ExpectedDate = null, + ChapterNumber = 0, + VolumeNumber = 0 + }; + } + + var chapters = _unitOfWork.ChapterRepository.GetChaptersForSeries(seriesId) + .Where(c => !c.IsSpecial) + .OrderBy(c => c.CreatedUtc) + .ToList(); + + // Calculate the time differences between consecutive chapters + var timeDifferences = chapters + .Select((chapter, index) => new + { + ChapterNumber = chapter.Number, + VolumeNumber = chapter.Volume.Number, + TimeDifference = index == 0 ? TimeSpan.Zero : (chapter.CreatedUtc - chapters.ElementAt(index - 1).CreatedUtc) + }) + .ToList(); + + // Calculate the average time difference between chapters + var averageTimeDifference = timeDifferences + .Average(td => td.TimeDifference.TotalDays); + + // Calculate the forecast for when the next chapter is expected + var nextChapterExpected = chapters.Any() + ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(averageTimeDifference) + : (DateTime?) null; + + if (nextChapterExpected != null && nextChapterExpected < DateTime.UtcNow) + { + nextChapterExpected = DateTime.UtcNow + TimeSpan.FromDays(averageTimeDifference); + } + + var lastChapter = timeDifferences.Last(); + float.TryParse(lastChapter.ChapterNumber, NumberStyles.Number, CultureInfo.InvariantCulture, + out var lastChapterNumber); + + var result = new NextExpectedChapterDto() + { + ChapterNumber = 0, + VolumeNumber = 0, + ExpectedDate = nextChapterExpected, + Title = string.Empty + }; + + if (lastChapterNumber > 0) + { + result.ChapterNumber = lastChapterNumber + 1; + result.VolumeNumber = lastChapter.VolumeNumber; + result.Title = series.Library.Type switch + { + LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", + new object[] {result.ChapterNumber}), + LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", + new object[] {"#", result.ChapterNumber}), + LibraryType.Book => await _localizationService.Translate(userId, "book-num", + new object[] {result.ChapterNumber}), + _ => "Chapter " + result.ChapterNumber + }; + } + else + { + result.VolumeNumber = lastChapter.VolumeNumber + 1; + result.Title = await _localizationService.Translate(userId, "vol-num", + new object[] {result.VolumeNumber}); + } + + + return result; + } } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index d6c43d8c2..6ce24d951 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -288,14 +288,12 @@ public class ProcessSeries : IProcessSeries series.Metadata.TotalCount = chapters.Max(chapter => chapter.TotalCount); // The actual number of count's defined across all chapter's metadata series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count); - // To not have to rely completely on ComicInfo, try to parse out if the series is complete by checking parsed filenames as well. - if (series.Metadata.MaxCount != series.Metadata.TotalCount) - { - var maxVolume = series.Volumes.Max(v => (int) Parser.Parser.MaxNumberFromRange(v.Name)); - var maxChapter = chapters.Max(c => (int) Parser.Parser.MaxNumberFromRange(c.Range)); - if (maxVolume == series.Metadata.TotalCount) series.Metadata.MaxCount = maxVolume; - else if (maxChapter == series.Metadata.TotalCount) series.Metadata.MaxCount = maxChapter; - } + + var maxVolume = series.Volumes.Max(v => (int) Parser.Parser.MaxNumberFromRange(v.Name)); + var maxChapter = chapters.Max(c => (int) Parser.Parser.MaxNumberFromRange(c.Range)); + var maxActual = Math.Max(maxVolume, maxChapter); + + series.Metadata.MaxCount = maxActual; if (!series.Metadata.PublicationStatusLocked) diff --git a/UI/Web/src/app/_models/series-detail/next-expected-chapter.ts b/UI/Web/src/app/_models/series-detail/next-expected-chapter.ts new file mode 100644 index 000000000..e31d73319 --- /dev/null +++ b/UI/Web/src/app/_models/series-detail/next-expected-chapter.ts @@ -0,0 +1,10 @@ +export interface NextExpectedChapter { + volumeNumber: number; + chapterNumber: number; + expectedDate: string | null; + title: string; + /** + * Not real, used for some type stuff with app-card + */ + id: number; +} diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index ea44c2cc1..4aea05982 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -17,6 +17,7 @@ export class ImageService { public errorImage = 'assets/images/error-placeholder2.dark-min.png'; public resetCoverImage = 'assets/images/image-reset-cover-min.png'; public errorWebLinkImage = 'assets/images/broken-white-32x32.png'; + public nextChapterImage = 'assets/images/image-placeholder.dark-min.png' constructor(private accountService: AccountService, private themeService: ThemeService) { this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(theme => { diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index e4d91b410..6a92a491e 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -21,6 +21,7 @@ import {UserReview} from "../_single-module/review-card/user-review"; import {Rating} from "../_models/rating"; import {Recommendation} from "../_models/series-detail/recommendation"; import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail"; +import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter"; @Injectable({ providedIn: 'root' @@ -233,4 +234,8 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/external-series-detail?aniListId=' + (aniListId || 0) + '&malId=' + (malId || 0) + '&seriesId=' + (seriesId || 0)); } + getNextExpectedChapterDate(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'series/next-expected?seriesId=' + seriesId); + } + } diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index fc216da49..f39e19738 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -56,6 +56,17 @@ export class ManageTasksSettingsComponent implements OnInit { api: this.serverService.bustCache(), successMessage: 'bust-cache-task-success' }, + { + name: 'bust-locale-task', + description: 'bust-locale-task-desc', + api: defer(() => { + localStorage.removeItem('@transloco/translations/timestamp'); + localStorage.removeItem('@transloco/translations'); + localStorage.removeItem('translocoLang'); + return of(); + }), + successMessage: 'bust-locale-task-success', + }, { name: 'clear-reading-cache-task', description: 'clear-reading-cache-task-desc', diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index e5fbcece0..00b488a1d 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -324,6 +324,7 @@ $action-bar-height: 38px; color: $primary-color; } +$pagination-color: transparent; .right { @@ -332,7 +333,7 @@ $action-bar-height: 38px; top: $action-bar-height; width: 20vw; z-index: 3; - background: transparent; + background: $pagination-color; border-color: transparent; border: none !important; opacity: 0; @@ -354,7 +355,7 @@ $action-bar-height: 38px; top: $action-bar-height; width: 18%; z-index: 3; - background: transparent; + background: $pagination-color; border-color: transparent; border: none !important; opacity: 0; @@ -374,7 +375,7 @@ $action-bar-height: 38px; left: 0px; top: $action-bar-height; width: 20vw; - background: transparent; + background: $pagination-color; border-color: transparent; border: none !important; z-index: 3; diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 52944f541..665c1e75a 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -34,7 +34,7 @@ {{count}}
-
+
{{overlayInformation}}
diff --git a/UI/Web/src/app/cards/card-item/card-item.component.scss b/UI/Web/src/app/cards/card-item/card-item.component.scss index 88d0038a5..e44086e22 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.scss +++ b/UI/Web/src/app/cards/card-item/card-item.component.scss @@ -77,7 +77,7 @@ $image-width: 160px; position: absolute; top: 0; width: 158px; - + } .not-read-badge { @@ -111,6 +111,8 @@ $image-width: 160px; } } + + .overlay-information { position: absolute; top: 5px; @@ -118,13 +120,18 @@ $image-width: 160px; border-radius: 15px; padding: 0 10px; background-color: var(--card-bg-color); + + &.overlay-information--centered { + top: 95px; + left: 36px; + } } .overlay { height: $image-height; border-top-left-radius: 4px; border-top-right-radius: 4px; - + &:hover { visibility: visible; @@ -138,11 +145,11 @@ $image-width: 160px; z-index: 100; } } - + .overlay-item { visibility: hidden; } - + z-index: 10; .count { diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index b1848667e..c7546d529 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -39,8 +39,10 @@ import {MangaFormatIconPipe} from "../../pipe/manga-format-icon.pipe"; import {SentenceCasePipe} from "../../pipe/sentence-case.pipe"; import {CommonModule} from "@angular/common"; import {RouterLink} from "@angular/router"; -import {TranslocoModule} from "@ngneat/transloco"; +import {translate, TranslocoModule} from "@ngneat/transloco"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; +import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; +import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe"; @Component({ selector: 'app-card-item', @@ -96,7 +98,7 @@ export class CardItemComponent implements OnInit { /** * This is the entity we are representing. It will be returned if an action is executed. */ - @Input({required: true}) entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem; + @Input({required: true}) entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter; /** * If the entity is selected or not. */ @@ -117,6 +119,10 @@ export class CardItemComponent implements OnInit { * Additional information to show on the overlay area. Will always render. */ @Input() overlayInformation: string = ''; + /** + * If overlay is enabled, should the text be centered or not + */ + @Input() centerOverlay = false; /** * Event emitted when item is clicked */ @@ -210,8 +216,29 @@ export class CardItemComponent implements OnInit { } } else if (this.utilityService.isSeries(this.entity)) { this.tooltipTitle = this.title || (this.utilityService.asSeries(this.entity).name); + } else if (this.entity.hasOwnProperty('expectedDate')) { + this.suppressArchiveWarning = true; + this.imageUrl = ''; + const nextDate = (this.entity as NextExpectedChapter); + + // if (nextDate.volumeNumber > 0 && nextDate.chapterNumber === 0) { + // this.overlayInformation = 'Volume ' + nextDate.volumeNumber; + // + // } else { + // this.overlayInformation = 'Chapter ' + nextDate.chapterNumber; + // } + this.overlayInformation = nextDate.title; + this.centerOverlay = true; + + if (nextDate.expectedDate) { + const utcPipe = new UtcToLocalTimePipe(); + this.title = utcPipe.transform(nextDate.expectedDate); + } + + this.cdRef.markForCheck(); } + this.filterSendTo(); this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { this.user = user; diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.scss b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.scss index 0cfec3f33..ce4da6883 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.scss +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.scss @@ -3,7 +3,7 @@ } .book-title { - margin: 8px 0 4px !important; + margin: 8px 0 4px !important; } // Override since it's not coming from library @@ -11,10 +11,15 @@ margin: 3px 0 4px !important; } +::ng-deep .dark > ngx-extended-pdf-viewer .treeItem>a { + color: lightblue !important; +} + + .progress-container { width: 100%; } .progress-bar { // NOTE: We have to override due to theme variables not being available background-color: #3B9E76; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 706c58976..b70f6fac2 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -157,6 +157,7 @@ [selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true"> +
@@ -203,6 +204,7 @@ [selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true"> +
@@ -240,6 +242,7 @@ + @@ -354,5 +357,26 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index f96e86008..0b0006873 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -1,4 +1,4 @@ -import { DOCUMENT, NgIf, NgStyle, NgFor, DecimalPipe } from '@angular/common'; +import {DecimalPipe, DOCUMENT, NgFor, NgIf, NgStyle, NgSwitch, NgSwitchCase, NgTemplateOutlet} from '@angular/common'; import { AfterContentChecked, ChangeDetectionStrategy, @@ -12,17 +12,31 @@ import { OnInit, ViewChild } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {Title} from '@angular/platform-browser'; import {ActivatedRoute, Router} from '@angular/router'; -import { NgbModal, NgbNavChangeEvent, NgbOffcanvas, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'; +import { + NgbDropdown, + NgbDropdownItem, + NgbDropdownMenu, + NgbDropdownToggle, + NgbModal, + NgbNav, + NgbNavChangeEvent, + NgbNavContent, + NgbNavItem, + NgbNavLink, + NgbNavOutlet, + NgbOffcanvas, + NgbProgressbar, + NgbTooltip +} from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; import {catchError, forkJoin, of} from 'rxjs'; import {take} from 'rxjs/operators'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import {CardDetailDrawerComponent} from 'src/app/cards/card-detail-drawer/card-detail-drawer.component'; import {EditSeriesModalComponent} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component'; -import {ConfirmService} from 'src/app/shared/confirm.service'; import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component'; import {DownloadService} from 'src/app/shared/_services/download.service'; import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; @@ -54,27 +68,30 @@ import {ReviewSeriesModalComponent} from '../../../_single-module/review-series- import {PageLayoutMode} from 'src/app/_models/page-layout-mode'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {UserReview} from "../../../_single-module/review-card/user-review"; -import { LoadingComponent } from '../../../shared/loading/loading.component'; -import { ExternalListItemComponent } from '../../../cards/external-list-item/external-list-item.component'; -import { ExternalSeriesCardComponent } from '../../../cards/external-series-card/external-series-card.component'; -import { SeriesCardComponent } from '../../../cards/series-card/series-card.component'; -import { EntityTitleComponent } from '../../../cards/entity-title/entity-title.component'; -import { ListItemComponent } from '../../../cards/list-item/list-item.component'; -import { CardItemComponent } from '../../../cards/card-item/card-item.component'; -import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; -import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component'; -import { ReviewCardComponent } from '../../../_single-module/review-card/review-card.component'; -import { CarouselReelComponent } from '../../../carousel/_components/carousel-reel/carousel-reel.component'; -import { SeriesMetadataDetailComponent } from '../series-metadata-detail/series-metadata-detail.component'; -import { ImageComponent } from '../../../shared/image/image.component'; -import { TagBadgeComponent } from '../../../shared/tag-badge/tag-badge.component'; -import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; +import {LoadingComponent} from '../../../shared/loading/loading.component'; +import {ExternalListItemComponent} from '../../../cards/external-list-item/external-list-item.component'; +import {ExternalSeriesCardComponent} from '../../../cards/external-series-card/external-series-card.component'; +import {SeriesCardComponent} from '../../../cards/series-card/series-card.component'; +import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.component'; +import {ListItemComponent} from '../../../cards/list-item/list-item.component'; +import {CardItemComponent} from '../../../cards/card-item/card-item.component'; +import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; +import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component'; +import {ReviewCardComponent} from '../../../_single-module/review-card/review-card.component'; +import {CarouselReelComponent} from '../../../carousel/_components/carousel-reel/carousel-reel.component'; +import {SeriesMetadataDetailComponent} from '../series-metadata-detail/series-metadata-detail.component'; +import {ImageComponent} from '../../../shared/image/image.component'; +import {TagBadgeComponent} from '../../../shared/tag-badge/tag-badge.component'; +import { + SideNavCompanionBarComponent +} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {ExternalSeries} from "../../../_models/series-detail/external-series"; import { SeriesPreviewDrawerComponent } from "../../../_single-module/series-preview-drawer/series-preview-drawer.component"; +import {PublicationStatus} from "../../../_models/metadata/publication-status"; interface RelatedSeriesPair { series: Series; @@ -102,7 +119,7 @@ interface StoryLineItem { styleUrls: ['./series-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective] + imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase] }) export class SeriesDetailComponent implements OnInit, AfterContentChecked { @@ -150,6 +167,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { seriesImage: string = ''; downloadInProgress: boolean = false; + nextExpectedChapter: any | undefined; + /** * Track by function for Volume to tell when to refresh card data */ @@ -293,7 +312,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { public utilityService: UtilityService, private toastr: ToastrService, private accountService: AccountService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, private libraryService: LibraryService, - private confirmService: ConfirmService, private titleService: Title, + private titleService: Title, private downloadService: DownloadService, private actionService: ActionService, private messageHub: MessageHubService, private readingListService: ReadingListService, public navService: NavService, @@ -501,6 +520,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.seriesService.getMetadata(seriesId).subscribe(metadata => { this.seriesMetadata = metadata; this.cdRef.markForCheck(); + + if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return; + this.seriesService.getNextExpectedChapterDate(seriesId).subscribe(date => { + if (date == null || date.expectedDate === null) return; + + this.nextExpectedChapter = date; + this.cdRef.markForCheck(); + }) }); this.seriesService.isWantToRead(seriesId).subscribe(isWantToRead => { diff --git a/UI/Web/src/app/user-settings/change-email/change-email.component.ts b/UI/Web/src/app/user-settings/change-email/change-email.component.ts index 8f5528c9d..597605f36 100644 --- a/UI/Web/src/app/user-settings/change-email/change-email.component.ts +++ b/UI/Web/src/app/user-settings/change-email/change-email.component.ts @@ -64,7 +64,7 @@ export class ChangeEmailComponent implements OnInit { if (updateEmailResponse.hadNoExistingEmail) { this.toastr.success(translate('toasts.email-sent-to-no-existing', {email: model.email})); } else { - this.toastr.success(translate('toasts.email-send-to')); + this.toastr.success(translate('toasts.email-sent-to')); } } else { this.toastr.success(translate('toasts.change-email-private')); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index bd2933899..d3777cc95 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -897,7 +897,6 @@ "card-item": { "cannot-read": "Cannot Read" - }, "chapter-metadata-detail": { @@ -1191,6 +1190,10 @@ "bust-cache-task-desc": "Busts the Kavita+ Cache - should only be used when debugging bad matches.", "bust-cache-task-success": "Kavita+ Cache busted", + "bust-locale-task": "Bust Locale Cache", + "bust-locale-task-desc": "Busts the Locale Cache. This can fix issues with strings not showing correctly after an update", + "bust-locale-task-success": "Locale Cache busted", + "clear-reading-cache-task": "Clear Reading Cache", "clear-reading-cache-task-desc": "Clears cached files for reading. Useful when you've just updated a file that you were previously reading within the last 24 hours.", "clear-reading-cache-task-success": "Cache has been cleared", diff --git a/openapi.json b/openapi.json index dacb0f10e..8ec8a9d53 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.8.15" + "version": "0.7.9.0" }, "servers": [ { @@ -9071,6 +9071,47 @@ } } }, + "/api/Series/next-expected": { + "get": { + "tags": [ + "Series" + ], + "summary": "Based on the delta times between when chapters are added, for series that are not Completed/Cancelled/Hiatus, forecast the next\r\ndate when it will be available.", + "parameters": [ + { + "name": "seriesId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/NextExpectedChapterDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/NextExpectedChapterDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/NextExpectedChapterDto" + } + } + } + } + } + } + }, "/api/Server/clear-cache": { "post": { "tags": [ @@ -13389,7 +13430,7 @@ }, "totalCount": { "type": "integer", - "description": "Total number of issues or volumes in the series", + "description": "Total number of issues or volumes in the series. This is straight from ComicInfo", "format": "int32" }, "count": { @@ -15637,6 +15678,31 @@ }, "additionalProperties": false }, + "NextExpectedChapterDto": { + "type": "object", + "properties": { + "chapterNumber": { + "type": "number", + "format": "float" + }, + "volumeNumber": { + "type": "integer", + "format": "int32" + }, + "expectedDate": { + "type": "string", + "description": "Null if not applicable", + "format": "date-time", + "nullable": true + }, + "title": { + "type": "string", + "description": "The localized title to render on the card", + "nullable": true + } + }, + "additionalProperties": false + }, "Person": { "type": "object", "properties": { @@ -17244,7 +17310,7 @@ }, "totalCount": { "type": "integer", - "description": "Total number of issues/volumes in the series", + "description": "Total expected number of issues/volumes in the series from ComicInfo.xml", "format": "int32" }, "maxCount": {