diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 759577bc1..4a2ed0f32 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -802,6 +802,26 @@ public class SeriesServiceTests : AbstractDbTest return series; } + [Fact] + public void GetFirstChapterForMetadata_BookWithOnlyVolumeNumbers_Test() + { + var file = new MangaFileBuilder("Test.cbz", MangaFormat.Archive, 1).Build(); + + var series = new SeriesBuilder("Test") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("0").WithPages(1).WithFile(file).Build()) + .Build()) + + .WithVolume(new VolumeBuilder("1.5") + .WithChapter(new ChapterBuilder("0").WithPages(2).WithFile(file).Build()) + .Build()) + .Build(); + series.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); + + var firstChapter = SeriesService.GetFirstChapterForMetadata(series); + Assert.Equal(1, firstChapter.Pages); + } + [Fact] public void GetFirstChapterForMetadata_Book_Test() { diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs index caf49d6db..d26822954 100644 --- a/API/Controllers/ScrobblingController.cs +++ b/API/Controllers/ScrobblingController.cs @@ -143,8 +143,7 @@ public class ScrobblingController : BaseApiController [HttpGet("library-allows-scrobbling")] public async Task> LibraryAllowsScrobbling(int seriesId) { - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); - return Ok(series != null && series.Library.AllowScrobbling); + return Ok(await _unitOfWork.LibraryRepository.GetAllowsScrobblingBySeriesId(seriesId)); } /// diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index d711d5f47..625ff38ba 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -125,14 +125,21 @@ public class StatsController : BaseApiController } [HttpGet("day-breakdown")] - [Authorize("RequireAdminRole")] [ResponseCache(CacheProfileName = "Statistics")] - public ActionResult>> GetDayBreakdown() + public async Task>>> GetDayBreakdown(int userId = 0) { - return Ok(_statService.GetDayBreakdown()); + if (userId == 0) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + if (!isAdmin) return BadRequest(); + } + + return Ok(_statService.GetDayBreakdown(userId)); } + [HttpGet("user/reading-history")] [ResponseCache(CacheProfileName = "Statistics")] public async Task>> GetReadingHistory(int userId) diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 48c49423c..adc04905d 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.9"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.10"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -180,7 +180,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("AppUserBookmark"); + b.ToTable("AppUserBookmark", (string)null); }); modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => @@ -201,7 +201,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserOnDeckRemoval"); + b.ToTable("AppUserOnDeckRemoval", (string)null); }); modelBuilder.Entity("API.Entities.AppUserPreferences", b => @@ -315,7 +315,7 @@ namespace API.Data.Migrations b.HasIndex("ThemeId"); - b.ToTable("AppUserPreferences"); + b.ToTable("AppUserPreferences", (string)null); }); modelBuilder.Entity("API.Entities.AppUserProgress", b => @@ -365,7 +365,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserProgresses"); + b.ToTable("AppUserProgresses", (string)null); }); modelBuilder.Entity("API.Entities.AppUserRating", b => @@ -398,7 +398,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserRating"); + b.ToTable("AppUserRating", (string)null); }); modelBuilder.Entity("API.Entities.AppUserRole", b => @@ -466,7 +466,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserTableOfContent"); + b.ToTable("AppUserTableOfContent", (string)null); }); modelBuilder.Entity("API.Entities.Chapter", b => @@ -576,7 +576,7 @@ namespace API.Data.Migrations b.HasIndex("VolumeId"); - b.ToTable("Chapter"); + b.ToTable("Chapter", (string)null); }); modelBuilder.Entity("API.Entities.CollectionTag", b => @@ -611,7 +611,7 @@ namespace API.Data.Migrations b.HasIndex("Id", "Promoted") .IsUnique(); - b.ToTable("CollectionTag"); + b.ToTable("CollectionTag", (string)null); }); modelBuilder.Entity("API.Entities.Device", b => @@ -657,7 +657,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("Device"); + b.ToTable("Device", (string)null); }); modelBuilder.Entity("API.Entities.FolderPath", b => @@ -679,7 +679,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("FolderPath"); + b.ToTable("FolderPath", (string)null); }); modelBuilder.Entity("API.Entities.Genre", b => @@ -699,7 +699,7 @@ namespace API.Data.Migrations b.HasIndex("NormalizedTitle") .IsUnique(); - b.ToTable("Genre"); + b.ToTable("Genre", (string)null); }); modelBuilder.Entity("API.Entities.Library", b => @@ -757,7 +757,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("Library"); + b.ToTable("Library", (string)null); }); modelBuilder.Entity("API.Entities.MangaFile", b => @@ -806,7 +806,7 @@ namespace API.Data.Migrations b.HasIndex("ChapterId"); - b.ToTable("MangaFile"); + b.ToTable("MangaFile", (string)null); }); modelBuilder.Entity("API.Entities.MediaError", b => @@ -841,7 +841,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("MediaError"); + b.ToTable("MediaError", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => @@ -942,7 +942,7 @@ namespace API.Data.Migrations b.HasIndex("Id", "SeriesId") .IsUnique(); - b.ToTable("SeriesMetadata"); + b.ToTable("SeriesMetadata", (string)null); }); modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => @@ -966,7 +966,7 @@ namespace API.Data.Migrations b.HasIndex("TargetSeriesId"); - b.ToTable("SeriesRelation"); + b.ToTable("SeriesRelation", (string)null); }); modelBuilder.Entity("API.Entities.Person", b => @@ -986,7 +986,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("Person"); + b.ToTable("Person", (string)null); }); modelBuilder.Entity("API.Entities.ReadingList", b => @@ -1049,7 +1049,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("ReadingList"); + b.ToTable("ReadingList", (string)null); }); modelBuilder.Entity("API.Entities.ReadingListItem", b => @@ -1083,7 +1083,7 @@ namespace API.Data.Migrations b.HasIndex("VolumeId"); - b.ToTable("ReadingListItem"); + b.ToTable("ReadingListItem", (string)null); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => @@ -1128,7 +1128,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleError"); + b.ToTable("ScrobbleError", (string)null); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => @@ -1188,8 +1188,8 @@ namespace API.Data.Migrations b.Property("SeriesId") .HasColumnType("INTEGER"); - b.Property("VolumeNumber") - .HasColumnType("INTEGER"); + b.Property("VolumeNumber") + .HasColumnType("REAL"); b.HasKey("Id"); @@ -1199,7 +1199,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleEvent"); + b.ToTable("ScrobbleEvent", (string)null); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => @@ -1232,7 +1232,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleHold"); + b.ToTable("ScrobbleHold", (string)null); }); modelBuilder.Entity("API.Entities.Series", b => @@ -1328,7 +1328,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("Series"); + b.ToTable("Series", (string)null); }); modelBuilder.Entity("API.Entities.ServerSetting", b => @@ -1345,7 +1345,7 @@ namespace API.Data.Migrations b.HasKey("Key"); - b.ToTable("ServerSetting"); + b.ToTable("ServerSetting", (string)null); }); modelBuilder.Entity("API.Entities.ServerStatistics", b => @@ -1383,7 +1383,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("ServerStatistics"); + b.ToTable("ServerStatistics", (string)null); }); modelBuilder.Entity("API.Entities.SiteTheme", b => @@ -1421,7 +1421,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("SiteTheme"); + b.ToTable("SiteTheme", (string)null); }); modelBuilder.Entity("API.Entities.Tag", b => @@ -1441,7 +1441,7 @@ namespace API.Data.Migrations b.HasIndex("NormalizedTitle") .IsUnique(); - b.ToTable("Tag"); + b.ToTable("Tag", (string)null); }); modelBuilder.Entity("API.Entities.Volume", b => @@ -1477,8 +1477,8 @@ namespace API.Data.Migrations b.Property("Name") .HasColumnType("TEXT"); - b.Property("Number") - .HasColumnType("INTEGER"); + b.Property("Number") + .HasColumnType("REAL"); b.Property("Pages") .HasColumnType("INTEGER"); @@ -1493,7 +1493,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("Volume"); + b.ToTable("Volume", (string)null); }); modelBuilder.Entity("AppUserLibrary", b => @@ -1508,7 +1508,7 @@ namespace API.Data.Migrations b.HasIndex("LibrariesId"); - b.ToTable("AppUserLibrary"); + b.ToTable("AppUserLibrary", (string)null); }); modelBuilder.Entity("ChapterGenre", b => @@ -1523,7 +1523,7 @@ namespace API.Data.Migrations b.HasIndex("GenresId"); - b.ToTable("ChapterGenre"); + b.ToTable("ChapterGenre", (string)null); }); modelBuilder.Entity("ChapterPerson", b => @@ -1538,7 +1538,7 @@ namespace API.Data.Migrations b.HasIndex("PeopleId"); - b.ToTable("ChapterPerson"); + b.ToTable("ChapterPerson", (string)null); }); modelBuilder.Entity("ChapterTag", b => @@ -1553,7 +1553,7 @@ namespace API.Data.Migrations b.HasIndex("TagsId"); - b.ToTable("ChapterTag"); + b.ToTable("ChapterTag", (string)null); }); modelBuilder.Entity("CollectionTagSeriesMetadata", b => @@ -1568,7 +1568,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("CollectionTagSeriesMetadata"); + b.ToTable("CollectionTagSeriesMetadata", (string)null); }); modelBuilder.Entity("GenreSeriesMetadata", b => @@ -1583,7 +1583,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("GenreSeriesMetadata"); + b.ToTable("GenreSeriesMetadata", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => @@ -1682,7 +1682,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("PersonSeriesMetadata"); + b.ToTable("PersonSeriesMetadata", (string)null); }); modelBuilder.Entity("SeriesMetadataTag", b => @@ -1697,7 +1697,7 @@ namespace API.Data.Migrations b.HasIndex("TagsId"); - b.ToTable("SeriesMetadataTag"); + b.ToTable("SeriesMetadataTag", (string)null); }); modelBuilder.Entity("API.Entities.AppUserBookmark", b => diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index db139b865..8a98b1dbe 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -53,6 +53,7 @@ public interface ILibraryRepository Task> GetAllCoverImagesAsync(); Task> GetLibraryTypesForIdsAsync(IEnumerable libraryIds); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); + Task GetAllowsScrobblingBySeriesId(int seriesId); } public class LibraryRepository : ILibraryRepository @@ -374,4 +375,11 @@ public class LibraryRepository : ILibraryRepository .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension)) .ToListAsync(); } + + public async Task GetAllowsScrobblingBySeriesId(int seriesId) + { + return await _context.Series.Where(s => s.Id == seriesId) + .Select(s => s.Library.AllowScrobbling) + .SingleOrDefaultAsync(); + } } diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index ccbdd133f..8bb845da3 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -128,15 +128,18 @@ public class LicenseService : ILicenseService /// Expected to be called at startup and on reoccurring basis public async Task ValidateLicenseStatus() { + var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); try { var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - if (string.IsNullOrEmpty(license.Value)) return; + if (string.IsNullOrEmpty(license.Value)) { + await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); + return; + } _logger.LogInformation("Validating Kavita+ License"); - var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); - await provider.FlushAsync(); + await provider.FlushAsync(); var isValid = await IsLicenseValid(license.Value); await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout); @@ -145,6 +148,7 @@ public class LicenseService : ILicenseService catch (Exception ex) { _logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins"); + await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30)); } } diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 075f535b0..7c9afaeee 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -104,7 +104,7 @@ public class ScrobblingService : IScrobblingService /// - /// + /// An automated job that will run against all user's tokens and validate if they are still active /// /// This service can validate without license check as the task which calls will be guarded /// @@ -115,6 +115,7 @@ public class ScrobblingService : IScrobblingService foreach (var user in users) { if (string.IsNullOrEmpty(user.AniListAccessToken) || !_tokenService.HasTokenExpired(user.AniListAccessToken)) continue; + _logger.LogInformation("User {UserName}'s AniList token has expired! They need to regenerate it for scrobbling to work", user.UserName); await _eventHub.SendMessageToAsync(MessageFactory.ScrobblingKeyExpired, MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), user.Id); } @@ -184,17 +185,13 @@ public class ScrobblingService : IScrobblingService public async Task ScrobbleReviewUpdate(int userId, int seriesId, string reviewTitle, string reviewBody) { if (!await _licenseService.HasActiveLicense()) return; - var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList); - if (await HasTokenExpired(token, ScrobbleProvider.AniList)) - { - throw new KavitaException(await _localizationService.Translate(userId, "unable-to-register-k+")); - } 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 library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); - if (library is not {AllowScrobbling: true}) return; - if (library.Type == LibraryType.Comic) return; + + _logger.LogInformation("Processing Scrobbling review event for {UserId} on {SeriesName}", userId, series.Name); + if (await CheckIfCanScrobble(userId, seriesId, series)) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, ScrobbleEventType.Review); @@ -229,17 +226,12 @@ public class ScrobblingService : IScrobblingService public async Task ScrobbleRatingUpdate(int userId, int seriesId, float rating) { if (!await _licenseService.HasActiveLicense()) return; - var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList); - if (await HasTokenExpired(token, ScrobbleProvider.AniList)) - { - throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired")); - } 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 library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); - if (library is not {AllowScrobbling: true}) return; - if (library.Type == LibraryType.Comic) return; + + _logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name); + if (await CheckIfCanScrobble(userId, seriesId, series)) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, ScrobbleEventType.ScoreUpdated); @@ -273,22 +265,12 @@ public class ScrobblingService : IScrobblingService public async Task ScrobbleReadingUpdate(int userId, int seriesId) { if (!await _licenseService.HasActiveLicense()) return; - var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList); - if (await HasTokenExpired(token, ScrobbleProvider.AniList)) - { - throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired")); - } var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) - { - _logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, userId); - return; - } - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); - if (library is not {AllowScrobbling: true}) return; - if (library.Type == LibraryType.Comic) return; + + _logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name); + if (await CheckIfCanScrobble(userId, seriesId, series)) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, ScrobbleEventType.ChapterRead); @@ -338,17 +320,12 @@ public class ScrobblingService : IScrobblingService public async Task ScrobbleWantToReadUpdate(int userId, int seriesId, bool onWantToRead) { if (!await _licenseService.HasActiveLicense()) return; - var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList); - if (await HasTokenExpired(token, ScrobbleProvider.AniList)) - { - throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired")); - } 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 library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); - if (library is not {AllowScrobbling: true}) return; - if (library.Type == LibraryType.Comic) return; + + _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); + if (await CheckIfCanScrobble(userId, seriesId, series)) return; var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id, onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead); @@ -369,6 +346,21 @@ public class ScrobblingService : IScrobblingService _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId); } + private async Task CheckIfCanScrobble(int userId, int seriesId, Series series) + { + if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) + { + _logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, + userId); + return true; + } + + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); + if (library is not {AllowScrobbling: true}) return true; + if (library.Type == LibraryType.Comic) return true; + return false; + } + private async Task GetRateLimit(string license, string aniListToken) { if (string.IsNullOrWhiteSpace(aniListToken)) return 0; diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index df27791fe..dc626481d 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -65,16 +65,19 @@ public class SeriesService : ISeriesService /// public static Chapter? GetFirstChapterForMetadata(Series series) { - var sortedVolumes = series.Volumes.OrderBy(v => v.Number, ChapterSortComparer.Default); + var sortedVolumes = series.Volumes + .Where(v => float.TryParse(v.Name, out var parsedValue) && parsedValue != 0.0f) + .OrderBy(v => float.TryParse(v.Name, out var parsedValue) ? parsedValue : float.MaxValue); var minVolumeNumber = sortedVolumes - .Where(v => v.Number != 0) - .MinBy(v => v.Number); + .MinBy(v => float.Parse(v.Name)); - var minChapter = series.Volumes - .SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default)) + + var allChapters = series.Volumes + .SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default)).ToList(); + var minChapter = allChapters .FirstOrDefault(); - if (minVolumeNumber != null && minChapter != null && float.Parse(minChapter.Number) > minVolumeNumber.Number) + if (minVolumeNumber != null && minChapter != null && float.TryParse(minChapter.Number, out var chapNum) && chapNum >= minVolumeNumber.Number) { return minVolumeNumber.Chapters.MinBy(c => float.Parse(c.Number), ChapterSortComparer.Default); } diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index d23cc86c0..2c8552749 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -27,7 +27,7 @@ public interface IStatisticService Task> GetTopUsers(int days); Task> GetReadingHistory(int userId); Task>> ReadCountByDay(int userId = 0, int days = 0); - IEnumerable> GetDayBreakdown(); + IEnumerable> GetDayBreakdown(int userId = 0); IEnumerable> GetPagesReadCountByYear(int userId = 0); IEnumerable> GetWordsReadCountByYear(int userId = 0); Task UpdateServerStatistics(); @@ -411,11 +411,12 @@ public class StatisticService : IStatisticService return results.OrderBy(r => r.Value); } - public IEnumerable> GetDayBreakdown() + public IEnumerable> GetDayBreakdown(int userId) { return _context.AppUserProgresses .AsSplitQuery() .AsNoTracking() + .WhereIf(userId > 0, p => p.AppUserId == userId) .GroupBy(p => p.LastModified.DayOfWeek) .OrderBy(g => g.Key) .Select(g => new StatCount{ Value = g.Key, Count = g.Count() }) diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 0875c3f52..6e844fbe3 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -229,14 +229,20 @@ public class LibraryWatcher : ILibraryWatcher public async Task ProcessChange(string filePath, bool isDirectoryChange = false) { var sw = Stopwatch.StartNew(); - _logger.LogDebug("[LibraryWatcher] Processing change of {FilePath}", filePath); + _logger.LogTrace("[LibraryWatcher] Processing change of {FilePath}", filePath); try { + // If the change occurs in a blacklisted folder path, then abort processing + if (Parser.Parser.HasBlacklistedFolderInPath(filePath)) + { + return; + } + // If not a directory change AND file is not an archive or book, ignore if (!isDirectoryChange && !(Parser.Parser.IsArchive(filePath) || Parser.Parser.IsBook(filePath))) { - _logger.LogDebug("[LibraryWatcher] Change from {FilePath} is not an archive or book, ignoring change", filePath); + _logger.LogTrace("[LibraryWatcher] Change from {FilePath} is not an archive or book, ignoring change", filePath); return; } @@ -248,10 +254,10 @@ public class LibraryWatcher : ILibraryWatcher .ToList(); var fullPath = GetFolder(filePath, libraryFolders); - _logger.LogDebug("Folder path: {FolderPath}", fullPath); + _logger.LogTrace("Folder path: {FolderPath}", fullPath); if (string.IsNullOrEmpty(fullPath)) { - _logger.LogDebug("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath); + _logger.LogTrace("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath); return; } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 55dba51b5..d6c43d8c2 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -146,7 +146,6 @@ public class ProcessSeries : IProcessSeries _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); // parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort) - // BUG: This check doesn't work for Books, as books usually have metadata on all files. (#2167) var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo); UpdateVolumes(series, parsedInfos, forceUpdate); diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 51fe3f025..ff87fa2c4 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -112,7 +112,7 @@ export class StatisticsService { return this.httpClient.get>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days); } - getDayBreakdown() { - return this.httpClient.get>>(this.baseUrl + 'stats/day-breakdown'); + getDayBreakdown( userId = 0) { + return this.httpClient.get>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId); } } diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts index 0556dc288..7eace3c2d 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts @@ -30,7 +30,6 @@ import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-ope import { NgIf, DecimalPipe } from '@angular/common'; import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {translate, TranslocoDirective} from "@ngneat/transloco"; -import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2"; diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index 83223fc13..de84ec9aa 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -5,7 +5,6 @@ import { ChangeDetectorRef, Component, ContentChild, - DestroyRef, ElementRef, EventEmitter, HostListener, @@ -13,7 +12,6 @@ import { Inject, Input, OnChanges, - OnDestroy, OnInit, Output, TemplateRef, @@ -70,12 +68,15 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { * Any actions to exist on the header for the parent collection (library, collection) */ @Input() actions: ActionItem[] = []; - @Input() trackByIdentity!: TrackByFunction; //(index: number, item: any) => string + /** + * A trackBy to help with rendering. This is required as without it there are issues when scrolling + */ + @Input({required: true}) trackByIdentity!: TrackByFunction; @Input() filterSettings!: FilterSettings; @Input() refresh!: EventEmitter; - @Input() jumpBarKeys: Array = []; // This is aprox 784 pixels tall, original keys + @Input() jumpBarKeys: Array = []; // This is approx 784 pixels tall, original keys jumpBarKeysToRender: Array = []; // What is rendered on screen @Output() itemClicked: EventEmitter = new EventEmitter(); @@ -115,7 +116,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { ngOnInit(): void { if (this.trackByIdentity === undefined) { - this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`; + this.trackByIdentity = (_: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`; } if (this.filterSettings === undefined) { diff --git a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts index 6316e2e1a..ed18ce213 100644 --- a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts +++ b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.ts @@ -25,7 +25,6 @@ import {CompactNumberPipe} from "../../pipe/compact-number.pipe"; import {AgeRatingPipe} from "../../pipe/age-rating.pipe"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component"; -import {FilterQueryParam} from "../../shared/_services/filter-utilities.service"; import {TranslocoModule} from "@ngneat/transloco"; import {TranslocoLocaleModule} from "@ngneat/transloco-locale"; import {FilterField} from "../../_models/metadata/v2/filter-field"; @@ -132,6 +131,4 @@ export class EntityInfoCardsComponent implements OnInit { } this.cdRef.markForCheck(); } - - protected readonly FilterQueryParam = FilterQueryParam; } diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.ts b/UI/Web/src/app/dashboard/_components/dashboard.component.ts index ede058a61..2affd8f55 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.ts @@ -3,7 +3,7 @@ import {Title} from '@angular/platform-browser'; import {Router, RouterLink} from '@angular/router'; import {Observable, of, ReplaySubject} from 'rxjs'; import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators'; -import {FilterQueryParam, FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; +import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; import {SeriesAddedEvent} from 'src/app/_models/events/series-added-event'; import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event'; import {Library} from 'src/app/_models/library'; @@ -169,23 +169,36 @@ export class DashboardComponent implements OnInit { handleSectionClick(sectionTitle: string) { if (sectionTitle.toLowerCase() === 'recently updated series') { const params: any = {}; - params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc - params[FilterQueryParam.Page] = 1; + params['page'] = 1; params['title'] = 'Recently Updated'; - this.router.navigate(['all-series'], {queryParams: params}); + const filter = this.filterUtilityService.createSeriesV2Filter(); + if (filter.sortOptions) { + filter.sortOptions.sortField = SortField.LastChapterAdded; + filter.sortOptions.isAscending = false; + } + this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params) } else if (sectionTitle.toLowerCase() === 'on deck') { const params: any = {}; - params[FilterQueryParam.ReadStatus] = 'true,false,false'; - params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc - params[FilterQueryParam.Page] = 1; + params['page'] = 1; params['title'] = 'On Deck'; - this.router.navigate(['all-series'], {queryParams: params}); + + const filter = this.filterUtilityService.createSeriesV2Filter(); + filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'}); + if (filter.sortOptions) { + filter.sortOptions.sortField = SortField.LastChapterAdded; + filter.sortOptions.isAscending = false; + } + this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params) }else if (sectionTitle.toLowerCase() === 'newly added series') { const params: any = {}; - params[FilterQueryParam.SortBy] = SortField.Created + ',false'; // sort by created, desc - params[FilterQueryParam.Page] = 1; + params['page'] = 1; params['title'] = 'Newly Added'; - this.router.navigate(['all-series'], {queryParams: params}); + const filter = this.filterUtilityService.createSeriesV2Filter(); + if (filter.sortOptions) { + filter.sortOptions.sortField = SortField.Created; + filter.sortOptions.isAscending = false; + } + this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params) } } diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index 2a268ef9b..835af8492 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -25,13 +25,13 @@
-
+
-
+
- -
- -
-
- -
-
+ +
+
+ +
+ + +
+ +
+
+ +
+
+ + diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index 6a06a9fc4..542a19a38 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -55,6 +55,7 @@ export class MetadataFilterComponent implements OnInit { @ContentChild('[ngbCollapse]') collapse!: NgbCollapse; private readonly destroyRef = inject(DestroyRef); + public readonly utilityService = inject(UtilityService); /** @@ -82,10 +83,7 @@ export class MetadataFilterComponent implements OnInit { private readonly cdRef = inject(ChangeDetectorRef); - constructor(private utilityService: UtilityService, - public toggleService: ToggleService, - private filterUtilityService: FilterUtilitiesService) { - } + constructor(public toggleService: ToggleService) {} ngOnInit(): void { if (this.filterSettings === undefined) { @@ -203,4 +201,5 @@ export class MetadataFilterComponent implements OnInit { this.toggleService.set(!this.filteringCollapsed); } + protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html index a3fa7fc8a..63d6ab4a5 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html @@ -79,7 +79,7 @@ -
+
{{item.title}}
@@ -97,7 +97,7 @@ -
+
diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts index 8a381886a..ac76fe609 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts @@ -1,40 +1,44 @@ -import { DOCUMENT, NgIf, NgOptimizedImage, AsyncPipe } from '@angular/common'; +import {AsyncPipe, DOCUMENT, NgIf, NgOptimizedImage} from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, DestroyRef, + Component, + DestroyRef, ElementRef, inject, Inject, OnInit, ViewChild } from '@angular/core'; -import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router'; -import { fromEvent } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs/operators'; -import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service'; -import { Chapter } from 'src/app/_models/chapter'; -import { CollectionTag } from 'src/app/_models/collection-tag'; -import { Library } from 'src/app/_models/library'; -import { MangaFile } from 'src/app/_models/manga-file'; -import { PersonRole } from 'src/app/_models/metadata/person'; -import { ReadingList } from 'src/app/_models/reading-list'; -import { SearchResult } from 'src/app/_models/search/search-result'; -import { SearchResultGroup } from 'src/app/_models/search/search-result-group'; -import { AccountService } from 'src/app/_services/account.service'; -import { ImageService } from 'src/app/_services/image.service'; -import { NavService } from 'src/app/_services/nav.service'; -import { ScrollService } from 'src/app/_services/scroll.service'; -import { SearchService } from 'src/app/_services/search.service'; +import {NavigationEnd, Router, RouterLink, RouterLinkActive} from '@angular/router'; +import {fromEvent} from 'rxjs'; +import {debounceTime, distinctUntilChanged, filter, tap} from 'rxjs/operators'; +import {Chapter} from 'src/app/_models/chapter'; +import {CollectionTag} from 'src/app/_models/collection-tag'; +import {Library} from 'src/app/_models/library'; +import {MangaFile} from 'src/app/_models/manga-file'; +import {PersonRole} from 'src/app/_models/metadata/person'; +import {ReadingList} from 'src/app/_models/reading-list'; +import {SearchResult} from 'src/app/_models/search/search-result'; +import {SearchResultGroup} from 'src/app/_models/search/search-result-group'; +import {AccountService} from 'src/app/_services/account.service'; +import {ImageService} from 'src/app/_services/image.service'; +import {NavService} from 'src/app/_services/nav.service'; +import {ScrollService} from 'src/app/_services/scroll.service'; +import {SearchService} from 'src/app/_services/search.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import { SentenceCasePipe } from '../../../pipe/sentence-case.pipe'; -import { PersonRolePipe } from '../../../pipe/person-role.pipe'; -import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap'; -import { EventsWidgetComponent } from '../events-widget/events-widget.component'; -import { SeriesFormatComponent } from '../../../shared/series-format/series-format.component'; -import { ImageComponent } from '../../../shared/image/image.component'; -import { GroupedTypeaheadComponent } from '../grouped-typeahead/grouped-typeahead.component'; +import {SentenceCasePipe} from '../../../pipe/sentence-case.pipe'; +import {PersonRolePipe} from '../../../pipe/person-role.pipe'; +import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap'; +import {EventsWidgetComponent} from '../events-widget/events-widget.component'; +import {SeriesFormatComponent} from '../../../shared/series-format/series-format.component'; +import {ImageComponent} from '../../../shared/image/image.component'; +import {GroupedTypeaheadComponent} from '../grouped-typeahead/grouped-typeahead.component'; import {TranslocoDirective} from "@ngneat/transloco"; +import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; +import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; +import {FilterField} from "../../../_models/metadata/v2/filter-field"; +import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; @Component({ selector: 'app-nav-header', @@ -58,10 +62,12 @@ export class NavHeaderComponent implements OnInit { backToTopNeeded = false; searchFocused: boolean = false; scrollElem: HTMLElement; + protected readonly FilterField = FilterField; constructor(public accountService: AccountService, private router: Router, public navService: NavService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document, - private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef) { + private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef, + private filterUtilityService: FilterUtilitiesService) { this.scrollElem = this.document.body; } @@ -69,12 +75,11 @@ export class NavHeaderComponent implements OnInit { this.scrollService.scrollContainer$.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), tap((scrollContainer) => { if (scrollContainer === 'body' || scrollContainer === undefined) { this.scrollElem = this.document.body; - fromEvent(this.document.body, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(this.document.body)); } else { const elem = scrollContainer as ElementRef; this.scrollElem = elem.nativeElement; - fromEvent(elem.nativeElement, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(elem.nativeElement)); } + fromEvent(this.scrollElem, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(this.scrollElem)); })).subscribe(); // Sometimes the top event emitter can be slow, so let's also check when a navigation occurs and recalculate @@ -125,49 +130,54 @@ export class NavHeaderComponent implements OnInit { }); } - goTo(queryParamName: string, filter: any) { + goTo(statement: FilterStatement) { let params: any = {}; - params[queryParamName] = filter; - params[FilterQueryParam.Page] = 1; + const filter = this.filterUtilityService.createSeriesV2Filter(); + filter.statements = [statement]; + params['page'] = 1; this.clearSearch(); - this.router.navigate(['all-series'], {queryParams: params}); + this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params); + } + + goToOther(field: FilterField, value: string) { + this.goTo({field, comparison: FilterComparison.Equal, value}); } goToPerson(role: PersonRole, filter: any) { this.clearSearch(); switch(role) { case PersonRole.Writer: - this.goTo(FilterQueryParam.Writers, filter); + this.goTo({field: FilterField.Writers, comparison: FilterComparison.Equal, value: filter}); break; case PersonRole.Artist: - this.goTo(FilterQueryParam.Artists, filter); + this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter}); // TODO: What is this supposed to be? break; case PersonRole.Character: - this.goTo(FilterQueryParam.Character, filter); + this.goTo({field: FilterField.Characters, comparison: FilterComparison.Equal, value: filter}); break; case PersonRole.Colorist: - this.goTo(FilterQueryParam.Colorist, filter); + this.goTo({field: FilterField.Colorist, comparison: FilterComparison.Equal, value: filter}); break; case PersonRole.Editor: - this.goTo(FilterQueryParam.Editor, filter); + this.goTo({field: FilterField.Editor, comparison: FilterComparison.Equal, value: filter}); break; case PersonRole.Inker: - this.goTo(FilterQueryParam.Inker, filter); + this.goTo({field: FilterField.Inker, comparison: FilterComparison.Equal, value: filter}); break; case PersonRole.CoverArtist: - this.goTo(FilterQueryParam.CoverArtists, filter); + this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter}); break; case PersonRole.Letterer: - this.goTo(FilterQueryParam.Letterer, filter); + this.goTo({field: FilterField.Letterer, comparison: FilterComparison.Equal, value: filter}); break; case PersonRole.Penciller: - this.goTo(FilterQueryParam.Penciller, filter); + this.goTo({field: FilterField.Penciller, comparison: FilterComparison.Equal, value: filter}); break; case PersonRole.Publisher: - this.goTo(FilterQueryParam.Publisher, filter); + this.goTo({field: FilterField.Publisher, comparison: FilterComparison.Equal, value: filter}); break; case PersonRole.Translator: - this.goTo(FilterQueryParam.Translator, filter); + this.goTo({field: FilterField.Translators, comparison: FilterComparison.Equal, value: filter}); break; } } @@ -232,4 +242,6 @@ export class NavHeaderComponent implements OnInit { hideSideNav() { this.navService.toggleSideNav(); } + + } diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html index e07ba4aff..cb1736a7b 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.html @@ -1,6 +1,6 @@
+ popoverTitle="Your Rating + Overall" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'"> {{userRating * 20}} @@ -23,7 +23,7 @@
- {{userRating * 20}}% diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.scss b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.scss index 0dc21384e..981b0b235 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.scss +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.scss @@ -19,6 +19,14 @@ } } +.lg-popover { + width: 320px; + + > .popover-body { + padding-top: 0px; + } +} + .rating-star { i { position: relative; diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts index e1fcefd62..1b4af06ea 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts @@ -18,6 +18,7 @@ import {LibraryType} from "../../../_models/library"; import {ProviderNamePipe} from "../../../pipe/provider-name.pipe"; import {NgxStarsModule} from "ngx-stars"; import {ThemeService} from "../../../_services/theme.service"; +import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service"; @Component({ selector: 'app-external-rating', @@ -37,6 +38,7 @@ export class ExternalRatingComponent implements OnInit { private readonly seriesService = inject(SeriesService); private readonly accountService = inject(AccountService); private readonly themeService = inject(ThemeService); + public readonly utilityService = inject(UtilityService); ratings: Array = []; isLoading: boolean = false; @@ -71,4 +73,6 @@ export class ExternalRatingComponent implements OnInit { this.cdRef.markForCheck(); }); } + + protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.ts b/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.ts index f464156b0..1225c8ee7 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.ts @@ -3,8 +3,7 @@ import {CommonModule} from '@angular/common'; import {A11yClickDirective} from "../../../shared/a11y-click.directive"; import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component"; import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component"; -import {FilterQueryParam, FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; -import {Router} from "@angular/router"; +import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {FilterField} from "../../../_models/metadata/v2/filter-field"; @@ -25,7 +24,6 @@ export class MetadataDetailComponent { @ContentChild('titleTemplate') titleTemplate!: TemplateRef; @ContentChild('itemTemplate') itemTemplate?: TemplateRef; - private readonly router = inject(Router); private readonly filterUtilitiesService = inject(FilterUtilitiesService); protected readonly TagBadgeCursor = TagBadgeCursor; diff --git a/UI/Web/src/app/shared/_services/filter-utilities.service.ts b/UI/Web/src/app/shared/_services/filter-utilities.service.ts index afe5bf4c5..d16caedf0 100644 --- a/UI/Web/src/app/shared/_services/filter-utilities.service.ts +++ b/UI/Web/src/app/shared/_services/filter-utilities.service.ts @@ -1,5 +1,5 @@ import {Injectable} from '@angular/core'; -import {ActivatedRouteSnapshot, Router} from '@angular/router'; +import {ActivatedRouteSnapshot, Params, Router} from '@angular/router'; import {Pagination} from 'src/app/_models/pagination'; import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter'; import {MetadataService} from "../../_services/metadata.service"; @@ -9,50 +9,17 @@ import {FilterCombination} from "../../_models/metadata/v2/filter-combination"; import {FilterField} from "../../_models/metadata/v2/filter-field"; import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; -/** - * Used to pass state between the filter and the url - */ -export enum FilterQueryParam { - Format = 'format', - Genres = 'genres', - AgeRating = 'ageRating', - PublicationStatus = 'publicationStatus', - Tags = 'tags', - Languages = 'languages', - CollectionTags = 'collectionTags', - Libraries = 'libraries', - Writers = 'writers', - Artists = 'artists', - Character = 'character', - Colorist = 'colorist', - CoverArtists = 'coverArtists', - Editor = 'editor', - Inker = 'inker', - Letterer = 'letterer', - Penciller = 'penciller', - Publisher = 'publisher', - Translator = 'translators', - ReadStatus = 'readStatus', - SortBy = 'sortBy', - Rating = 'rating', - Name = 'name', - /** - * This is a pagination control - */ - Page = 'page', - /** - * Special case for the UI. Does not trigger filtering - */ - None = 'none' -} +const sortOptionsKey = 'sortOptions='; +const statementsKey = 'stmts='; +const limitToKey = 'limitTo='; +const combinationKey = 'combination='; @Injectable({ providedIn: 'root' }) export class FilterUtilitiesService { - constructor(private metadataService: MetadataService, private router: Router) { - } + constructor(private metadataService: MetadataService, private router: Router) {} applyFilter(page: Array, filter: FilterField, comparison: FilterComparison, value: string) { const dto: SeriesFilterV2 = { @@ -61,8 +28,14 @@ export class FilterUtilitiesService { limitTo: 0 }; - console.log('applying filter: ', this.urlFromFilterV2(page.join('/') + '?', dto)) - this.router.navigateByUrl(this.urlFromFilterV2(page.join('/') + '?', dto)); + const url = this.urlFromFilterV2(page.join('/') + '?', dto); + return this.router.navigateByUrl(url); + } + + applyFilterWithParams(page: Array, filter: SeriesFilterV2, extraParams: Params) { + let url = this.urlFromFilterV2(page.join('/') + '?', filter); + url += Object.keys(extraParams).map(k => `&${k}=${extraParams[k]}`).join(''); + return this.router.navigateByUrl(url, extraParams); } /** @@ -84,7 +57,7 @@ export class FilterUtilitiesService { /** * Will fetch current page from route if present - * @param ActivatedRouteSnapshot to fetch page from. Must be from component else may get stale data + * @param snapshot to fetch page from. Must be from component else may get stale data * @param itemsPerPage If you want pagination, pass non-zero number * @returns A default pagination object */ @@ -107,10 +80,10 @@ export class FilterUtilitiesService { encodeSeriesFilter(filter: SeriesFilterV2) { const encodedStatements = this.encodeFilterStatements(filter.statements); - const encodedSortOptions = filter.sortOptions ? `sortOptions=${this.encodeSortOptions(filter.sortOptions)}` : ''; - const encodedLimitTo = `limitTo=${filter.limitTo}`; + const encodedSortOptions = filter.sortOptions ? `${sortOptionsKey}${this.encodeSortOptions(filter.sortOptions)}` : ''; + const encodedLimitTo = `${limitToKey}${filter.limitTo}`; - return `${this.encodeName(filter.name)}stmts=${encodedStatements}&${encodedSortOptions}&${encodedLimitTo}&combination=${filter.combination}`; + return `${this.encodeName(filter.name)}${encodedStatements}&${encodedSortOptions}&${encodedLimitTo}&${combinationKey}${filter.combination}`; } encodeName(name: string | undefined) { @@ -124,7 +97,8 @@ export class FilterUtilitiesService { } encodeFilterStatements(statements: Array) { - return encodeURIComponent(statements.map(statement => { + if (statements.length === 0) return ''; + return statementsKey + encodeURIComponent(statements.map(statement => { const encodedComparison = `comparison=${statement.comparison}`; const encodedField = `field=${statement.field}`; const encodedValue = `value=${encodeURIComponent(statement.value)}`; @@ -144,19 +118,23 @@ export class FilterUtilitiesService { } const fullUrl = window.location.href.split('?')[1]; - const stmtsStartIndex = fullUrl.indexOf('stmts='); - let endIndex = fullUrl.indexOf('&sortOptions='); + const stmtsStartIndex = fullUrl.indexOf(statementsKey); + let endIndex = fullUrl.indexOf('&' + sortOptionsKey); if (endIndex < 0) { - endIndex = fullUrl.indexOf('&limitTo='); + endIndex = fullUrl.indexOf('&' + limitToKey); } - if (stmtsStartIndex !== -1 && endIndex !== -1) { - const stmtsEncoded = fullUrl.substring(stmtsStartIndex + 6, endIndex); + if (stmtsStartIndex !== -1 || endIndex !== -1) { + // +1 is for the = + const stmtsEncoded = fullUrl.substring(stmtsStartIndex + statementsKey.length, endIndex); filter.statements = this.decodeFilterStatements(stmtsEncoded); } if (queryParams.sortOptions) { - const sortOptions = this.decodeSortOptions(queryParams.sortOptions); + const optionsStartIndex = fullUrl.indexOf('&' + sortOptionsKey); + const endIndex = fullUrl.indexOf('&' + limitToKey); + const sortOptionsEncoded = fullUrl.substring(optionsStartIndex + sortOptionsKey.length + 1, endIndex); + const sortOptions = this.decodeSortOptions(sortOptionsEncoded); if (sortOptions) { filter.sortOptions = sortOptions; } @@ -174,7 +152,7 @@ export class FilterUtilitiesService { } decodeSortOptions(encodedSortOptions: string): SortOptions | null { - const parts = encodedSortOptions.split('&'); + const parts = decodeURIComponent(encodedSortOptions).split('&'); const sortFieldPart = parts.find(part => part.startsWith('sortField=')); const isAscendingPart = parts.find(part => part.startsWith('isAscending=')); @@ -188,7 +166,7 @@ export class FilterUtilitiesService { } decodeFilterStatements(encodedStatements: string): FilterStatement[] { - const statementStrings = decodeURIComponent(encodedStatements).split(','); // I don't think this will wrk + const statementStrings = decodeURIComponent(encodedStatements).split(','); return statementStrings.map(statementString => { const parts = statementString.split('&'); if (parts === null || parts.length < 3) return null; diff --git a/UI/Web/src/app/statistics/_components/day-breakdown/day-breakdown.component.ts b/UI/Web/src/app/statistics/_components/day-breakdown/day-breakdown.component.ts index afd3b8ab3..2fc0e345f 100644 --- a/UI/Web/src/app/statistics/_components/day-breakdown/day-breakdown.component.ts +++ b/UI/Web/src/app/statistics/_components/day-breakdown/day-breakdown.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, DestroyRef, inject} from '@angular/core'; +import {ChangeDetectionStrategy, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; import {FormControl} from '@angular/forms'; import { BarChartModule } from '@swimlane/ngx-charts'; import {map, Observable} from 'rxjs'; @@ -18,8 +18,9 @@ import {TranslocoDirective} from "@ngneat/transloco"; standalone: true, imports: [BarChartModule, AsyncPipe, TranslocoDirective] }) -export class DayBreakdownComponent { +export class DayBreakdownComponent implements OnInit { + @Input() userId = 0; view: [number, number] = [0,0]; showLegend: boolean = true; @@ -27,9 +28,11 @@ export class DayBreakdownComponent { dayBreakdown$!: Observable>; private readonly destroyRef = inject(DestroyRef); - constructor(private statService: StatisticsService) { + constructor(private statService: StatisticsService) {} + + ngOnInit() { const dayOfWeekPipe = new DayOfWeekPipe(); - this.dayBreakdown$ = this.statService.getDayBreakdown().pipe( + this.dayBreakdown$ = this.statService.getDayBreakdown(this.userId).pipe( map((data: Array>) => { return data.map(d => { return {name: dayOfWeekPipe.transform(d.value), value: d.count}; diff --git a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts index 606b5a06d..e63430cd4 100644 --- a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts +++ b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts @@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject} fr import {Router} from '@angular/router'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {map, Observable, ReplaySubject, shareReplay} from 'rxjs'; -import {FilterQueryParam, FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; +import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {Series} from 'src/app/_models/series'; import {ImageService} from 'src/app/_services/image.service'; diff --git a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html index e8f3065ec..6a8cb9280 100644 --- a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html @@ -14,6 +14,12 @@
+
+
+ +
+
+
diff --git a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.ts b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.ts index 8f48141aa..23f2f10a3 100644 --- a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.ts +++ b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.ts @@ -13,6 +13,7 @@ import {StatListComponent} from '../stat-list/stat-list.component'; import {ReadingActivityComponent} from '../reading-activity/reading-activity.component'; import {UserStatsInfoCardsComponent} from '../user-stats-info-cards/user-stats-info-cards.component'; import {TranslocoModule} from "@ngneat/transloco"; +import {DayBreakdownComponent} from "../day-breakdown/day-breakdown.component"; @Component({ selector: 'app-user-stats', @@ -27,6 +28,7 @@ import {TranslocoModule} from "@ngneat/transloco"; StatListComponent, AsyncPipe, TranslocoModule, + DayBreakdownComponent, ], }) export class UserStatsComponent implements OnInit { diff --git a/openapi.json b/openapi.json index c14895509..154ecfe81 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.7.9" + "version": "0.7.7.10" }, "servers": [ { @@ -9855,6 +9855,17 @@ "tags": [ "Stats" ], + "parameters": [ + { + "name": "userId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + } + ], "responses": { "200": { "description": "Success",