From 730624d36476602b379d39257179d246a361fc55 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Wed, 2 Feb 2022 07:18:09 -0800 Subject: [PATCH] Recently Added Chapter Feedback (#1020) * Added an alt implementation which shows Recently Added chapters. No extra grouping is performed if multiple chapters per volume. * Started working on a grouping implementation for series. * Disabled the code for bookmarks cleanup as there is some critical issue in there. * Implemented a Series Group activity stream which shows recently updated series and providers a count badge showing how many new chapters/volumes were added in that series. * Removed the bookmark disabling code * Cleaned up code * One more code cleanup --- API/Controllers/SeriesController.cs | 9 +- API/DTOs/GroupedSeriesDto.cs | 32 ++ API/Data/Repositories/SeriesRepository.cs | 290 ++++++++++-------- API/Entities/Series.cs | 6 + API/Parser/Parser.cs | 1 + API/Services/Tasks/CleanupService.cs | 2 - UI/Web/src/app/_models/recently-added-item.ts | 2 +- UI/Web/src/app/_models/series-group.ts | 14 + UI/Web/src/app/_services/series.service.ts | 14 +- .../cards/card-item/card-item.component.html | 4 + .../cards/card-item/card-item.component.scss | 6 + .../cards/card-item/card-item.component.ts | 4 + UI/Web/src/app/library/library.component.html | 20 +- UI/Web/src/app/library/library.component.ts | 10 + 14 files changed, 261 insertions(+), 153 deletions(-) create mode 100644 API/DTOs/GroupedSeriesDto.cs create mode 100644 UI/Web/src/app/_models/series-group.ts diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index e39ea1fd2..397109c09 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -236,8 +236,15 @@ namespace API.Controllers return Ok(series); } - [HttpPost("recently-added-chapters")] + [HttpPost("recently-updated-series")] public async Task>> GetRecentlyAddedChapters() + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId)); + } + + [HttpPost("recently-added-chapters")] + public async Task>> GetRecentlyAddedChaptersAlt() { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.GetRecentlyAddedChapters(userId)); diff --git a/API/DTOs/GroupedSeriesDto.cs b/API/DTOs/GroupedSeriesDto.cs new file mode 100644 index 000000000..9795da16e --- /dev/null +++ b/API/DTOs/GroupedSeriesDto.cs @@ -0,0 +1,32 @@ +using System; +using API.Entities.Enums; + +namespace API.DTOs; +/// +/// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section +/// +public class GroupedSeriesDto +{ + public string SeriesName { get; set; } + public int SeriesId { get; set; } + public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } + public DateTime Created { get; set; } + /// + /// Chapter Id if this is a chapter. Not guaranteed to be set. + /// + public int ChapterId { get; set; } = 0; + /// + /// Volume Id if this is a chapter. Not guaranteed to be set. + /// + public int VolumeId { get; set; } = 0; + /// + /// This is used only on the UI. It is just index of being added. + /// + public int Id { get; set; } + public MangaFormat Format { get; set; } + /// + /// Number of items that are updated. This provides a sort of grouping when multiple chapters are added per Volume/Series + /// + public int Count { get; set; } +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 6e51fb60f..a539185a7 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; +using API.Data.Migrations; using API.Data.Scanner; using API.DTOs; using API.DTOs.CollectionTags; @@ -21,6 +22,26 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +internal class RecentlyAddedSeries +{ + public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } + public DateTime Created { get; set; } + public int SeriesId { get; set; } + public string SeriesName { get; set; } + public Series Series { get; set; } + public IList Chapters { get; set; } // I don't know if I need this + public Chapter Chapter { get; set; } // for Alt implementation + public MangaFormat Format { get; set; } + public int ChapterId { get; set; } // for Alt implementation + public int VolumeId { get; set; } // for Alt implementation + public string ChapterNumber { get; set; } + public string ChapterRange { get; set; } + public string ChapterTitle { get; set; } + public bool IsSpecial { get; set; } + public int VolumeNumber { get; set; } +} + public interface ISeriesRepository { void Attach(Series series); @@ -73,6 +94,7 @@ public interface ISeriesRepository Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); Task> GetAllLanguagesForLibrariesAsync(List libraryIds); Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); + Task> GetRecentlyUpdatedSeries(int userId); Task> GetRecentlyAddedChapters(int userId); } @@ -346,7 +368,7 @@ public class SeriesRepository : ISeriesRepository } /// - /// This returns a dictonary mapping seriesId -> list of chapters back for each series id passed + /// This returns a dictionary mapping seriesId -> list of chapters back for each series id passed /// /// /// @@ -453,7 +475,6 @@ public class SeriesRepository : ISeriesRepository allPeopleIds.AddRange(filter.Publisher); allPeopleIds.AddRange(filter.CoverArtist); allPeopleIds.AddRange(filter.Translators); - //allPeopleIds.AddRange(filter.Artist); hasPeopleFilter = allPeopleIds.Count > 0; hasGenresFilter = filter.Genres.Count > 0; @@ -808,7 +829,116 @@ public class SeriesRepository : ISeriesRepository .ToListAsync(); } + private static string RecentlyAddedItemTitle(RecentlyAddedSeries item) + { + switch (item.LibraryType) + { + case LibraryType.Book: + return string.Empty; + case LibraryType.Comic: + return "Issue"; + case LibraryType.Manga: + default: + return "Chapter"; + } + } + + /// + /// Show all recently added chapters. Provide some mapping for chapter 0 -> Volume 1 + /// + /// + /// public async Task> GetRecentlyAddedChapters(int userId) + { + var ret = await GetRecentlyAddedChaptersQuery(userId); + + var items = new List(); + foreach (var item in ret) + { + var dto = new RecentlyAddedItemDto() + { + LibraryId = item.LibraryId, + LibraryType = item.LibraryType, + SeriesId = item.SeriesId, + SeriesName = item.SeriesName, + Created = item.Created, + Id = items.Count, + Format = item.Format + }; + + // Add title and Volume/Chapter Id + var chapterTitle = RecentlyAddedItemTitle(item); + string title; + if (item.ChapterNumber.Equals(Parser.Parser.DefaultChapter)) + { + if ((item.VolumeNumber + string.Empty).Equals(Parser.Parser.DefaultChapter)) + { + title = item.ChapterTitle; + } + else + { + title = "Volume " + item.VolumeNumber; + } + + dto.VolumeId = item.VolumeId; + } + else + { + title = item.IsSpecial + ? item.ChapterRange + : $"{chapterTitle} {item.ChapterRange}"; + dto.ChapterId = item.ChapterId; + } + + dto.Title = title; + items.Add(dto); + } + + + return items; + + } + + + /// + /// Return recently updated series, regardless of read progress, and group the number of volume or chapters added. + /// + /// Used to ensure user has access to libraries + /// + public async Task> GetRecentlyUpdatedSeries(int userId) + { + var ret = await GetRecentlyAddedChaptersQuery(userId); + + + var seriesMap = new Dictionary(); + var index = 0; + foreach (var item in ret) + { + if (seriesMap.ContainsKey(item.SeriesName)) + { + seriesMap[item.SeriesName].Count += 1; + } + else + { + seriesMap[item.SeriesName] = new GroupedSeriesDto() + { + LibraryId = item.LibraryId, + LibraryType = item.LibraryType, + SeriesId = item.SeriesId, + SeriesName = item.SeriesName, + Created = item.Created, + Id = index, + Format = item.Format, + Count = 1 + }; + index += 1; + } + } + + return seriesMap.Values.ToList(); + } + + private async Task> GetRecentlyAddedChaptersQuery(int userId) { var libraries = await _context.AppUser .Where(u => u.Id == userId) @@ -817,141 +947,33 @@ public class SeriesRepository : ISeriesRepository var libraryIds = libraries.Select(l => l.LibraryId).ToList(); var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12); - - var ret = await _context.Series - .Where(s => libraryIds.Contains(s.LibraryId) && s.LastModified >= withinLastWeek) - .Include(s => s.Volumes) - .ThenInclude(v => v.Chapters) - .Select(s => new + var ret = await _context.Chapter + .Where(c => c.Created >= withinLastWeek) + .AsNoTracking() + .Include(c => c.Volume) + .ThenInclude(v => v.Series) + .ThenInclude(s => s.Library) + .OrderByDescending(c => c.Created) + .Select(c => new RecentlyAddedSeries() { - s.LibraryId, - LibraryType = s.Library.Type, - s.Created, - SeriesId = s.Id, - SeriesName = s.Name, - Series = s, - Chapters = s.Volumes.SelectMany(v => v.Chapters) + LibraryId = c.Volume.Series.LibraryId, + LibraryType = c.Volume.Series.Library.Type, + Created = c.Created, + SeriesId = c.Volume.Series.Id, + SeriesName = c.Volume.Series.Name, + Series = c.Volume.Series, + VolumeId = c.VolumeId, + ChapterId = c.Id, + Format = c.Volume.Series.Format, + ChapterNumber = c.Number, + ChapterRange = c.Range, + IsSpecial = c.IsSpecial, + VolumeNumber = c.Volume.Number, + ChapterTitle = c.Title }) .Take(50) - .AsNoTracking() - .AsSplitQuery() - .OrderByDescending(item => item.Created) + .Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId)) .ToListAsync(); - - var items = new List(); - foreach (var series in ret) - { - if (items.Count >= 50) return items; - var chaptersThatMeetCutoff = series.Chapters.Where(c => c.Created >= withinLastWeek) - .OrderByDescending(c => c.Created); - var chapterMap = chaptersThatMeetCutoff.GroupBy(c => c.VolumeId) - .ToDictionary(g => g.Key, g => g.ToList()); - - foreach (var (volumeId, chapters) in chapterMap) - { - // If a single chapter - if (chapters.Count == 1) - { - // Create a chapter ReadingListItemDto - var chapterTitle = "Chapter"; - switch (series.LibraryType) - { - case LibraryType.Book: - chapterTitle = ""; - break; - case LibraryType.Comic: - chapterTitle = "Issue"; - break; - } - - // If chapter is 0, then it means it's really a volume, so show it that way - var firstChapter = chapters.First(); - string title; - if (firstChapter.Number.Equals(Parser.Parser.DefaultChapter)) - { - title = "Volume " + series.Series.Volumes.FirstOrDefault(v => v.Id == volumeId)?.Number; - } - else - { - title = chapters.First().IsSpecial - ? chapters.FirstOrDefault()?.Range - : $"{chapterTitle} {chapters.FirstOrDefault()?.Range}"; - } - - items.Add(new RecentlyAddedItemDto() - { - LibraryId = series.LibraryId, - LibraryType = series.LibraryType, - SeriesId = series.SeriesId, - SeriesName = series.SeriesName, - Created = chapters.Max(c => c.Created), - Title = title, - ChapterId = firstChapter.Id, - Id = items.Count, - Format = series.Series.Format - }); - if (items.Count >= 50) return items; - continue; - } - - - // Multiple chapters, so let's show as a volume - var volumeNumber = series.Series.Volumes.FirstOrDefault(v => v.Id == volumeId)?.Number; - if (volumeNumber == 0) - { - var volumeChapters = chapters.Where(c => c.Created >= withinLastWeek).ToList(); - foreach (var chap in volumeChapters) - { - // Create a chapter ReadingListItemDto - var chapterTitle = "Chapter"; - switch (series.LibraryType) - { - case LibraryType.Book: - chapterTitle = ""; - break; - case LibraryType.Comic: - chapterTitle = "Issue"; - break; - } - - var title = volumeChapters.First().IsSpecial - ? volumeChapters.FirstOrDefault()?.Range - : $"{chapterTitle} {volumeChapters.FirstOrDefault()?.Range}"; - items.Add(new RecentlyAddedItemDto() - { - LibraryId = series.LibraryId, - LibraryType = series.LibraryType, - SeriesId = series.SeriesId, - SeriesName = series.SeriesName, - Created = chap.Created, - Title = title, - ChapterId = chap.Id, - Id = items.Count, - Format = series.Series.Format - }); - if (items.Count >= 50) return items; - } - continue; - } - - // Create a volume ReadingListItemDto - var theVolume = series.Series.Volumes.First(v => v.Id == volumeId); - items.Add(new RecentlyAddedItemDto() - { - LibraryId = series.LibraryId, - LibraryType = series.LibraryType, - SeriesId = series.SeriesId, - SeriesName = series.SeriesName, - Created = chapters.Max(c => c.Created), - Title = "Volume " + theVolume.Number, - VolumeId = theVolume.Id, - Id = items.Count, - Format = series.Series.Format - }); - if (items.Count >= 50) return items; - } - } - - return items; + return ret; } } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index a532028bb..77a011d53 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -31,7 +31,13 @@ namespace API.Entities /// Original Name on disk. Not exposed to UI. /// public string OriginalName { get; set; } + /// + /// Time of creation + /// public DateTime Created { get; set; } + /// + /// Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc + /// public DateTime LastModified { get; set; } /// /// Absolute path to the (managed) image file diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index be9d8e8af..01bb8b4e6 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -964,6 +964,7 @@ namespace API.Parser public static string Normalize(string name) { + // TODO: This can eat upto 100MB on a file scan. Look into optimizing var normalized = NormalizeRegex.Replace(name, string.Empty).ToLower(); return string.IsNullOrEmpty(normalized) ? name : normalized; } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 19583b979..05dfdc6c0 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -199,8 +199,6 @@ namespace API.Services.Tasks _directoryService.FileSystem.Directory.Delete(directory, false); } } - - } } } diff --git a/UI/Web/src/app/_models/recently-added-item.ts b/UI/Web/src/app/_models/recently-added-item.ts index f60b83dc6..4c44474a8 100644 --- a/UI/Web/src/app/_models/recently-added-item.ts +++ b/UI/Web/src/app/_models/recently-added-item.ts @@ -9,5 +9,5 @@ export interface RecentlyAddedItem { libraryType: LibraryType; volumeId: number; chapterId: number; - id: number; // This is UI only + id: number; // This is UI only, sent from backend but has no relation to any entity } \ No newline at end of file diff --git a/UI/Web/src/app/_models/series-group.ts b/UI/Web/src/app/_models/series-group.ts new file mode 100644 index 000000000..657890c25 --- /dev/null +++ b/UI/Web/src/app/_models/series-group.ts @@ -0,0 +1,14 @@ +import { LibraryType } from "./library"; + +export interface SeriesGroup { + seriesId: number; + seriesName: string; + created: string; + title: string; + libraryId: number; + libraryType: LibraryType; + volumeId: number; + chapterId: number; + id: number; // This is UI only, sent from backend but has no relation to any entity + count: number; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index e6a35a775..b432f3d2a 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -9,6 +9,7 @@ import { PaginatedResult } from '../_models/pagination'; import { RecentlyAddedItem } from '../_models/recently-added-item'; import { Series } from '../_models/series'; import { SeriesFilter } from '../_models/series-filter'; +import { SeriesGroup } from '../_models/series-group'; import { SeriesMetadata } from '../_models/series-metadata'; import { Volume } from '../_models/volume'; import { ImageService } from './image.service'; @@ -123,13 +124,11 @@ export class SeriesService { ); } + getRecentlyUpdatedSeries() { + return this.httpClient.post(this.baseUrl + 'series/recently-updated-series', {}); + } getRecentlyAddedChapters() { - return this.httpClient.post(this.baseUrl + 'series/recently-added-chapters', {}).pipe( - map(items => { - items.forEach((item, i) => item.id = i); - return items; - }) - ); + return this.httpClient.post(this.baseUrl + 'series/recently-added-chapters', {}); } getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { @@ -144,9 +143,6 @@ export class SeriesService { })); } - // getContinueReading(libraryId: number = 0) { - // return this.httpClient.get(this.baseUrl + 'series/continue-reading?libraryId=' + libraryId); - // } refreshMetadata(series: Series) { return this.httpClient.post(this.baseUrl + 'series/refresh-metadata', {libraryId: series.libraryId, seriesId: series.id}); 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 611cc7461..6f1163ef0 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 @@ -22,6 +22,10 @@
+ +
+ {{count}} +
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 d1c81c155..f6dd70bdd 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 @@ -118,6 +118,12 @@ $image-width: 160px; } z-index: 10; + + .count { + top: 5px; + right: 10px; + position: absolute; + } } .card-actions { 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 84a6b0418..fc98739c7 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 @@ -68,6 +68,10 @@ export class CardItemComponent implements OnInit, OnDestroy { * This will supress the cannot read archive warning when total pages is 0 */ @Input() supressArchiveWarning: boolean = false; + /** + * The number of updates/items within the card. If less than 2, will not be shown. + */ + @Input() count: number = 0; /** * Event emitted when item is clicked */ diff --git a/UI/Web/src/app/library/library.component.html b/UI/Web/src/app/library/library.component.html index 00e56db0b..63a07cce2 100644 --- a/UI/Web/src/app/library/library.component.html +++ b/UI/Web/src/app/library/library.component.html @@ -11,6 +11,20 @@ + + + + + + + + + - - - - - - diff --git a/UI/Web/src/app/library/library.component.ts b/UI/Web/src/app/library/library.component.ts index ca71d562f..dbf002b82 100644 --- a/UI/Web/src/app/library/library.component.ts +++ b/UI/Web/src/app/library/library.component.ts @@ -8,6 +8,7 @@ import { SeriesRemovedEvent } from '../_models/events/series-removed-event'; import { Library } from '../_models/library'; import { RecentlyAddedItem } from '../_models/recently-added-item'; import { Series } from '../_models/series'; +import { SeriesGroup } from '../_models/series-group'; import { User } from '../_models/user'; import { AccountService } from '../_services/account.service'; import { ImageService } from '../_services/image.service'; @@ -28,6 +29,7 @@ export class LibraryComponent implements OnInit, OnDestroy { isAdmin = false; recentlyAdded: Series[] = []; + recentlyUpdatedSeries: SeriesGroup[] = []; recentlyAddedChapters: RecentlyAddedItem[] = []; inProgress: Series[] = []; @@ -45,10 +47,14 @@ export class LibraryComponent implements OnInit, OnDestroy { this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => { this.recentlyAdded.unshift(series); }); + this.loadRecentlyAddedChapters(); } else if (res.event === EVENTS.SeriesRemoved) { const seriesRemovedEvent = res.payload as SeriesRemovedEvent; this.recentlyAdded = this.recentlyAdded.filter(item => item.id != seriesRemovedEvent.seriesId); this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId); + + this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId); + this.recentlyAddedChapters = this.recentlyAddedChapters.filter(item => item.seriesId != seriesRemovedEvent.seriesId); } }); } @@ -105,6 +111,10 @@ export class LibraryComponent implements OnInit, OnDestroy { } loadRecentlyAddedChapters() { + this.seriesService.getRecentlyUpdatedSeries().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { + this.recentlyUpdatedSeries = updatedSeries; + }); + this.seriesService.getRecentlyAddedChapters().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { this.recentlyAddedChapters = updatedSeries; });