From 9565fe7360d28acb436b4891ba92a628103ad5d5 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Wed, 19 Feb 2025 15:06:54 -0600 Subject: [PATCH] Polish 2 (#3555) Co-authored-by: Robbie Davis Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com> --- API.Tests/Services/CleanupServiceTests.cs | 69 +++- API.Tests/Services/ScannerServiceTests.cs | 69 ++++ .../Alternating Removal - Manga.json | 5 + API/API.csproj | 3 + API/Assets/anilist-no-image-placeholder.jpg | Bin 0 -> 22464 bytes API/Controllers/OPDSController.cs | 12 +- API/Controllers/SettingsController.cs | 3 +- API/Controllers/UploadController.cs | 11 +- API/DTOs/Update/UpdateNotificationDto.cs | 1 + .../Repositories/AppUserProgressRepository.cs | 6 + API/Data/Repositories/SeriesRepository.cs | 2 +- API/Extensions/ImageExtensions.cs | 122 ++++++ API/Helpers/PdfComicInfoExtractor.cs | 49 +-- API/Helpers/PdfMetadataExtractor.cs | 363 ++++++++---------- API/Program.cs | 2 +- API/Services/DirectoryService.cs | 7 + API/Services/ImageService.cs | 4 +- API/Services/Plus/ExternalMetadataService.cs | 16 +- API/Services/Tasks/CleanupService.cs | 65 ++++ API/Services/Tasks/Metadata/CoverDbService.cs | 47 ++- API/Services/Tasks/ScannerService.cs | 2 +- API/Services/Tasks/VersionUpdaterService.cs | 16 +- API/Services/TokenService.cs | 20 +- UI/Web/src/_card-item-common.scss | 6 +- .../_models/events/update-version-event.ts | 1 + UI/Web/src/app/_services/theme.service.ts | 5 + UI/Web/src/app/_services/version.service.ts | 2 +- .../match-series-modal.component.html | 5 +- .../match-series-modal.component.scss | 3 + .../match-series-modal.component.ts | 4 + .../match-series-result-item.component.html | 21 +- .../match-series-result-item.component.scss | 33 ++ .../match-series-result-item.component.ts | 1 + .../manage-metadata-settings.component.html | 4 +- .../manage-metadata-settings.component.ts | 9 + .../changelog-update-item.component.html | 1 + .../changelog/changelog.component.ts | 2 +- .../new-update-modal.component.html | 2 - .../edit-series-modal.component.html | 2 +- .../canvas-renderer.component.ts | 4 +- .../double-no-cover-renderer.component.ts | 4 +- .../double-renderer.component.ts | 4 +- .../double-reverse-renderer.component.ts | 4 +- .../infinite-scroller.component.ts | 4 +- .../manga-reader/manga-reader.component.ts | 6 +- .../single-renderer.component.ts | 4 +- ...der.service.ts => manga-reader.service.ts} | 5 +- .../setting-item/setting-item.component.html | 4 +- .../setting-item/setting-item.component.scss | 21 + .../setting-switch.component.html | 9 +- .../settings/settings.component.scss | 2 + .../manage-user-preferences.component.ts | 2 +- UI/Web/src/assets/langs/en.json | 3 +- UI/Web/src/styles.scss | 1 + UI/Web/src/theme/components/_sidenav.scss | 6 +- UI/Web/src/theme/utilities/_spinners.scss | 4 + openapi.json | 9 +- 57 files changed, 777 insertions(+), 314 deletions(-) create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json create mode 100644 API/Assets/anilist-no-image-placeholder.jpg create mode 100644 API/Extensions/ImageExtensions.cs rename UI/Web/src/app/manga-reader/_service/{managa-reader.service.ts => manga-reader.service.ts} (97%) create mode 100644 UI/Web/src/theme/utilities/_spinners.scss diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 2ebee8d1d..ef80ad850 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -521,6 +521,71 @@ public class CleanupServiceTests : AbstractDbTest } #endregion + #region ConsolidateProgress + + [Fact] + public async Task ConsolidateProgress_ShouldRemoveDuplicates() + { + await ResetDb(); + + var s = new SeriesBuilder("Test ConsolidateProgress_ShouldRemoveDuplicates") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPages(3) + .Build()) + .Build()) + .Build(); + + s.Library = new LibraryBuilder("Test Lib").Build(); + _context.Series.Add(s); + + var user = new AppUser() + { + UserName = "ConsolidateProgress_ShouldRemoveDuplicates", + }; + _context.AppUser.Add(user); + + await _unitOfWork.CommitAsync(); + + // Add 2 progress events + user.Progresses ??= []; + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 1, + }); + await _unitOfWork.CommitAsync(); + + // Add a duplicate with higher page number + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 3, + }); + await _unitOfWork.CommitAsync(); + + Assert.Equal(2, (await _unitOfWork.AppUserProgressRepository.GetAllProgress()).Count()); + + var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + + + await cleanupService.ConsolidateProgress(); + + var progress = await _unitOfWork.AppUserProgressRepository.GetAllProgress(); + + Assert.Single(progress); + Assert.True(progress.First().PagesRead == 3); + } + #endregion + #region EnsureChapterProgressIsCapped @@ -587,7 +652,7 @@ public class CleanupServiceTests : AbstractDbTest } #endregion - // #region CleanupBookmarks + #region CleanupBookmarks // // [Fact] // public async Task CleanupBookmarks_LeaveAllFiles() @@ -724,5 +789,5 @@ public class CleanupServiceTests : AbstractDbTest // Assert.Equal(1, ds.FileSystem.Directory.GetDirectories($"{BookmarkDirectory}1/1/").Length); // } // - // #endregion + #endregion } diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 5b6feeefa..a56794992 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -562,4 +562,73 @@ public class ScannerServiceTests : AbstractDbTest s2 = postLib.Series.First(s => s.Name == "Accel"); Assert.Single(s2.Volumes); } + + //[Fact] + public async Task ScanLibrary_AlternatingRemoval_IssueReplication() + { + // https://github.com/Kareadita/Kavita/issues/3476#issuecomment-2661635558 + // TODO: Come back to this, it's complicated + const string testcase = "Alternating Removal - Manga.json"; + + // Setup: Generate test library + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/ScannerService/ScanTests", + testcase.Replace(".json", string.Empty)); + + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + + // First Scan: Everything should be added + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Second Scan: Remove Root 2, expect Accel to be removed + library.Folders = [new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.DoesNotContain(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Third Scan: Re-add Root 2, Accel should come back + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Accel should be back + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Fourth Scan: Run again to check stability (should not remove Accel) + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + } + } diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json new file mode 100644 index 000000000..791dcdc44 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] diff --git a/API/API.csproj b/API/API.csproj index 2e1607c54..f8e1833ca 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -197,6 +197,9 @@ Always + + Always + diff --git a/API/Assets/anilist-no-image-placeholder.jpg b/API/Assets/anilist-no-image-placeholder.jpg new file mode 100644 index 0000000000000000000000000000000000000000..54c1066b610185571629669ac032b282f960b7a1 GIT binary patch literal 22464 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 Spn7JUKWB7X^4B{~_x}$j(05t@ literal 0 HcmV?d00001 diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 152e65495..4be2b8dc1 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -1370,7 +1370,9 @@ public class OpdsController : BaseApiController using var sm = new StringWriter(); _xmlSerializer.Serialize(sm, feed); - return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + var ret = sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + + return ret; } // Recursively sanitize all string properties in the object @@ -1381,6 +1383,10 @@ public class OpdsController : BaseApiController var properties = obj.GetType().GetProperties(); foreach (var property in properties) { + // Skip properties that require an index (e.g., indexed collections) + if (property.GetIndexParameters().Length > 0) + continue; + if (property.PropertyType == typeof(string) && property.CanWrite) { var value = (string?)property.GetValue(obj); @@ -1391,7 +1397,9 @@ public class OpdsController : BaseApiController } else if (property.PropertyType.IsClass) // Handle nested objects { - SanitizeFeed(property.GetValue(obj)); + var nestedObject = property.GetValue(obj); + if (nestedObject != null) + SanitizeFeed(nestedObject); } } } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 3f12a059d..ff92964ff 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -567,14 +567,15 @@ public class SettingsController : BaseApiController existingMetadataSetting.EnableStartDate = dto.EnableStartDate; existingMetadataSetting.EnableGenres = dto.EnableGenres; existingMetadataSetting.EnableTags = dto.EnableTags; - existingMetadataSetting.PersonRoles = dto.PersonRoles; existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming; + existingMetadataSetting.EnableCoverImage = dto.EnableCoverImage; existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? []; existingMetadataSetting.Blacklist = dto.Blacklist.Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; existingMetadataSetting.Whitelist = dto.Whitelist.Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; existingMetadataSetting.Overrides = dto.Overrides.ToList() ?? []; + existingMetadataSetting.PersonRoles = dto.PersonRoles ?? []; // Handle Field Mappings if (dto.FieldMappings != null) diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index bea9771ce..4b935a1bf 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -110,13 +110,10 @@ public class UploadController : BaseApiController lockState = uploadFileDto.LockCover; } - if (!string.IsNullOrEmpty(filePath)) - { - series.CoverImage = filePath; - series.CoverImageLocked = lockState; - _imageService.UpdateColorScape(series); - _unitOfWork.SeriesRepository.Update(series); - } + series.CoverImage = filePath; + series.CoverImageLocked = lockState; + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); if (_unitOfWork.HasChanges()) { diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 8fb5146d5..535e1f896 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -62,6 +62,7 @@ public class UpdateNotificationDto public IList Theme { get; set; } public IList Developer { get; set; } public IList Api { get; set; } + public IList FeatureRequests { get; set; } /// /// The part above the changelog part /// diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 3b065f2e0..388ca5b7e 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -19,6 +19,7 @@ namespace API.Data.Repositories; public interface IAppUserProgressRepository { void Update(AppUserProgress userProgress); + void Remove(AppUserProgress userProgress); Task CleanupAbandonedChapters(); Task UserHasProgress(LibraryType libraryType, int userId); Task GetUserProgressAsync(int chapterId, int userId); @@ -57,6 +58,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository _context.Entry(userProgress).State = EntityState.Modified; } + public void Remove(AppUserProgress userProgress) + { + _context.Remove(userProgress); + } + /// /// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well. /// diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 1eb613ea4..d80d479f4 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1848,7 +1848,7 @@ public class SeriesRepository : ISeriesRepository .ToList(); // Prefer the first match or handle duplicates by choosing the last one - if (matchingSeries.Any()) + if (matchingSeries.Count != 0) { ids.Add(matchingSeries.Last().Id); } diff --git a/API/Extensions/ImageExtensions.cs b/API/Extensions/ImageExtensions.cs new file mode 100644 index 000000000..720f572a9 --- /dev/null +++ b/API/Extensions/ImageExtensions.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using NetVips; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Image = NetVips.Image; + +namespace API.Extensions; + +public static class ImageExtensions +{ + public static int GetResolution(this Image image) + { + return image.Width * image.Height; + } + + /// + /// Smaller is better + /// + /// + /// + /// + public static float GetMeanSquaredError(this Image img1, Image img2) + { + if (img1.Width != img2.Width || img1.Height != img2.Height) + { + img2.Mutate(x => x.Resize(img1.Width, img1.Height)); + } + + double totalDiff = 0; + for (var y = 0; y < img1.Height; y++) + { + for (var x = 0; x < img1.Width; x++) + { + var pixel1 = img1[x, y]; + var pixel2 = img2[x, y]; + + var diff = Math.Pow(pixel1.R - pixel2.R, 2) + + Math.Pow(pixel1.G - pixel2.G, 2) + + Math.Pow(pixel1.B - pixel2.B, 2); + totalDiff += diff; + } + } + + return (float)(totalDiff / (img1.Width * img1.Height)); + } + + public static float GetSimilarity(this string imagePath1, string imagePath2) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Calculate similarity score + return CalculateSimilarity(imagePath1, imagePath2); + } + + /// + /// Determines which image is "better" based on similarity and resolution. + /// + /// Path to first image + /// Path to second image + /// Minimum similarity to consider images similar + /// The path of the better image + public static string GetBetterImage(this string imagePath1, string imagePath2, float similarityThreshold = 0.7f) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Calculate similarity score + var similarity = CalculateSimilarity(imagePath1, imagePath2); + + using var img1 = Image.NewFromFile(imagePath1, access: Enums.Access.Sequential); + using var img2 = Image.NewFromFile(imagePath2, access: Enums.Access.Sequential); + + var resolution1 = img1.Width * img1.Height; + var resolution2 = img2.Width * img2.Height; + + // If images are similar, choose the one with higher resolution + if (similarity >= similarityThreshold) + { + return resolution1 >= resolution2 ? imagePath1 : imagePath2; + } + + // If images are not similar, allow the new image + return imagePath2; + } + + /// + /// Calculate a similarity score (0-1f) based on resolution difference and MSE. + /// + /// + /// + /// + private static float CalculateSimilarity(string imagePath1, string imagePath2) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + return -1; + } + + using var img1 = Image.NewFromFile(imagePath1, access: Enums.Access.Sequential); + using var img2 = Image.NewFromFile(imagePath2, access: Enums.Access.Sequential); + + var res1 = img1.Width * img1.Height; + var res2 = img2.Width * img2.Height; + var resolutionDiff = Math.Abs(res1 - res2) / (float)Math.Max(res1, res2); + + using var imgSharp1 = SixLabors.ImageSharp.Image.Load(imagePath1); + using var imgSharp2 = SixLabors.ImageSharp.Image.Load(imagePath2); + + var mse = imgSharp1.GetMeanSquaredError(imgSharp2); + var normalizedMse = 1f - Math.Min(1f, mse / 65025f); // Normalize based on max color diff + + // Final similarity score (weighted) + return Math.Max(0f, 1f - (resolutionDiff * 0.5f) - (1f - normalizedMse) * 0.5f); + } +} diff --git a/API/Helpers/PdfComicInfoExtractor.cs b/API/Helpers/PdfComicInfoExtractor.cs index f01a25604..aaa93428f 100644 --- a/API/Helpers/PdfComicInfoExtractor.cs +++ b/API/Helpers/PdfComicInfoExtractor.cs @@ -1,16 +1,11 @@ -/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure. - -// Contributed by https://github.com/microtherion - -// All references to the "PDF Spec" (section numbers, etc) refer to the -// PDF 1.7 Specification a.k.a. PDF32000-1:2008 -// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf - +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ using System; -using System.Xml; -using System.Text; -using System.IO; -using System.Diagnostics; using API.Data.Metadata; using API.Entities.Enums; using API.Services; @@ -18,6 +13,7 @@ using API.Services.Tasks.Scanner.Parser; using Microsoft.Extensions.Logging; using Nager.ArticleNumber; using System.Collections.Generic; +using System.Globalization; namespace API.Helpers; #nullable enable @@ -27,6 +23,9 @@ public interface IPdfComicInfoExtractor ComicInfo? GetComicInfo(string filePath); } +/// +/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure. +/// public class PdfComicInfoExtractor : IPdfComicInfoExtractor { private readonly ILogger _logger; @@ -44,7 +43,7 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor _mediaErrorService = mediaErrorService; } - private float? GetFloatFromText(string? text) + private static float? GetFloatFromText(string? text) { if (string.IsNullOrEmpty(text)) return null; @@ -78,9 +77,9 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor return null; } - private string? MaybeGetMetadata(Dictionary metadata, string key) + private static string? MaybeGetMetadata(Dictionary metadata, string key) { - return metadata.ContainsKey(key) ? metadata[key] : null; + return metadata.TryGetValue(key, out var value) ? value : null; } private ComicInfo? GetComicInfoFromMetadata(Dictionary metadata, string filePath) @@ -100,6 +99,7 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor info.Publisher = MaybeGetMetadata(metadata, "Publisher") ?? string.Empty; info.Writer = MaybeGetMetadata(metadata, "Author") ?? string.Empty; info.Title = MaybeGetMetadata(metadata, "Title") ?? string.Empty; + info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty; info.Genre = MaybeGetMetadata(metadata, "Subject") ?? string.Empty; info.LanguageISO = BookService.ValidateLanguage(MaybeGetMetadata(metadata, "Language")); info.Isbn = MaybeGetMetadata(metadata, "ISBN") ?? string.Empty; @@ -111,10 +111,9 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor } info.UserRating = GetFloatFromText(MaybeGetMetadata(metadata, "UserRating")) ?? 0.0f; - info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty; - info.Series = MaybeGetMetadata(metadata, "Series") ?? info.TitleSort; + info.Series = MaybeGetMetadata(metadata, "Series") ?? info.Title; info.SeriesSort = info.Series; - info.Volume = (GetFloatFromText(MaybeGetMetadata(metadata, "Volume")) ?? 0.0f).ToString(); + info.Volume = MaybeGetMetadata(metadata, "Volume") ?? string.Empty; // If this is a single book and not a collection, set publication status to Completed if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) @@ -122,18 +121,6 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor info.Count = 1; } - // Removed as probably unneeded per discussion in https://github.com/Kareadita/Kavita/pull/3108#discussion_r1956747782 - // - // var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga) - // .Equals(Parser.LooseLeafVolume); - - // if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) - // { - // // This is likely a light novel for which we can set series from parsed title - // info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga); - // info.Volume = Parser.ParseVolume(info.Title, LibraryType.Manga); - // } - ComicInfo.CleanComicInfo(info); return info; @@ -156,4 +143,4 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor return null; } -} \ No newline at end of file +} diff --git a/API/Helpers/PdfMetadataExtractor.cs b/API/Helpers/PdfMetadataExtractor.cs index 5ef20516c..44327672b 100644 --- a/API/Helpers/PdfMetadataExtractor.cs +++ b/API/Helpers/PdfMetadataExtractor.cs @@ -1,21 +1,14 @@ -/// Parse PDF file and try to extract as much metadata as possible. -/// Supports both text based XRef tables and compressed XRef streams (Deflate only). -/// Supports both UTF-16 and PDFDocEncoding for strings. -/// Lacks support for many PDF configurations that are theoretically possible, but should handle most common cases. - -// Contributed by https://github.com/microtherion - -// All references to the "PDF Spec" (section numbers, etc) refer to the -// PDF 1.7 Specification a.k.a. PDF32000-1:2008 -// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO.Compression; -using System.Reflection.Metadata.Ecma335; -using System.Runtime.InteropServices; -using System.Security.Principal; using System.Text; using System.Xml; using System.IO; @@ -25,6 +18,12 @@ using API.Services; namespace API.Helpers; #nullable enable +/// +/// Parse PDF file and try to extract as much metadata as possible. +/// Supports both text based XRef tables and compressed XRef streams (Deflate only). +/// Supports both UTF-16 and PDFDocEncoding for strings. +/// Lacks support for many PDF configurations that are theoretically possible, but should handle most common cases. +/// public class PdfMetadataExtractorException : Exception { public PdfMetadataExtractorException() @@ -56,19 +55,21 @@ class PdfStringBuilder // PDFDocEncoding defined in PDF Spec D.1 - private readonly char[] _pdfDocMappingLow = new char[] { - '\u02D8', '\u02C7', '\u02C6', '\u02D9', '\u02DD', '\u02DB', '\u02DA', '\u02DC', - }; + private readonly char[] _pdfDocMappingLow = + [ + '\u02D8', '\u02C7', '\u02C6', '\u02D9', '\u02DD', '\u02DB', '\u02DA', '\u02DC' + ]; - private readonly char[] _pdfDocMappingHigh = new char[] { + private readonly char[] _pdfDocMappingHigh = + [ '\u2022', '\u2020', '\u2021', '\u2026', '\u2014', '\u2013', '\u0192', '\u2044', '\u2039', '\u203A', '\u2212', '\u2030', '\u201E', '\u201C', '\u201D', '\u2018', '\u2019', '\u201A', '\u2122', '\uFB01', '\uFB02', '\u0141', '\u0152', '\u0160', '\u0178', '\u017D', '\u0131', '\u0142', '\u0153', '\u0161', '\u017E', ' ', - '\u20AC', - }; + '\u20AC' + ]; - public void AppendPdfDocByte(byte b) + private void AppendPdfDocByte(byte b) { if (b >= 0x18 && b < 0x20) { @@ -148,8 +149,13 @@ class PdfStringBuilder } } -class PdfLexer(Stream stream) +internal class PdfLexer(Stream stream) { + private const int BufferSize = 1024; + private readonly byte[] _buffer = new byte[BufferSize]; + private int _pos = 0; + private int _valid = 0; + public enum TokenType { None, @@ -171,16 +177,10 @@ class PdfLexer(Stream stream) Newline, } - public struct Token + public struct Token(TokenType type, object value) { - public TokenType type; - public object value; - - public Token(TokenType type, object value) - { - this.type = type; - this.value = value; - } + public TokenType Type = type; + public object Value = value; } public Token NextToken(bool reportNewlines = false) @@ -273,7 +273,7 @@ class PdfLexer(Stream stream) { while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { case ' ': @@ -303,7 +303,7 @@ class PdfLexer(Stream stream) // Look for the startxref element as per PDF Spec 7.5.5 while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { @@ -345,13 +345,13 @@ class PdfLexer(Stream stream) var token = NextToken(true); - if (token.type == TokenType.Keyword && (string)token.value == "startxref") + if (token.Type == TokenType.Keyword && (string)token.Value == "startxref") { token = NextToken(); - if (token.type == TokenType.Int) + if (token.Type == TokenType.Int) { - return (long)token.value; + return (long)token.Value; } else { @@ -382,8 +382,8 @@ class PdfLexer(Stream stream) if (obj == 0) { - obj = Convert.ToInt64(System.Text.Encoding.ASCII.GetString(_buffer, _pos, 10)); - generation = Convert.ToInt32(System.Text.Encoding.ASCII.GetString(_buffer, _pos + 11, 5)); + obj = Convert.ToInt64(Encoding.ASCII.GetString(_buffer, _pos, 10)); + generation = Convert.ToInt32(Encoding.ASCII.GetString(_buffer, _pos + 11, 5)); inUse = _buffer[_pos + 17] == 'n'; } @@ -404,7 +404,7 @@ class PdfLexer(Stream stream) if (_pos < _valid) { - int buffered = Math.Min(_valid - _pos, length); + var buffered = Math.Min(_valid - _pos, length); rawData.Write(_buffer, _pos, buffered); length -= buffered; _pos += buffered; @@ -412,8 +412,8 @@ class PdfLexer(Stream stream) while (length > 0) { - int buffered = Math.Min(length, _bufferSize); - stream.Read(_buffer, 0, buffered); + var buffered = Math.Min(length, BufferSize); + stream.ReadExactly(_buffer, 0, buffered); rawData.Write(_buffer, 0, buffered); _pos = 0; _valid = 0; @@ -432,17 +432,12 @@ class PdfLexer(Stream stream) } } - private const int _bufferSize = 1024; - private readonly byte[] _buffer = new byte[_bufferSize]; - private int _pos = 0; - private int _valid = 0; - private byte NextByte() { if (_pos >= _valid) { _pos = 0; - _valid = stream.Read(_buffer, 0, _bufferSize); + _valid = stream.Read(_buffer, 0, BufferSize); if (_valid <= 0) { @@ -478,7 +473,7 @@ class PdfLexer(Stream stream) Buffer.BlockCopy(_buffer, _pos, _buffer, 0, _valid - _pos); _valid -= _pos; _pos = 0; - _valid += stream.Read(_buffer, _valid, _bufferSize - _valid); + _valid += stream.Read(_buffer, _valid, BufferSize - _valid); } } @@ -486,7 +481,7 @@ class PdfLexer(Stream stream) { while (true) { - byte b = NextByte(); + var b = NextByte(); if (b == '\n') { @@ -507,14 +502,14 @@ class PdfLexer(Stream stream) private Token ScanNumber() { StringBuilder sb = new(); - bool hasDot = LastByte() == '.'; - bool followedBySpace = false; + var hasDot = LastByte() == '.'; + var followedBySpace = false; sb.Append((char)LastByte()); while (true) { - byte b = NextByte(); + var b = NextByte(); if (b == '.' || b >= '0' && b <= '9') { @@ -533,17 +528,19 @@ class PdfLexer(Stream stream) break; } } + if (hasDot) { return new Token(TokenType.Double, double.Parse(sb.ToString())); } + if (followedBySpace) { // Look ahead to see if it's an object reference (PDF Spec 7.3.10) WantLookahead(32); var savedPos = _pos; - byte b = NextByte(); + var b = NextByte(); while (b == ' ' || b == '\t') { @@ -578,32 +575,25 @@ class PdfLexer(Stream stream) return new Token(TokenType.Int, long.Parse(sb.ToString())); } - private int HexDigit(byte b) + private static int HexDigit(byte b) { - switch ((char)b) + return (char) b switch { - case >= '0' and <= '9': - return b - (byte)'0'; - - case >= 'a' and <= 'f': - return b - (byte)'a' + 10; - - case >= 'A' and <= 'F': - return b - (byte)'A' + 10; - - default: - throw new PdfMetadataExtractorException("Invalid hex digit, got {b}"); - } + >= '0' and <= '9' => b - (byte) '0', + >= 'a' and <= 'f' => b - (byte) 'a' + 10, + >= 'A' and <= 'F' => b - (byte) 'A' + 10, + _ => throw new PdfMetadataExtractorException("Invalid hex digit, got {b}") + }; } private Token ScanName() { // PDF Spec 7.3.5 - StringBuilder sb = new StringBuilder(); + var sb = new StringBuilder(); while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { case '(': @@ -628,8 +618,8 @@ class PdfLexer(Stream stream) return new Token(TokenType.Name, sb.ToString()); case '#': - byte b1 = NextByte(); - byte b2 = NextByte(); + var b1 = NextByte(); + var b2 = NextByte(); b = (byte)((HexDigit(b1) << 4) | HexDigit(b2)); goto default; @@ -646,11 +636,11 @@ class PdfLexer(Stream stream) // PDF Spec 7.3.4.2 PdfStringBuilder sb = new(); - int parenLevel = 1; + var parenLevel = 1; while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { @@ -698,9 +688,9 @@ class PdfLexer(Stream stream) break; case >= '0' and <= '7': - byte b1 = b; - byte b2 = NextByte(); - byte b3 = NextByte(); + var b1 = b; + var b2 = NextByte(); + var b3 = NextByte(); if (b2 < '0' || b2 > '7' || b3 < '0' || b3 > '7') { @@ -728,12 +718,12 @@ class PdfLexer(Stream stream) while (true) { - byte b = NextByte(); + var b = NextByte(); switch ((char)b) { case (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'): - byte b1 = NextByte(); + var b1 = NextByte(); if (b1 == '>') { PutBack(); @@ -760,7 +750,7 @@ class PdfLexer(Stream stream) while (true) { - byte b = NextByte(); + var b = NextByte(); if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')) { sb.Append((char)b); @@ -796,38 +786,25 @@ class PdfLexer(Stream stream) } } -class PdfMetadataExtractor : IPdfMetadataExtractor +internal class PdfMetadataExtractor : IPdfMetadataExtractor { private readonly ILogger _logger; private readonly PdfLexer _lexer; private readonly FileStream _stream; private long[] _objectOffsets = new long[0]; - private readonly Dictionary _metadata = new(); + private readonly Dictionary _metadata = []; + private readonly Stack _metadataRef = new(); - private struct MetadataRef + private struct MetadataRef(long root, long info) { - public long root; - public long info; - - public MetadataRef(long root, long info) - { - this.root = root; - this.info = info; - } + public long Root = root; + public long Info = info; } - private readonly Stack metadataRef = new(); - - private struct XRefSection + private struct XRefSection(long first, long count) { - public long first; - public long count; - - public XRefSection(long first, long count) - { - this.first = first; - this.count = count; - } + public readonly long First = first; + public readonly long Count = count; } public PdfMetadataExtractor(ILogger logger, string filename) @@ -887,7 +864,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.Keyword || (string)token.value != "xref") + if (token.Type != PdfLexer.TokenType.Keyword || (string)token.Value != "xref") { throw new PdfMetadataExtractorException("Expected xref keyword"); } @@ -896,17 +873,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.Int) + if (token.Type == PdfLexer.TokenType.Int) { - var startObj = (long)token.value; + var startObj = (long)token.Value; token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.Int) + if (token.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected number of objects in xref subsection"); } - var numObj = (long)token.value; + var numObj = (long)token.Value; if (_objectOffsets.Length < startObj + numObj) { @@ -927,7 +904,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor } } } - else if (token.type == PdfLexer.TokenType.Keyword && (string)token.value == "trailer") + else if (token.Type == PdfLexer.TokenType.Keyword && (string)token.Value == "trailer") { break; } @@ -946,7 +923,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected obj keyword"); } @@ -967,7 +944,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor switch (key) { case "Type": - if (value.type != PdfLexer.TokenType.Name || (string)value.value != "XRef") + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XRef") { throw new PdfMetadataExtractorException("Expected /Type to be /XRef"); } @@ -975,37 +952,37 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Length": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer after /Length"); } - length = (long)value.value; + length = (long)value.Value; return true; case "Size": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer after /Size"); } - size = (long)value.value; + size = (long)value.Value; return true; case "Prev": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected offset after /Prev"); } - prev = (long)value.value; + prev = (long)value.Value; return true; case "Index": - if (value.type != PdfLexer.TokenType.ArrayStart) + if (value.Type != PdfLexer.TokenType.ArrayStart) { throw new PdfMetadataExtractorException("Expected array after /Index"); } @@ -1014,31 +991,31 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.ArrayEnd) + if (token.Type == PdfLexer.TokenType.ArrayEnd) { break; } - else if (token.type != PdfLexer.TokenType.Int) + else if (token.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer in /Index array"); } - var first = (long)token.value; + var first = (long)token.Value; token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.Int) + if (token.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer pair in /Index array"); } - var count = (long)token.value; + var count = (long)token.Value; sections.Enqueue(new XRefSection(first, count)); } return true; case "W": - if (value.type != PdfLexer.TokenType.ArrayStart) + if (value.Type != PdfLexer.TokenType.ArrayStart) { throw new PdfMetadataExtractorException("Expected array after /W"); } @@ -1049,17 +1026,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.Int) + if (token.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer in /W array"); } - widths[i] = (long)token.value; + widths[i] = (long)token.Value; } token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ArrayEnd) + if (token.Type != PdfLexer.TokenType.ArrayEnd) { throw new PdfMetadataExtractorException("Unclosed array after /W"); } @@ -1071,12 +1048,12 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Filter": - if (value.type != PdfLexer.TokenType.Name) + if (value.Type != PdfLexer.TokenType.Name) { throw new PdfMetadataExtractorException("Expected name after /Filter"); } - if ((string)value.value != "FlateDecode") + if ((string)value.Value != "FlateDecode") { throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); } @@ -1086,22 +1063,22 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Root": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object reference after /Root"); } - meta.root = (long)value.value; + meta.Root = (long)value.Value; return true; case "Info": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object reference after /Info"); } - meta.info = (long)value.value; + meta.Info = (long)value.Value; return true; @@ -1112,7 +1089,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.StreamStart) + if (token.Type != PdfLexer.TokenType.StreamStart) { throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); } @@ -1133,7 +1110,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor Array.Resize(ref _objectOffsets, (int)size); } - for (var i = section.first; i < section.first + section.count; ++i) + for (var i = section.First; i < section.First + section.Count; ++i) { long type = 0; long offset = 0; @@ -1146,17 +1123,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor for (var j = 0; j < typeWidth; ++j) { - type = (type << 8) | (UInt16)stream.ReadByte(); + type = (type << 8) | (ushort)stream.ReadByte(); } for (var j = 0; j < offsetWidth; ++j) { - offset = (offset << 8) | (UInt16)stream.ReadByte(); + offset = (offset << 8) | (ushort)stream.ReadByte(); } for (var j = 0; j < generationWidth; ++j) { - generation = (generation << 8) | (UInt16)stream.ReadByte(); + generation = (generation << 8) | (ushort)stream.ReadByte(); } if (type == 1 && _objectOffsets[i] == 0) @@ -1176,22 +1153,22 @@ class PdfMetadataExtractor : IPdfMetadataExtractor private void PushMetadataRef(MetadataRef meta) { - if (metadataRef.Count > 0) + if (_metadataRef.Count > 0) { - if (meta.root == metadataRef.Peek().root) + if (meta.Root == _metadataRef.Peek().Root) { - meta.root = -1; + meta.Root = -1; } - if (meta.info == metadataRef.Peek().info) + if (meta.Info == _metadataRef.Peek().Info) { - meta.info = -1; + meta.Info = -1; } } - if (meta.root != -1 || meta.info != -1) + if (meta.Root != -1 || meta.Info != -1) { - metadataRef.Push(meta); + _metadataRef.Push(meta); } } @@ -1209,40 +1186,40 @@ class PdfMetadataExtractor : IPdfMetadataExtractor switch (key) { case "Root": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object reference after /Root"); } - meta.root = (long)value.value; + meta.Root = (long)value.Value; return true; case "Prev": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected offset after /Prev"); } - prev = (long)value.value; + prev = (long)value.Value; return true; case "Info": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object reference after /Info"); } - meta.info = (long)value.value; + meta.Info = (long)value.Value; return true; case "XRefStm": // Prefer encoded xref stream over xref table - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected offset after /XRefStm"); } - xrefStm = (long)value.value; + xrefStm = (long)value.Value; return true; @@ -1272,14 +1249,14 @@ class PdfMetadataExtractor : IPdfMetadataExtractor // We read potential metadata sources in backwards historical order, so // we can overwrite to our heart's content - while (metadataRef.Count > 0) + while (_metadataRef.Count > 0) { - var meta = metadataRef.Pop(); + var meta = _metadataRef.Pop(); - _logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.root, meta.info); + //_logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.root, meta.info); - ReadMetadataFromInfo(meta.info); - ReadMetadataFromXML(MetadataObjInObjectCatalog(meta.root)); + ReadMetadataFromInfo(meta.Info); + ReadMetadataFromXml(MetadataObjInObjectCatalog(meta.Root)); } } @@ -1298,12 +1275,12 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected object header"); } - Dictionary indirectObjects = new(); + Dictionary indirectObjects = []; ParseDictionary(delegate(string key, PdfLexer.Token value) { @@ -1317,16 +1294,16 @@ class PdfMetadataExtractor : IPdfMetadataExtractor case "Producer": case "CreationDate": case "ModDate": - if (value.type == PdfLexer.TokenType.ObjectRef) { - indirectObjects[key] = (long)value.value; + if (value.Type == PdfLexer.TokenType.ObjectRef) { + indirectObjects[key] = (long)value.Value; } - else if (value.type != PdfLexer.TokenType.String) + else if (value.Type != PdfLexer.TokenType.String) { throw new PdfMetadataExtractorException("Expected string value"); } else { - _metadata[key] = (string)value.value; + _metadata[key] = (string)value.Value; } return true; @@ -1343,17 +1320,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) { + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected object here"); } token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.String) { + if (token.Type != PdfLexer.TokenType.String) { throw new PdfMetadataExtractorException("Expected string"); } - _metadata[key] = (string)token.value; + _metadata[key] = (string) token.Value; } } @@ -1371,7 +1348,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected object header"); } @@ -1382,12 +1359,12 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { switch (key) { case "Metadata": - if (value.type != PdfLexer.TokenType.ObjectRef) + if (value.Type != PdfLexer.TokenType.ObjectRef) { throw new PdfMetadataExtractorException("Expected object number after /Metadata"); } - meta = (long)value.value; + meta = (long)value.Value; return true; @@ -1403,13 +1380,13 @@ class PdfMetadataExtractor : IPdfMetadataExtractor // See XMP specification: https://developer.adobe.com/xmp/docs/XMPSpecifications/ // and Dublin Core: https://www.dublincore.org/specifications/dublin-core/ - private string? GetTextFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + private static string? GetTextFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) { return (doc.DocumentElement?.SelectSingleNode(path + "//rdf:li", ns) ?? doc.DocumentElement?.SelectSingleNode(path, ns))?.InnerText; } - private string? GetListFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + private static string? GetListFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) { var nodes = doc.DocumentElement?.SelectNodes(path + "//rdf:li", ns); @@ -1421,7 +1398,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { if (list.Length > 0) { - list.Append(","); + list.Append(','); } list.Append(n.InnerText); @@ -1437,7 +1414,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor _metadata[key] = value; } - private void ReadMetadataFromXML(long meta) + private void ReadMetadataFromXml(long meta) { if (meta < 1 || meta >= _objectOffsets.Length || _objectOffsets[meta] == 0) return; @@ -1446,7 +1423,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.ObjectStart) + if (token.Type != PdfLexer.TokenType.ObjectStart) { throw new PdfMetadataExtractorException("Expected object header"); } @@ -1460,7 +1437,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { switch (key) { case "Type": - if (value.type != PdfLexer.TokenType.Name || (string)value.value != "Metadata") + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "Metadata") { throw new PdfMetadataExtractorException("Expected /Type to be /Metadata"); } @@ -1468,7 +1445,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Subtype": - if (value.type != PdfLexer.TokenType.Name || (string)value.value != "XML") + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XML") { throw new PdfMetadataExtractorException("Expected /Subtype to be /XML"); } @@ -1476,22 +1453,22 @@ class PdfMetadataExtractor : IPdfMetadataExtractor return true; case "Length": - if (value.type != PdfLexer.TokenType.Int) + if (value.Type != PdfLexer.TokenType.Int) { throw new PdfMetadataExtractorException("Expected integer after /Length"); } - length = (long)value.value; + length = (long)value.Value; return true; case "Filter": - if (value.type != PdfLexer.TokenType.Name) + if (value.Type != PdfLexer.TokenType.Name) { throw new PdfMetadataExtractorException("Expected name after /Filter"); } - if ((string)value.value != "FlateDecode") + if ((string)value.Value != "FlateDecode") { throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); } @@ -1507,7 +1484,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.StreamStart) + if (token.Type != PdfLexer.TokenType.StreamStart) { throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); } @@ -1567,7 +1544,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { var token = _lexer.NextToken(); - if (token.type != PdfLexer.TokenType.DictionaryStart) + if (token.Type != PdfLexer.TokenType.DictionaryStart) { throw new PdfMetadataExtractorException("Expected dictionary"); } @@ -1576,15 +1553,16 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.DictionaryEnd) + if (token.Type == PdfLexer.TokenType.DictionaryEnd) { return; } - else if (token.type == PdfLexer.TokenType.Name) + + if (token.Type == PdfLexer.TokenType.Name) { var value = _lexer.NextToken(); - if (!handler((string)token.value, value)) { + if (!handler((string)token.Value, value)) { SkipValue(value); } } @@ -1599,7 +1577,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { var token = existingToken ?? _lexer.NextToken(); - switch (token.type) + switch (token.Type) { case PdfLexer.TokenType.Bool: case PdfLexer.TokenType.Int: @@ -1608,17 +1586,16 @@ class PdfMetadataExtractor : IPdfMetadataExtractor case PdfLexer.TokenType.String: case PdfLexer.TokenType.ObjectRef: break; - case PdfLexer.TokenType.ArrayStart: + { SkipArray(); - break; - + } case PdfLexer.TokenType.DictionaryStart: + { SkipDictionary(); - break; - + } default: throw new PdfMetadataExtractorException("Unexpected token in SkipValue"); } @@ -1630,7 +1607,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { var token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.ArrayEnd) + if (token.Type == PdfLexer.TokenType.ArrayEnd) { break; } @@ -1645,11 +1622,11 @@ class PdfMetadataExtractor : IPdfMetadataExtractor { var token = _lexer.NextToken(); - if (token.type == PdfLexer.TokenType.DictionaryEnd) + if (token.Type == PdfLexer.TokenType.DictionaryEnd) { break; } - else if (token.type != PdfLexer.TokenType.Name) + if (token.Type != PdfLexer.TokenType.Name) { throw new PdfMetadataExtractorException("Expected name in dictionary"); } diff --git a/API/Program.cs b/API/Program.cs index fde52a2f3..ff6b67ef2 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -58,7 +58,7 @@ public class Program } Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "http://localhost:5020" : "https://plus-next.kavitareader.com"; + ? "http://localhost:5020" : "https://plus.kavitareader.com"; try { diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index ed9e3431b..ae9383c7b 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -39,6 +39,10 @@ public interface IDirectoryService /// string BookmarkDirectory { get; } /// + /// Used for random files needed, like images to check against, list of countries, etc + /// + string AssetsDirectory { get; } + /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. /// /// Absolute path of directory to scan. @@ -87,6 +91,7 @@ public class DirectoryService : IDirectoryService public string TempDirectory { get; } public string ConfigDirectory { get; } public string BookmarkDirectory { get; } + public string AssetsDirectory { get; } public string SiteThemeDirectory { get; } public string FaviconDirectory { get; } public string LocalizationDirectory { get; } @@ -120,6 +125,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(TempDirectory); BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); ExistOrCreate(BookmarkDirectory); + AssetsDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "Assets"); + ExistOrCreate(AssetsDirectory); SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); ExistOrCreate(SiteThemeDirectory); FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons"); diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index fc39c426d..0255b785d 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Drawing; using System.IO; using System.Linq; using System.Numerics; @@ -11,9 +10,11 @@ using API.Entities.Interfaces; using API.Extensions; using Microsoft.Extensions.Logging; using NetVips; +using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing.Processors.Quantization; +using Color = System.Drawing.Color; using Image = NetVips.Image; namespace API.Services; @@ -748,6 +749,7 @@ public class ImageService : IImageService entity.SecondaryColor = colors.Secondary; } + public static Color HexToRgb(string? hex) { if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null"); diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 1781ba4c6..47cc6cd39 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -752,7 +752,7 @@ public class ExternalMetadataService : IExternalMetadataService _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); - await DownloadAndSetCovers(upstreamArtists); + await DownloadAndSetPersonCovers(upstreamArtists); return true; } @@ -809,7 +809,7 @@ public class ExternalMetadataService : IExternalMetadataService _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); - await DownloadAndSetCovers(upstreamWriters); + await DownloadAndSetPersonCovers(upstreamWriters); return true; } @@ -1058,7 +1058,7 @@ public class ExternalMetadataService : IExternalMetadataService { try { - await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false); + await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false, true); } catch (Exception ex) { @@ -1066,7 +1066,7 @@ public class ExternalMetadataService : IExternalMetadataService } } - private async Task DownloadAndSetCovers(List people) + private async Task DownloadAndSetPersonCovers(List people) { foreach (var staff in people) { @@ -1075,7 +1075,7 @@ public class ExternalMetadataService : IExternalMetadataService var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value); if (person != null && !string.IsNullOrEmpty(staff.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) { - await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false); + await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false, true); } } } @@ -1326,11 +1326,15 @@ public class ExternalMetadataService : IExternalMetadataService } try { - return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") + var ret = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") .WithKavitaPlusHeaders(license) .PostJsonAsync(payload) .ReceiveJson(); + ret.Summary = StringHelper.SquashBreaklines(ret.Summary); + + return ret; + } catch (Exception e) { diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 4faf59e6c..c4ad40fe8 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -33,6 +33,8 @@ public interface ICleanupService /// /// Task CleanupWantToRead(); + + Task ConsolidateProgress(); } /// /// Cleans up after operations on reoccurring basis @@ -74,13 +76,21 @@ public class CleanupService : ICleanupService _logger.LogInformation("Starting Cleanup"); await SendProgress(0F, "Starting cleanup"); + _logger.LogInformation("Cleaning temp directory"); _directoryService.ClearDirectory(_directoryService.TempDirectory); + await SendProgress(0.1F, "Cleaning temp directory"); CleanupCacheAndTempDirectories(); + await SendProgress(0.25F, "Cleaning old database backups"); _logger.LogInformation("Cleaning old database backups"); await CleanupBackups(); + + await SendProgress(0.35F, "Consolidating Progress Events"); + _logger.LogInformation("Consolidating Progress Events"); + await ConsolidateProgress(); + await SendProgress(0.50F, "Cleaning deleted cover images"); _logger.LogInformation("Cleaning deleted cover images"); await DeleteSeriesCoverImages(); @@ -226,6 +236,61 @@ public class CleanupService : ICleanupService _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); } + /// + /// Find any progress events that have duplicate, find the highest page read event, then copy over information from that and delete others, to leave one. + /// + public async Task ConsolidateProgress() + { + // AppUserProgress + var allProgress = await _unitOfWork.AppUserProgressRepository.GetAllProgress(); + + // Group by the unique identifiers that would make a progress entry unique + var duplicateGroups = allProgress + .GroupBy(p => new + { + p.AppUserId, + p.ChapterId, + }) + .Where(g => g.Count() > 1); + + foreach (var group in duplicateGroups) + { + // Find the entry with the highest pages read + var highestProgress = group + .OrderByDescending(p => p.PagesRead) + .ThenByDescending(p => p.LastModifiedUtc) + .First(); + + // Get the duplicate entries to remove (all except the highest progress) + var duplicatesToRemove = group + .Where(p => p.Id != highestProgress.Id) + .ToList(); + + // Copy over any non-null BookScrollId if the highest progress entry doesn't have one + if (string.IsNullOrEmpty(highestProgress.BookScrollId)) + { + var firstValidScrollId = duplicatesToRemove + .FirstOrDefault(p => !string.IsNullOrEmpty(p.BookScrollId)) + ?.BookScrollId; + + if (firstValidScrollId != null) + { + highestProgress.BookScrollId = firstValidScrollId; + highestProgress.MarkModified(); + } + } + + // Remove the duplicates + foreach (var duplicate in duplicatesToRemove) + { + _unitOfWork.AppUserProgressRepository.Remove(duplicate); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + } + public async Task CleanupLogs() { _logger.LogInformation("Performing cleanup of logs directory"); diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index ced75565d..a75c17b76 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -19,6 +19,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NetVips; + namespace API.Services.Tasks.Metadata; #nullable enable @@ -28,8 +29,8 @@ public interface ICoverDbService Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat); Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url); - Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true); - Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true); + Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false); + Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false); } @@ -461,13 +462,39 @@ public class CoverDbService : ICoverDbService return null; } - - public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true) + /// + /// + /// + /// + /// + /// + /// Will check against all known null image placeholders to avoid writing it + public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false) { + // TODO: Refactor checkNoImagePlaceholder bool to an action that evaluates how to process Image if (!string.IsNullOrEmpty(url)) { var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64); + // Additional check to see if downloaded image is similar and we have a higher resolution + if (checkNoImagePlaceholder) + { + var matchRating = Path.Join(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg").GetSimilarity(Path.Join(_directoryService.CoverImageDirectory, filePath))!; + + if (matchRating >= 0.9f) + { + if (string.IsNullOrEmpty(person.CoverImage)) + { + filePath = null; + } + else + { + filePath = Path.GetFileName(Path.Join(_directoryService.CoverImageDirectory, person.CoverImage)); + } + + } + } + if (!string.IsNullOrEmpty(filePath)) { person.CoverImage = filePath; @@ -498,7 +525,8 @@ public class CoverDbService : ICoverDbService /// /// /// - public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true) + /// If images are similar, will choose the higher quality image + public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false) { if (!string.IsNullOrEmpty(url)) { @@ -506,6 +534,13 @@ public class CoverDbService : ICoverDbService if (!string.IsNullOrEmpty(filePath)) { + // Additional check to see if downloaded image is similar and we have a higher resolution + if (chooseBetterImage) + { + var betterImage = Path.Join(_directoryService.CoverImageDirectory, series.CoverImage).GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!; + filePath = Path.GetFileName(betterImage); + } + series.CoverImage = filePath; series.CoverImageLocked = true; _imageService.UpdateColorScape(series); @@ -540,6 +575,6 @@ public class CoverDbService : ICoverDbService filename, encodeFormat, coverImageSize.GetDimensions().Width); } - return await DownloadImageFromUrl(filename, encodeFormat, url); + return await DownloadImageFromUrl(filename, encodeFormat, url); } } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 12521a039..d22fe4e68 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -515,7 +515,7 @@ public class ScannerService : IScannerService var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths)); if (!shouldUseLibraryScan) { - _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name); + _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders as a library root, using series scan", library.Name); } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 88d1096b4..a52fec020 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -67,7 +67,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService [GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)] private static partial Regex BlogPartRegex(); - private static string _cacheFilePath; + private readonly string _cacheFilePath; private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); public VersionUpdaterService(ILogger logger, IEventHub eventHub, IDirectoryService directoryService) @@ -131,6 +131,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService Theme = sections.TryGetValue("Theme", out var theme) ? theme : [], Developer = sections.TryGetValue("Developer", out var developer) ? developer : [], Api = sections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = sections.TryGetValue("Feature Requests", out var frs) ? frs : [], BlogPart = _markdown.Transform(blogPart.Trim()), UpdateBody = _markdown.Transform(prInfo.Body.Trim()) }; @@ -305,7 +306,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService return updateDtos; } - private static async Task?> TryGetCachedReleases() + private async Task?> TryGetCachedReleases() { if (!File.Exists(_cacheFilePath)) return null; @@ -376,6 +377,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [], Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [], Api = parsedSections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = parsedSections.TryGetValue("Feature Requests", out var frs) ? frs : [], BlogPart = blogPart }; } @@ -492,7 +494,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService return item; } - sealed class PullRequestInfo + private sealed class PullRequestInfo { public required string Title { get; init; } public required string Body { get; init; } @@ -501,25 +503,25 @@ public partial class VersionUpdaterService : IVersionUpdaterService public required int Number { get; init; } } - sealed class CommitInfo + private sealed class CommitInfo { public required string Sha { get; init; } public required CommitDetail Commit { get; init; } public required string Html_Url { get; init; } } - sealed class CommitDetail + private sealed class CommitDetail { public required string Message { get; init; } public required CommitAuthor Author { get; init; } } - sealed class CommitAuthor + private sealed class CommitAuthor { public required string Date { get; init; } } - sealed class NightlyInfo + private sealed class NightlyInfo { public required string Version { get; init; } public required int PrNumber { get; init; } diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 7e3c3c0dc..721eb0481 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -4,6 +4,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; +using System.Threading; using System.Threading.Tasks; using API.Data; using API.DTOs.Account; @@ -36,6 +37,7 @@ public class TokenService : ITokenService private readonly IUnitOfWork _unitOfWork; private readonly SymmetricSecurityKey _key; private const string RefreshTokenName = "RefreshToken"; + private static readonly SemaphoreSlim _refreshTokenLock = new SemaphoreSlim(1, 1); public TokenService(IConfiguration config, UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) { @@ -81,6 +83,8 @@ public class TokenService : ITokenService public async Task ValidateRefreshToken(TokenRequestDto request) { + await _refreshTokenLock.WaitAsync(); + try { var tokenHandler = new JwtSecurityTokenHandler(); @@ -91,6 +95,7 @@ public class TokenService : ITokenService _logger.LogDebug("[RefreshToken] failed to validate due to not finding user in RefreshToken"); return null; } + var user = await _userManager.FindByNameAsync(username); if (user == null) { @@ -98,13 +103,19 @@ public class TokenService : ITokenService return null; } - var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken); + var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, + RefreshTokenName, request.RefreshToken); if (!validated && tokenContent.ValidTo <= DateTime.UtcNow.Add(TimeSpan.FromHours(1))) { _logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token"); return null; } + // Remove the old refresh token first + await _userManager.RemoveAuthenticationTokenAsync(user, + TokenOptions.DefaultProvider, + RefreshTokenName); + try { user.UpdateLastActive(); @@ -121,7 +132,8 @@ public class TokenService : ITokenService Token = await CreateToken(user), RefreshToken = await CreateRefreshToken(user) }; - } catch (SecurityTokenExpiredException ex) + } + catch (SecurityTokenExpiredException ex) { // Handle expired token _logger.LogError(ex, "Failed to validate refresh token"); @@ -133,6 +145,10 @@ public class TokenService : ITokenService _logger.LogError(ex, "Failed to validate refresh token"); return null; } + finally + { + _refreshTokenLock.Release(); + } } public async Task GetJwtFromUser(AppUser user) diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss index f27a8768b..1c6f916f3 100644 --- a/UI/Web/src/_card-item-common.scss +++ b/UI/Web/src/_card-item-common.scss @@ -163,11 +163,15 @@ $image-width: 160px; align-items: center; padding: 0 5px; + :first-child { + min-width: 22px; + } + .card-title { font-size: 0.8rem; margin: 0; text-align: center; - max-width: 98px; + max-width: 90px; a { overflow: hidden; diff --git a/UI/Web/src/app/_models/events/update-version-event.ts b/UI/Web/src/app/_models/events/update-version-event.ts index a25f528f0..63661e5e5 100644 --- a/UI/Web/src/app/_models/events/update-version-event.ts +++ b/UI/Web/src/app/_models/events/update-version-event.ts @@ -17,6 +17,7 @@ export interface UpdateVersionEvent { theme: Array; developer: Array; api: Array; + featureRequests: Array; /** * The part above the changelog part */ diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 95293baea..3e186f8ac 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -44,6 +44,9 @@ export class ThemeService { private themesSource = new ReplaySubject(1); public themes$ = this.themesSource.asObservable(); + + private darkModeSource = new ReplaySubject(1); + public isDarkMode$ = this.darkModeSource.asObservable(); /** * Maintain a cache of themes. SignalR will inform us if we need to refresh cache @@ -237,9 +240,11 @@ export class ThemeService { } this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); }); } else { this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); } } else { // Only time themes isn't already loaded is on first load diff --git a/UI/Web/src/app/_services/version.service.ts b/UI/Web/src/app/_services/version.service.ts index 45331fad2..e16a18d1f 100644 --- a/UI/Web/src/app/_services/version.service.ts +++ b/UI/Web/src/app/_services/version.service.ts @@ -76,7 +76,7 @@ export class VersionService implements OnDestroy{ this.modalOpen = true; this.serverService.getChangelog(1).subscribe(changelog => { - const ref = this.modalService.open(NewUpdateModalComponent, {size: 'lg'}); + const ref = this.modalService.open(NewUpdateModalComponent, {size: 'lg', keyboard: false}); ref.componentInstance.version = version; ref.componentInstance.update = changelog[0]; diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html index 6e36c6e88..c34a3d888 100644 --- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html @@ -32,7 +32,7 @@
-
+
@@ -50,8 +50,7 @@ @if (!formGroup.get('dontMatch')?.value) { @for(item of matches; track item.series.name) { - -
+ } @empty { @if (!isLoading) { {{t('no-results')}} diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss index e69de29bb..d3a1cb9a9 100644 --- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss @@ -0,0 +1,3 @@ +.setting-section-break { + margin: 0 !important; +} \ No newline at end of file diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts index 77166a22a..be670684e 100644 --- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts @@ -10,11 +10,14 @@ import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-m import {ToastrService} from "ngx-toastr"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; +import { ThemeService } from 'src/app/_services/theme.service'; +import { AsyncPipe } from '@angular/common'; @Component({ selector: 'app-match-series-modal', standalone: true, imports: [ + AsyncPipe, TranslocoDirective, MatchSeriesResultItemComponent, LoadingComponent, @@ -31,6 +34,7 @@ export class MatchSeriesModalComponent implements OnInit { private readonly seriesService = inject(SeriesService); private readonly modalService = inject(NgbActiveModal); private readonly toastr = inject(ToastrService); + protected readonly themeService = inject(ThemeService); @Input({required: true}) series!: Series; diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html index a8ac15b3a..1da736e18 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html @@ -1,13 +1,13 @@ - -
-
+
+
+
@if (item.series.coverUrl) { - + }
-
{{item.series.name}}
+
{{item.series.name}} ({{item.matchRating | translocoPercent}})
@for(synm of item.series.synonyms; track synm; let last = $last) { {{synm}} @@ -19,6 +19,7 @@ @if (item.series.summary) { }
@@ -30,8 +31,7 @@ {{t('updating-metadata-status')}}
} @else { -
- {{t('details')}} +
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) { {{t('volume-count', {num: item.series.volumes})}} {{t('chapter-count', {num: item.series.chapters})}} @@ -40,11 +40,8 @@ } {{item.series.plusMediaFormat | plusMediaFormat}} - ({{item.matchRating | translocoPercent}})
} - - - - + +
diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss index e69de29bb..5df806397 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss @@ -0,0 +1,33 @@ +.search-result { + img { + max-width: 100px; + min-width: 100px; + } +} +.title { + font-size: 1.2rem; + font-weight: bold; + margin: 0; + padding: 0; +} + +.match-item-container { + &.dark { + background-color: var(--elevation-layer6-dark); + } + + &.light { + background-color: var(--elevation-layer6); + } + border-radius: 15px; + + &:hover { + &.dark { + background-color: var(--elevation-layer11-dark); + } + + &.light { + background-color: var(--elevation-layer11); + } + } +} \ No newline at end of file diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts index 4bb02f72e..a8126dabc 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts @@ -37,6 +37,7 @@ export class MatchSeriesResultItemComponent { private readonly cdRef = inject(ChangeDetectorRef); @Input({required: true}) item!: ExternalSeriesMatch; + @Input({required: true}) isDarkMode = true; @Output() selected: EventEmitter = new EventEmitter(); isSelected = false; diff --git a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html index 5277e95bc..dd55d8069 100644 --- a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html +++ b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.html @@ -165,7 +165,7 @@ @if(settingsForm.get('blacklist'); as formControl) { - @let val = (formControl.value || '').split(','); + @let val = breakTags(formControl.value); @for(opt of val; track opt) { {{opt.trim()}} @@ -184,7 +184,7 @@ @if(settingsForm.get('whitelist'); as formControl) { - @let val = (formControl.value || '').split(','); + @let val = breakTags(formControl.value); @for(opt of val; track opt) { {{opt.trim()}} diff --git a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts index b1719fdf5..589107998 100644 --- a/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts +++ b/UI/Web/src/app/admin/manage-metadata-settings/manage-metadata-settings.component.ts @@ -149,6 +149,15 @@ export class ManageMetadataSettingsComponent implements OnInit { } + breakTags(csString: string) { + if (csString) { + return csString.split(','); + } + + return []; + } + + packData(withFieldMappings: boolean = true) { const model = this.settingsForm.value; diff --git a/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html index 9367f9388..8d95bc84b 100644 --- a/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html +++ b/UI/Web/src/app/announcements/_components/changelog-update-item/changelog-update-item.component.html @@ -15,6 +15,7 @@ +
@if (showExtras) { diff --git a/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts b/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts index c625ce1d4..0a8b3a908 100644 --- a/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts +++ b/UI/Web/src/app/announcements/_components/changelog/changelog.component.ts @@ -33,7 +33,7 @@ export class ChangelogComponent implements OnInit { isLoading: boolean = true; ngOnInit(): void { - this.serverService.getChangelog(10).subscribe(updates => { + this.serverService.getChangelog(30).subscribe(updates => { this.updates = updates; this.isLoading = false; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html index 3aa8b8131..d5842315b 100644 --- a/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html +++ b/UI/Web/src/app/announcements/_components/new-update-modal/new-update-modal.component.html @@ -1,7 +1,6 @@ diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index d29b6f44b..a8a549f01 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -649,7 +649,7 @@

{{t('volumes-title')}}

@if (isLoadingVolumes) { -
+
{{t('loading')}}
} @else { diff --git a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts index 6065a4c8e..7257cd55a 100644 --- a/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/canvas-renderer/canvas-renderer.component.ts @@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { ImageRenderer } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; import { NgClass, AsyncPipe } from '@angular/common'; @@ -67,7 +67,7 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, ImageRend - constructor(private readonly cdRef: ChangeDetectorRef, private mangaReaderService: ManagaReaderService, private readerService: ReaderService) { } + constructor(private readonly cdRef: ChangeDetectorRef, private mangaReaderService: MangaReaderService, private readerService: ReaderService) { } ngOnInit(): void { this.readerSettings$.pipe(takeUntilDestroyed(this.destroyRef), tap((value: ReaderSetting) => { diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts index 3978111bd..fc619dbd4 100644 --- a/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/double-renderer-no-cover/double-no-cover-renderer.component.ts @@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { DEBUG_MODES } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; @@ -82,7 +82,7 @@ export class DoubleNoCoverRendererComponent implements OnInit { - constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService, @Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { } ngOnInit(): void { diff --git a/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts index 8a2f04fc0..8a495ca43 100644 --- a/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/double-renderer/double-renderer.component.ts @@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { DEBUG_MODES, ImageRenderer } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; @@ -80,7 +80,7 @@ export class DoubleRendererComponent implements OnInit, ImageRenderer { protected readonly LayoutMode = LayoutMode; - constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService, @Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { } ngOnInit(): void { diff --git a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts index 8acfdca3c..6c3b83743 100644 --- a/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/double-reverse-renderer/double-reverse-renderer.component.ts @@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { DEBUG_MODES, ImageRenderer } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; @@ -84,7 +84,7 @@ export class DoubleReverseRendererComponent implements OnInit, ImageRenderer { - constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService, @Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { } ngOnInit(): void { diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts index 0aef10674..14f182f7b 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts @@ -22,7 +22,7 @@ import { ScrollService } from 'src/app/_services/scroll.service'; import { ReaderService } from '../../../_services/reader.service'; import { PAGING_DIRECTION } from '../../_models/reader-enums'; import { WebtoonImage } from '../../_models/webtoon-image'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {TranslocoDirective} from "@jsverse/transloco"; import {InfiniteScrollModule} from "ngx-infinite-scroll"; @@ -66,7 +66,7 @@ const enum DEBUG_MODES { }) export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { - private readonly mangaReaderService = inject(ManagaReaderService); + private readonly mangaReaderService = inject(MangaReaderService); private readonly readerService = inject(ReaderService); private readonly renderer = inject(Renderer2); private readonly scrollService = inject(ScrollService); diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index f04121444..a2abf8b0e 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -52,7 +52,7 @@ import {ReaderService} from 'src/app/_services/reader.service'; import {LayoutMode} from '../../_models/layout-mode'; import {FITTING_OPTION, PAGING_DIRECTION} from '../../_models/reader-enums'; import {ReaderSetting} from '../../_models/reader-setting'; -import {ManagaReaderService} from '../../_service/managa-reader.service'; +import {MangaReaderService} from '../../_service/manga-reader.service'; import {CanvasRendererComponent} from '../canvas-renderer/canvas-renderer.component'; import {DoubleRendererComponent} from '../double-renderer/double-renderer.component'; import {DoubleReverseRendererComponent} from '../double-reverse-renderer/double-reverse-renderer.component'; @@ -99,7 +99,7 @@ enum KeyDirection { templateUrl: './manga-reader.component.html', styleUrls: ['./manga-reader.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ManagaReaderService], + providers: [MangaReaderService], animations: [ trigger('slideFromTop', [ state('in', style({ transform: 'translateY(0)' })), @@ -153,7 +153,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly toastr = inject(ToastrService); public readonly readerService = inject(ReaderService); public readonly utilityService = inject(UtilityService); - public readonly mangaReaderService = inject(ManagaReaderService); + public readonly mangaReaderService = inject(MangaReaderService); protected readonly KeyDirection = KeyDirection; protected readonly ReaderMode = ReaderMode; diff --git a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts index b1366d17e..981473a9c 100644 --- a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts +++ b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.ts @@ -17,7 +17,7 @@ import { LayoutMode } from '../../_models/layout-mode'; import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums'; import { ReaderSetting } from '../../_models/reader-setting'; import { ImageRenderer } from '../../_models/renderer'; -import { ManagaReaderService } from '../../_service/managa-reader.service'; +import { MangaReaderService } from '../../_service/manga-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SafeStylePipe } from '../../../_pipes/safe-style.pipe'; @@ -61,7 +61,7 @@ export class SingleRendererComponent implements OnInit, ImageRenderer { get ReaderMode() {return ReaderMode;} get LayoutMode() {return LayoutMode;} - constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: ManagaReaderService, + constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService, @Inject(DOCUMENT) private document: Document) { } ngOnInit(): void { diff --git a/UI/Web/src/app/manga-reader/_service/managa-reader.service.ts b/UI/Web/src/app/manga-reader/_service/manga-reader.service.ts similarity index 97% rename from UI/Web/src/app/manga-reader/_service/managa-reader.service.ts rename to UI/Web/src/app/manga-reader/_service/manga-reader.service.ts index b623af6b1..a2975fd24 100644 --- a/UI/Web/src/app/manga-reader/_service/managa-reader.service.ts +++ b/UI/Web/src/app/manga-reader/_service/manga-reader.service.ts @@ -6,12 +6,11 @@ import { ChapterInfo } from '../_models/chapter-info'; import { DimensionMap } from '../_models/file-dimension'; import { FITTING_OPTION } from '../_models/reader-enums'; import { BookmarkInfo } from 'src/app/_models/manga-reader/bookmark-info'; -import {ReaderMode} from "../../_models/preferences/reader-mode"; @Injectable({ providedIn: 'root' }) -export class ManagaReaderService { +export class MangaReaderService { private pageDimensions: DimensionMap = {}; private pairs: {[key: number]: number} = {}; @@ -168,7 +167,7 @@ export class ManagaReaderService { } // Boost score if width is small (≤ 800px, common in webtoons) - if (info.width <= 800) { + if (info.width <= 750) { score += 0.5; // Adjust weight as needed } diff --git a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html index b6d32d413..2a9478751 100644 --- a/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html +++ b/UI/Web/src/app/settings/_components/setting-item/setting-item.component.html @@ -1,7 +1,7 @@
-
+
@if (labelId) { @@ -13,7 +13,7 @@ }
-
+
@if (showEdit) {