mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Polish 2 (#3555)
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
parent
b858729c9e
commit
9565fe7360
@ -521,6 +521,71 @@ public class CleanupServiceTests : AbstractDbTest
|
|||||||
}
|
}
|
||||||
#endregion
|
#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<ILogger<CleanupService>>(), _unitOfWork,
|
||||||
|
Substitute.For<IEventHub>(),
|
||||||
|
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
|
||||||
|
|
||||||
|
|
||||||
|
await cleanupService.ConsolidateProgress();
|
||||||
|
|
||||||
|
var progress = await _unitOfWork.AppUserProgressRepository.GetAllProgress();
|
||||||
|
|
||||||
|
Assert.Single(progress);
|
||||||
|
Assert.True(progress.First().PagesRead == 3);
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
#region EnsureChapterProgressIsCapped
|
#region EnsureChapterProgressIsCapped
|
||||||
|
|
||||||
@ -587,7 +652,7 @@ public class CleanupServiceTests : AbstractDbTest
|
|||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
// #region CleanupBookmarks
|
#region CleanupBookmarks
|
||||||
//
|
//
|
||||||
// [Fact]
|
// [Fact]
|
||||||
// public async Task CleanupBookmarks_LeaveAllFiles()
|
// public async Task CleanupBookmarks_LeaveAllFiles()
|
||||||
@ -724,5 +789,5 @@ public class CleanupServiceTests : AbstractDbTest
|
|||||||
// Assert.Equal(1, ds.FileSystem.Directory.GetDirectories($"{BookmarkDirectory}1/1/").Length);
|
// Assert.Equal(1, ds.FileSystem.Directory.GetDirectories($"{BookmarkDirectory}1/1/").Length);
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// #endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
@ -562,4 +562,73 @@ public class ScannerServiceTests : AbstractDbTest
|
|||||||
s2 = postLib.Series.First(s => s.Name == "Accel");
|
s2 = postLib.Series.First(s => s.Name == "Accel");
|
||||||
Assert.Single(s2.Volumes);
|
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<string, ComicInfo>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
]
|
@ -197,6 +197,9 @@
|
|||||||
<Content Include="EmailTemplates\**">
|
<Content Include="EmailTemplates\**">
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Assets\**">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
<Folder Include="Data\ManualMigrations\v0.8.3\" />
|
<Folder Include="Data\ManualMigrations\v0.8.3\" />
|
||||||
<Folder Include="Extensions\KavitaPlus\" />
|
<Folder Include="Extensions\KavitaPlus\" />
|
||||||
<None Include="I18N\**" />
|
<None Include="I18N\**" />
|
||||||
|
BIN
API/Assets/anilist-no-image-placeholder.jpg
Normal file
BIN
API/Assets/anilist-no-image-placeholder.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
@ -1370,7 +1370,9 @@ public class OpdsController : BaseApiController
|
|||||||
using var sm = new StringWriter();
|
using var sm = new StringWriter();
|
||||||
_xmlSerializer.Serialize(sm, feed);
|
_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
|
// Recursively sanitize all string properties in the object
|
||||||
@ -1381,6 +1383,10 @@ public class OpdsController : BaseApiController
|
|||||||
var properties = obj.GetType().GetProperties();
|
var properties = obj.GetType().GetProperties();
|
||||||
foreach (var property in properties)
|
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)
|
if (property.PropertyType == typeof(string) && property.CanWrite)
|
||||||
{
|
{
|
||||||
var value = (string?)property.GetValue(obj);
|
var value = (string?)property.GetValue(obj);
|
||||||
@ -1391,7 +1397,9 @@ public class OpdsController : BaseApiController
|
|||||||
}
|
}
|
||||||
else if (property.PropertyType.IsClass) // Handle nested objects
|
else if (property.PropertyType.IsClass) // Handle nested objects
|
||||||
{
|
{
|
||||||
SanitizeFeed(property.GetValue(obj));
|
var nestedObject = property.GetValue(obj);
|
||||||
|
if (nestedObject != null)
|
||||||
|
SanitizeFeed(nestedObject);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -567,14 +567,15 @@ public class SettingsController : BaseApiController
|
|||||||
existingMetadataSetting.EnableStartDate = dto.EnableStartDate;
|
existingMetadataSetting.EnableStartDate = dto.EnableStartDate;
|
||||||
existingMetadataSetting.EnableGenres = dto.EnableGenres;
|
existingMetadataSetting.EnableGenres = dto.EnableGenres;
|
||||||
existingMetadataSetting.EnableTags = dto.EnableTags;
|
existingMetadataSetting.EnableTags = dto.EnableTags;
|
||||||
existingMetadataSetting.PersonRoles = dto.PersonRoles;
|
|
||||||
existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming;
|
existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming;
|
||||||
|
existingMetadataSetting.EnableCoverImage = dto.EnableCoverImage;
|
||||||
|
|
||||||
existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? [];
|
existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? [];
|
||||||
|
|
||||||
existingMetadataSetting.Blacklist = dto.Blacklist.Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? [];
|
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.Whitelist = dto.Whitelist.Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? [];
|
||||||
existingMetadataSetting.Overrides = dto.Overrides.ToList() ?? [];
|
existingMetadataSetting.Overrides = dto.Overrides.ToList() ?? [];
|
||||||
|
existingMetadataSetting.PersonRoles = dto.PersonRoles ?? [];
|
||||||
|
|
||||||
// Handle Field Mappings
|
// Handle Field Mappings
|
||||||
if (dto.FieldMappings != null)
|
if (dto.FieldMappings != null)
|
||||||
|
@ -110,13 +110,10 @@ public class UploadController : BaseApiController
|
|||||||
lockState = uploadFileDto.LockCover;
|
lockState = uploadFileDto.LockCover;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(filePath))
|
|
||||||
{
|
|
||||||
series.CoverImage = filePath;
|
series.CoverImage = filePath;
|
||||||
series.CoverImageLocked = lockState;
|
series.CoverImageLocked = lockState;
|
||||||
_imageService.UpdateColorScape(series);
|
_imageService.UpdateColorScape(series);
|
||||||
_unitOfWork.SeriesRepository.Update(series);
|
_unitOfWork.SeriesRepository.Update(series);
|
||||||
}
|
|
||||||
|
|
||||||
if (_unitOfWork.HasChanges())
|
if (_unitOfWork.HasChanges())
|
||||||
{
|
{
|
||||||
|
@ -62,6 +62,7 @@ public class UpdateNotificationDto
|
|||||||
public IList<string> Theme { get; set; }
|
public IList<string> Theme { get; set; }
|
||||||
public IList<string> Developer { get; set; }
|
public IList<string> Developer { get; set; }
|
||||||
public IList<string> Api { get; set; }
|
public IList<string> Api { get; set; }
|
||||||
|
public IList<string> FeatureRequests { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The part above the changelog part
|
/// The part above the changelog part
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -19,6 +19,7 @@ namespace API.Data.Repositories;
|
|||||||
public interface IAppUserProgressRepository
|
public interface IAppUserProgressRepository
|
||||||
{
|
{
|
||||||
void Update(AppUserProgress userProgress);
|
void Update(AppUserProgress userProgress);
|
||||||
|
void Remove(AppUserProgress userProgress);
|
||||||
Task<int> CleanupAbandonedChapters();
|
Task<int> CleanupAbandonedChapters();
|
||||||
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
|
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
|
||||||
Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId);
|
Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId);
|
||||||
@ -57,6 +58,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository
|
|||||||
_context.Entry(userProgress).State = EntityState.Modified;
|
_context.Entry(userProgress).State = EntityState.Modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Remove(AppUserProgress userProgress)
|
||||||
|
{
|
||||||
|
_context.Remove(userProgress);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well.
|
/// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -1848,7 +1848,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Prefer the first match or handle duplicates by choosing the last one
|
// Prefer the first match or handle duplicates by choosing the last one
|
||||||
if (matchingSeries.Any())
|
if (matchingSeries.Count != 0)
|
||||||
{
|
{
|
||||||
ids.Add(matchingSeries.Last().Id);
|
ids.Add(matchingSeries.Last().Id);
|
||||||
}
|
}
|
||||||
|
122
API/Extensions/ImageExtensions.cs
Normal file
122
API/Extensions/ImageExtensions.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Smaller is better
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="img1"></param>
|
||||||
|
/// <param name="img2"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static float GetMeanSquaredError(this Image<Rgba32> img1, Image<Rgba32> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines which image is "better" based on similarity and resolution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="imagePath1">Path to first image</param>
|
||||||
|
/// <param name="imagePath2">Path to second image</param>
|
||||||
|
/// <param name="similarityThreshold">Minimum similarity to consider images similar</param>
|
||||||
|
/// <returns>The path of the better image</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculate a similarity score (0-1f) based on resolution difference and MSE.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="imagePath1"></param>
|
||||||
|
/// <param name="imagePath2"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
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<Rgba32>(imagePath1);
|
||||||
|
using var imgSharp2 = SixLabors.ImageSharp.Image.Load<Rgba32>(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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,16 +1,11 @@
|
|||||||
/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure.
|
/**
|
||||||
|
* Contributed by https://github.com/microtherion
|
||||||
// Contributed by https://github.com/microtherion
|
*
|
||||||
|
* All references to the "PDF Spec" (section numbers, etc) refer to the
|
||||||
// All references to the "PDF Spec" (section numbers, etc) refer to the
|
* PDF 1.7 Specification a.k.a. PDF32000-1:2008
|
||||||
// PDF 1.7 Specification a.k.a. PDF32000-1:2008
|
* https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
|
||||||
// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
|
*/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Xml;
|
|
||||||
using System.Text;
|
|
||||||
using System.IO;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using API.Data.Metadata;
|
using API.Data.Metadata;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
@ -18,6 +13,7 @@ using API.Services.Tasks.Scanner.Parser;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Nager.ArticleNumber;
|
using Nager.ArticleNumber;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace API.Helpers;
|
namespace API.Helpers;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
@ -27,6 +23,9 @@ public interface IPdfComicInfoExtractor
|
|||||||
ComicInfo? GetComicInfo(string filePath);
|
ComicInfo? GetComicInfo(string filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure.
|
||||||
|
/// </summary>
|
||||||
public class PdfComicInfoExtractor : IPdfComicInfoExtractor
|
public class PdfComicInfoExtractor : IPdfComicInfoExtractor
|
||||||
{
|
{
|
||||||
private readonly ILogger<BookService> _logger;
|
private readonly ILogger<BookService> _logger;
|
||||||
@ -44,7 +43,7 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor
|
|||||||
_mediaErrorService = mediaErrorService;
|
_mediaErrorService = mediaErrorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private float? GetFloatFromText(string? text)
|
private static float? GetFloatFromText(string? text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(text)) return null;
|
if (string.IsNullOrEmpty(text)) return null;
|
||||||
|
|
||||||
@ -78,9 +77,9 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? MaybeGetMetadata(Dictionary<string, string> metadata, string key)
|
private static string? MaybeGetMetadata(Dictionary<string, string> metadata, string key)
|
||||||
{
|
{
|
||||||
return metadata.ContainsKey(key) ? metadata[key] : null;
|
return metadata.TryGetValue(key, out var value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ComicInfo? GetComicInfoFromMetadata(Dictionary<string, string> metadata, string filePath)
|
private ComicInfo? GetComicInfoFromMetadata(Dictionary<string, string> metadata, string filePath)
|
||||||
@ -100,6 +99,7 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor
|
|||||||
info.Publisher = MaybeGetMetadata(metadata, "Publisher") ?? string.Empty;
|
info.Publisher = MaybeGetMetadata(metadata, "Publisher") ?? string.Empty;
|
||||||
info.Writer = MaybeGetMetadata(metadata, "Author") ?? string.Empty;
|
info.Writer = MaybeGetMetadata(metadata, "Author") ?? string.Empty;
|
||||||
info.Title = MaybeGetMetadata(metadata, "Title") ?? string.Empty;
|
info.Title = MaybeGetMetadata(metadata, "Title") ?? string.Empty;
|
||||||
|
info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty;
|
||||||
info.Genre = MaybeGetMetadata(metadata, "Subject") ?? string.Empty;
|
info.Genre = MaybeGetMetadata(metadata, "Subject") ?? string.Empty;
|
||||||
info.LanguageISO = BookService.ValidateLanguage(MaybeGetMetadata(metadata, "Language"));
|
info.LanguageISO = BookService.ValidateLanguage(MaybeGetMetadata(metadata, "Language"));
|
||||||
info.Isbn = MaybeGetMetadata(metadata, "ISBN") ?? string.Empty;
|
info.Isbn = MaybeGetMetadata(metadata, "ISBN") ?? string.Empty;
|
||||||
@ -111,10 +111,9 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor
|
|||||||
}
|
}
|
||||||
|
|
||||||
info.UserRating = GetFloatFromText(MaybeGetMetadata(metadata, "UserRating")) ?? 0.0f;
|
info.UserRating = GetFloatFromText(MaybeGetMetadata(metadata, "UserRating")) ?? 0.0f;
|
||||||
info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty;
|
info.Series = MaybeGetMetadata(metadata, "Series") ?? info.Title;
|
||||||
info.Series = MaybeGetMetadata(metadata, "Series") ?? info.TitleSort;
|
|
||||||
info.SeriesSort = info.Series;
|
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 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))
|
if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume))
|
||||||
@ -122,18 +121,6 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor
|
|||||||
info.Count = 1;
|
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);
|
ComicInfo.CleanComicInfo(info);
|
||||||
|
|
||||||
return info;
|
return info;
|
||||||
|
@ -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).
|
* Contributed by https://github.com/microtherion
|
||||||
/// Supports both UTF-16 and PDFDocEncoding for strings.
|
*
|
||||||
/// Lacks support for many PDF configurations that are theoretically possible, but should handle most common cases.
|
* All references to the "PDF Spec" (section numbers, etc) refer to the
|
||||||
|
* PDF 1.7 Specification a.k.a. PDF32000-1:2008
|
||||||
// Contributed by https://github.com/microtherion
|
* https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
|
||||||
|
*/
|
||||||
// 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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Reflection.Metadata.Ecma335;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Security.Principal;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@ -25,6 +18,12 @@ using API.Services;
|
|||||||
namespace API.Helpers;
|
namespace API.Helpers;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public class PdfMetadataExtractorException : Exception
|
public class PdfMetadataExtractorException : Exception
|
||||||
{
|
{
|
||||||
public PdfMetadataExtractorException()
|
public PdfMetadataExtractorException()
|
||||||
@ -56,19 +55,21 @@ class PdfStringBuilder
|
|||||||
|
|
||||||
// PDFDocEncoding defined in PDF Spec D.1
|
// PDFDocEncoding defined in PDF Spec D.1
|
||||||
|
|
||||||
private readonly char[] _pdfDocMappingLow = new char[] {
|
private readonly char[] _pdfDocMappingLow =
|
||||||
'\u02D8', '\u02C7', '\u02C6', '\u02D9', '\u02DD', '\u02DB', '\u02DA', '\u02DC',
|
[
|
||||||
};
|
'\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',
|
'\u2022', '\u2020', '\u2021', '\u2026', '\u2014', '\u2013', '\u0192', '\u2044',
|
||||||
'\u2039', '\u203A', '\u2212', '\u2030', '\u201E', '\u201C', '\u201D', '\u2018',
|
'\u2039', '\u203A', '\u2212', '\u2030', '\u201E', '\u201C', '\u201D', '\u2018',
|
||||||
'\u2019', '\u201A', '\u2122', '\uFB01', '\uFB02', '\u0141', '\u0152', '\u0160',
|
'\u2019', '\u201A', '\u2122', '\uFB01', '\uFB02', '\u0141', '\u0152', '\u0160',
|
||||||
'\u0178', '\u017D', '\u0131', '\u0142', '\u0153', '\u0161', '\u017E', ' ',
|
'\u0178', '\u017D', '\u0131', '\u0142', '\u0153', '\u0161', '\u017E', ' ',
|
||||||
'\u20AC',
|
'\u20AC'
|
||||||
};
|
];
|
||||||
|
|
||||||
public void AppendPdfDocByte(byte b)
|
private void AppendPdfDocByte(byte b)
|
||||||
{
|
{
|
||||||
if (b >= 0x18 && b < 0x20)
|
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
|
public enum TokenType
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
@ -171,16 +177,10 @@ class PdfLexer(Stream stream)
|
|||||||
Newline,
|
Newline,
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Token
|
public struct Token(TokenType type, object value)
|
||||||
{
|
{
|
||||||
public TokenType type;
|
public TokenType Type = type;
|
||||||
public object value;
|
public object Value = value;
|
||||||
|
|
||||||
public Token(TokenType type, object value)
|
|
||||||
{
|
|
||||||
this.type = type;
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Token NextToken(bool reportNewlines = false)
|
public Token NextToken(bool reportNewlines = false)
|
||||||
@ -273,7 +273,7 @@ class PdfLexer(Stream stream)
|
|||||||
{
|
{
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
byte b = NextByte();
|
var b = NextByte();
|
||||||
switch ((char)b)
|
switch ((char)b)
|
||||||
{
|
{
|
||||||
case ' ':
|
case ' ':
|
||||||
@ -303,7 +303,7 @@ class PdfLexer(Stream stream)
|
|||||||
// Look for the startxref element as per PDF Spec 7.5.5
|
// Look for the startxref element as per PDF Spec 7.5.5
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
byte b = NextByte();
|
var b = NextByte();
|
||||||
|
|
||||||
switch ((char)b)
|
switch ((char)b)
|
||||||
{
|
{
|
||||||
@ -345,13 +345,13 @@ class PdfLexer(Stream stream)
|
|||||||
|
|
||||||
var token = NextToken(true);
|
var token = NextToken(true);
|
||||||
|
|
||||||
if (token.type == TokenType.Keyword && (string)token.value == "startxref")
|
if (token.Type == TokenType.Keyword && (string)token.Value == "startxref")
|
||||||
{
|
{
|
||||||
token = NextToken();
|
token = NextToken();
|
||||||
|
|
||||||
if (token.type == TokenType.Int)
|
if (token.Type == TokenType.Int)
|
||||||
{
|
{
|
||||||
return (long)token.value;
|
return (long)token.Value;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -382,8 +382,8 @@ class PdfLexer(Stream stream)
|
|||||||
|
|
||||||
if (obj == 0)
|
if (obj == 0)
|
||||||
{
|
{
|
||||||
obj = Convert.ToInt64(System.Text.Encoding.ASCII.GetString(_buffer, _pos, 10));
|
obj = Convert.ToInt64(Encoding.ASCII.GetString(_buffer, _pos, 10));
|
||||||
generation = Convert.ToInt32(System.Text.Encoding.ASCII.GetString(_buffer, _pos + 11, 5));
|
generation = Convert.ToInt32(Encoding.ASCII.GetString(_buffer, _pos + 11, 5));
|
||||||
inUse = _buffer[_pos + 17] == 'n';
|
inUse = _buffer[_pos + 17] == 'n';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -404,7 +404,7 @@ class PdfLexer(Stream stream)
|
|||||||
|
|
||||||
if (_pos < _valid)
|
if (_pos < _valid)
|
||||||
{
|
{
|
||||||
int buffered = Math.Min(_valid - _pos, length);
|
var buffered = Math.Min(_valid - _pos, length);
|
||||||
rawData.Write(_buffer, _pos, buffered);
|
rawData.Write(_buffer, _pos, buffered);
|
||||||
length -= buffered;
|
length -= buffered;
|
||||||
_pos += buffered;
|
_pos += buffered;
|
||||||
@ -412,8 +412,8 @@ class PdfLexer(Stream stream)
|
|||||||
|
|
||||||
while (length > 0)
|
while (length > 0)
|
||||||
{
|
{
|
||||||
int buffered = Math.Min(length, _bufferSize);
|
var buffered = Math.Min(length, BufferSize);
|
||||||
stream.Read(_buffer, 0, buffered);
|
stream.ReadExactly(_buffer, 0, buffered);
|
||||||
rawData.Write(_buffer, 0, buffered);
|
rawData.Write(_buffer, 0, buffered);
|
||||||
_pos = 0;
|
_pos = 0;
|
||||||
_valid = 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()
|
private byte NextByte()
|
||||||
{
|
{
|
||||||
if (_pos >= _valid)
|
if (_pos >= _valid)
|
||||||
{
|
{
|
||||||
_pos = 0;
|
_pos = 0;
|
||||||
_valid = stream.Read(_buffer, 0, _bufferSize);
|
_valid = stream.Read(_buffer, 0, BufferSize);
|
||||||
|
|
||||||
if (_valid <= 0)
|
if (_valid <= 0)
|
||||||
{
|
{
|
||||||
@ -478,7 +473,7 @@ class PdfLexer(Stream stream)
|
|||||||
Buffer.BlockCopy(_buffer, _pos, _buffer, 0, _valid - _pos);
|
Buffer.BlockCopy(_buffer, _pos, _buffer, 0, _valid - _pos);
|
||||||
_valid -= _pos;
|
_valid -= _pos;
|
||||||
_pos = 0;
|
_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)
|
while (true)
|
||||||
{
|
{
|
||||||
byte b = NextByte();
|
var b = NextByte();
|
||||||
|
|
||||||
if (b == '\n')
|
if (b == '\n')
|
||||||
{
|
{
|
||||||
@ -507,14 +502,14 @@ class PdfLexer(Stream stream)
|
|||||||
private Token ScanNumber()
|
private Token ScanNumber()
|
||||||
{
|
{
|
||||||
StringBuilder sb = new();
|
StringBuilder sb = new();
|
||||||
bool hasDot = LastByte() == '.';
|
var hasDot = LastByte() == '.';
|
||||||
bool followedBySpace = false;
|
var followedBySpace = false;
|
||||||
|
|
||||||
sb.Append((char)LastByte());
|
sb.Append((char)LastByte());
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
byte b = NextByte();
|
var b = NextByte();
|
||||||
|
|
||||||
if (b == '.' || b >= '0' && b <= '9')
|
if (b == '.' || b >= '0' && b <= '9')
|
||||||
{
|
{
|
||||||
@ -533,17 +528,19 @@ class PdfLexer(Stream stream)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasDot)
|
if (hasDot)
|
||||||
{
|
{
|
||||||
return new Token(TokenType.Double, double.Parse(sb.ToString()));
|
return new Token(TokenType.Double, double.Parse(sb.ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (followedBySpace)
|
if (followedBySpace)
|
||||||
{
|
{
|
||||||
// Look ahead to see if it's an object reference (PDF Spec 7.3.10)
|
// Look ahead to see if it's an object reference (PDF Spec 7.3.10)
|
||||||
WantLookahead(32);
|
WantLookahead(32);
|
||||||
|
|
||||||
var savedPos = _pos;
|
var savedPos = _pos;
|
||||||
byte b = NextByte();
|
var b = NextByte();
|
||||||
|
|
||||||
while (b == ' ' || b == '\t')
|
while (b == ' ' || b == '\t')
|
||||||
{
|
{
|
||||||
@ -578,32 +575,25 @@ class PdfLexer(Stream stream)
|
|||||||
return new Token(TokenType.Int, long.Parse(sb.ToString()));
|
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':
|
>= '0' and <= '9' => b - (byte) '0',
|
||||||
return b - (byte)'0';
|
>= 'a' and <= 'f' => b - (byte) 'a' + 10,
|
||||||
|
>= 'A' and <= 'F' => b - (byte) 'A' + 10,
|
||||||
case >= 'a' and <= 'f':
|
_ => throw new PdfMetadataExtractorException("Invalid hex digit, got {b}")
|
||||||
return b - (byte)'a' + 10;
|
};
|
||||||
|
|
||||||
case >= 'A' and <= 'F':
|
|
||||||
return b - (byte)'A' + 10;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new PdfMetadataExtractorException("Invalid hex digit, got {b}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Token ScanName()
|
private Token ScanName()
|
||||||
{
|
{
|
||||||
// PDF Spec 7.3.5
|
// PDF Spec 7.3.5
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
byte b = NextByte();
|
var b = NextByte();
|
||||||
switch ((char)b)
|
switch ((char)b)
|
||||||
{
|
{
|
||||||
case '(':
|
case '(':
|
||||||
@ -628,8 +618,8 @@ class PdfLexer(Stream stream)
|
|||||||
return new Token(TokenType.Name, sb.ToString());
|
return new Token(TokenType.Name, sb.ToString());
|
||||||
|
|
||||||
case '#':
|
case '#':
|
||||||
byte b1 = NextByte();
|
var b1 = NextByte();
|
||||||
byte b2 = NextByte();
|
var b2 = NextByte();
|
||||||
b = (byte)((HexDigit(b1) << 4) | HexDigit(b2));
|
b = (byte)((HexDigit(b1) << 4) | HexDigit(b2));
|
||||||
|
|
||||||
goto default;
|
goto default;
|
||||||
@ -646,11 +636,11 @@ class PdfLexer(Stream stream)
|
|||||||
// PDF Spec 7.3.4.2
|
// PDF Spec 7.3.4.2
|
||||||
|
|
||||||
PdfStringBuilder sb = new();
|
PdfStringBuilder sb = new();
|
||||||
int parenLevel = 1;
|
var parenLevel = 1;
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
byte b = NextByte();
|
var b = NextByte();
|
||||||
|
|
||||||
switch ((char)b)
|
switch ((char)b)
|
||||||
{
|
{
|
||||||
@ -698,9 +688,9 @@ class PdfLexer(Stream stream)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case >= '0' and <= '7':
|
case >= '0' and <= '7':
|
||||||
byte b1 = b;
|
var b1 = b;
|
||||||
byte b2 = NextByte();
|
var b2 = NextByte();
|
||||||
byte b3 = NextByte();
|
var b3 = NextByte();
|
||||||
|
|
||||||
if (b2 < '0' || b2 > '7' || b3 < '0' || b3 > '7')
|
if (b2 < '0' || b2 > '7' || b3 < '0' || b3 > '7')
|
||||||
{
|
{
|
||||||
@ -728,12 +718,12 @@ class PdfLexer(Stream stream)
|
|||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
byte b = NextByte();
|
var b = NextByte();
|
||||||
|
|
||||||
switch ((char)b)
|
switch ((char)b)
|
||||||
{
|
{
|
||||||
case (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'):
|
case (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'):
|
||||||
byte b1 = NextByte();
|
var b1 = NextByte();
|
||||||
if (b1 == '>')
|
if (b1 == '>')
|
||||||
{
|
{
|
||||||
PutBack();
|
PutBack();
|
||||||
@ -760,7 +750,7 @@ class PdfLexer(Stream stream)
|
|||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
byte b = NextByte();
|
var b = NextByte();
|
||||||
if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z'))
|
if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z'))
|
||||||
{
|
{
|
||||||
sb.Append((char)b);
|
sb.Append((char)b);
|
||||||
@ -796,38 +786,25 @@ class PdfLexer(Stream stream)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PdfMetadataExtractor : IPdfMetadataExtractor
|
internal class PdfMetadataExtractor : IPdfMetadataExtractor
|
||||||
{
|
{
|
||||||
private readonly ILogger<BookService> _logger;
|
private readonly ILogger<BookService> _logger;
|
||||||
private readonly PdfLexer _lexer;
|
private readonly PdfLexer _lexer;
|
||||||
private readonly FileStream _stream;
|
private readonly FileStream _stream;
|
||||||
private long[] _objectOffsets = new long[0];
|
private long[] _objectOffsets = new long[0];
|
||||||
private readonly Dictionary<string, string> _metadata = new();
|
private readonly Dictionary<string, string> _metadata = [];
|
||||||
|
private readonly Stack<MetadataRef> _metadataRef = new();
|
||||||
|
|
||||||
private struct MetadataRef
|
private struct MetadataRef(long root, long info)
|
||||||
{
|
{
|
||||||
public long root;
|
public long Root = root;
|
||||||
public long info;
|
public long Info = info;
|
||||||
|
|
||||||
public MetadataRef(long root, long info)
|
|
||||||
{
|
|
||||||
this.root = root;
|
|
||||||
this.info = info;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly Stack<MetadataRef> metadataRef = new();
|
private struct XRefSection(long first, long count)
|
||||||
|
|
||||||
private struct XRefSection
|
|
||||||
{
|
{
|
||||||
public long first;
|
public readonly long First = first;
|
||||||
public long count;
|
public readonly long Count = count;
|
||||||
|
|
||||||
public XRefSection(long first, long count)
|
|
||||||
{
|
|
||||||
this.first = first;
|
|
||||||
this.count = count;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PdfMetadataExtractor(ILogger<BookService> logger, string filename)
|
public PdfMetadataExtractor(ILogger<BookService> logger, string filename)
|
||||||
@ -887,7 +864,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
|
|
||||||
var token = _lexer.NextToken();
|
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");
|
throw new PdfMetadataExtractorException("Expected xref keyword");
|
||||||
}
|
}
|
||||||
@ -896,17 +873,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
token = _lexer.NextToken();
|
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();
|
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");
|
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)
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@ -946,7 +923,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
|
|
||||||
var token = _lexer.NextToken();
|
var token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.ObjectStart)
|
if (token.Type != PdfLexer.TokenType.ObjectStart)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected obj keyword");
|
throw new PdfMetadataExtractorException("Expected obj keyword");
|
||||||
}
|
}
|
||||||
@ -967,7 +944,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
switch (key)
|
switch (key)
|
||||||
{
|
{
|
||||||
case "Type":
|
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");
|
throw new PdfMetadataExtractorException("Expected /Type to be /XRef");
|
||||||
}
|
}
|
||||||
@ -975,37 +952,37 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Length":
|
case "Length":
|
||||||
if (value.type != PdfLexer.TokenType.Int)
|
if (value.Type != PdfLexer.TokenType.Int)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected integer after /Length");
|
throw new PdfMetadataExtractorException("Expected integer after /Length");
|
||||||
}
|
}
|
||||||
|
|
||||||
length = (long)value.value;
|
length = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Size":
|
case "Size":
|
||||||
if (value.type != PdfLexer.TokenType.Int)
|
if (value.Type != PdfLexer.TokenType.Int)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected integer after /Size");
|
throw new PdfMetadataExtractorException("Expected integer after /Size");
|
||||||
}
|
}
|
||||||
|
|
||||||
size = (long)value.value;
|
size = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Prev":
|
case "Prev":
|
||||||
if (value.type != PdfLexer.TokenType.Int)
|
if (value.Type != PdfLexer.TokenType.Int)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected offset after /Prev");
|
throw new PdfMetadataExtractorException("Expected offset after /Prev");
|
||||||
}
|
}
|
||||||
|
|
||||||
prev = (long)value.value;
|
prev = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Index":
|
case "Index":
|
||||||
if (value.type != PdfLexer.TokenType.ArrayStart)
|
if (value.Type != PdfLexer.TokenType.ArrayStart)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected array after /Index");
|
throw new PdfMetadataExtractorException("Expected array after /Index");
|
||||||
}
|
}
|
||||||
@ -1014,31 +991,31 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
token = _lexer.NextToken();
|
token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type == PdfLexer.TokenType.ArrayEnd)
|
if (token.Type == PdfLexer.TokenType.ArrayEnd)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
else if (token.type != PdfLexer.TokenType.Int)
|
else if (token.Type != PdfLexer.TokenType.Int)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected integer in /Index array");
|
throw new PdfMetadataExtractorException("Expected integer in /Index array");
|
||||||
}
|
}
|
||||||
|
|
||||||
var first = (long)token.value;
|
var first = (long)token.Value;
|
||||||
token = _lexer.NextToken();
|
token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.Int)
|
if (token.Type != PdfLexer.TokenType.Int)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected integer pair in /Index array");
|
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));
|
sections.Enqueue(new XRefSection(first, count));
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "W":
|
case "W":
|
||||||
if (value.type != PdfLexer.TokenType.ArrayStart)
|
if (value.Type != PdfLexer.TokenType.ArrayStart)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected array after /W");
|
throw new PdfMetadataExtractorException("Expected array after /W");
|
||||||
}
|
}
|
||||||
@ -1049,17 +1026,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
token = _lexer.NextToken();
|
token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.Int)
|
if (token.Type != PdfLexer.TokenType.Int)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected integer in /W array");
|
throw new PdfMetadataExtractorException("Expected integer in /W array");
|
||||||
}
|
}
|
||||||
|
|
||||||
widths[i] = (long)token.value;
|
widths[i] = (long)token.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
token = _lexer.NextToken();
|
token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.ArrayEnd)
|
if (token.Type != PdfLexer.TokenType.ArrayEnd)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Unclosed array after /W");
|
throw new PdfMetadataExtractorException("Unclosed array after /W");
|
||||||
}
|
}
|
||||||
@ -1071,12 +1048,12 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Filter":
|
case "Filter":
|
||||||
if (value.type != PdfLexer.TokenType.Name)
|
if (value.Type != PdfLexer.TokenType.Name)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected name after /Filter");
|
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");
|
throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported");
|
||||||
}
|
}
|
||||||
@ -1086,22 +1063,22 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Root":
|
case "Root":
|
||||||
if (value.type != PdfLexer.TokenType.ObjectRef)
|
if (value.Type != PdfLexer.TokenType.ObjectRef)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected object reference after /Root");
|
throw new PdfMetadataExtractorException("Expected object reference after /Root");
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.root = (long)value.value;
|
meta.Root = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Info":
|
case "Info":
|
||||||
if (value.type != PdfLexer.TokenType.ObjectRef)
|
if (value.Type != PdfLexer.TokenType.ObjectRef)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected object reference after /Info");
|
throw new PdfMetadataExtractorException("Expected object reference after /Info");
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.info = (long)value.value;
|
meta.Info = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@ -1112,7 +1089,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
|
|
||||||
token = _lexer.NextToken();
|
token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.StreamStart)
|
if (token.Type != PdfLexer.TokenType.StreamStart)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected xref stream after dictionary");
|
throw new PdfMetadataExtractorException("Expected xref stream after dictionary");
|
||||||
}
|
}
|
||||||
@ -1133,7 +1110,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
Array.Resize(ref _objectOffsets, (int)size);
|
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 type = 0;
|
||||||
long offset = 0;
|
long offset = 0;
|
||||||
@ -1146,17 +1123,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
|
|
||||||
for (var j = 0; j < typeWidth; ++j)
|
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)
|
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)
|
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)
|
if (type == 1 && _objectOffsets[i] == 0)
|
||||||
@ -1176,22 +1153,22 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
|
|
||||||
private void PushMetadataRef(MetadataRef meta)
|
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)
|
switch (key)
|
||||||
{
|
{
|
||||||
case "Root":
|
case "Root":
|
||||||
if (value.type != PdfLexer.TokenType.ObjectRef)
|
if (value.Type != PdfLexer.TokenType.ObjectRef)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected object reference after /Root");
|
throw new PdfMetadataExtractorException("Expected object reference after /Root");
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.root = (long)value.value;
|
meta.Root = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
case "Prev":
|
case "Prev":
|
||||||
if (value.type != PdfLexer.TokenType.Int)
|
if (value.Type != PdfLexer.TokenType.Int)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected offset after /Prev");
|
throw new PdfMetadataExtractorException("Expected offset after /Prev");
|
||||||
}
|
}
|
||||||
|
|
||||||
prev = (long)value.value;
|
prev = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
case "Info":
|
case "Info":
|
||||||
if (value.type != PdfLexer.TokenType.ObjectRef)
|
if (value.Type != PdfLexer.TokenType.ObjectRef)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected object reference after /Info");
|
throw new PdfMetadataExtractorException("Expected object reference after /Info");
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.info = (long)value.value;
|
meta.Info = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
case "XRefStm":
|
case "XRefStm":
|
||||||
// Prefer encoded xref stream over xref table
|
// 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");
|
throw new PdfMetadataExtractorException("Expected offset after /XRefStm");
|
||||||
}
|
}
|
||||||
|
|
||||||
xrefStm = (long)value.value;
|
xrefStm = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@ -1272,14 +1249,14 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
// We read potential metadata sources in backwards historical order, so
|
// We read potential metadata sources in backwards historical order, so
|
||||||
// we can overwrite to our heart's content
|
// 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);
|
ReadMetadataFromInfo(meta.Info);
|
||||||
ReadMetadataFromXML(MetadataObjInObjectCatalog(meta.root));
|
ReadMetadataFromXml(MetadataObjInObjectCatalog(meta.Root));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1298,12 +1275,12 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
|
|
||||||
var token = _lexer.NextToken();
|
var token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.ObjectStart)
|
if (token.Type != PdfLexer.TokenType.ObjectStart)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected object header");
|
throw new PdfMetadataExtractorException("Expected object header");
|
||||||
}
|
}
|
||||||
|
|
||||||
Dictionary<String, long> indirectObjects = new();
|
Dictionary<string, long> indirectObjects = [];
|
||||||
|
|
||||||
ParseDictionary(delegate(string key, PdfLexer.Token value)
|
ParseDictionary(delegate(string key, PdfLexer.Token value)
|
||||||
{
|
{
|
||||||
@ -1317,16 +1294,16 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
case "Producer":
|
case "Producer":
|
||||||
case "CreationDate":
|
case "CreationDate":
|
||||||
case "ModDate":
|
case "ModDate":
|
||||||
if (value.type == PdfLexer.TokenType.ObjectRef) {
|
if (value.Type == PdfLexer.TokenType.ObjectRef) {
|
||||||
indirectObjects[key] = (long)value.value;
|
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");
|
throw new PdfMetadataExtractorException("Expected string value");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_metadata[key] = (string)value.value;
|
_metadata[key] = (string)value.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -1343,17 +1320,17 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
|
|
||||||
token = _lexer.NextToken();
|
token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.ObjectStart) {
|
if (token.Type != PdfLexer.TokenType.ObjectStart) {
|
||||||
throw new PdfMetadataExtractorException("Expected object here");
|
throw new PdfMetadataExtractorException("Expected object here");
|
||||||
}
|
}
|
||||||
|
|
||||||
token = _lexer.NextToken();
|
token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.String) {
|
if (token.Type != PdfLexer.TokenType.String) {
|
||||||
throw new PdfMetadataExtractorException("Expected 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();
|
var token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.ObjectStart)
|
if (token.Type != PdfLexer.TokenType.ObjectStart)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected object header");
|
throw new PdfMetadataExtractorException("Expected object header");
|
||||||
}
|
}
|
||||||
@ -1382,12 +1359,12 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "Metadata":
|
case "Metadata":
|
||||||
if (value.type != PdfLexer.TokenType.ObjectRef)
|
if (value.Type != PdfLexer.TokenType.ObjectRef)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected object number after /Metadata");
|
throw new PdfMetadataExtractorException("Expected object number after /Metadata");
|
||||||
}
|
}
|
||||||
|
|
||||||
meta = (long)value.value;
|
meta = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@ -1403,13 +1380,13 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
// See XMP specification: https://developer.adobe.com/xmp/docs/XMPSpecifications/
|
// See XMP specification: https://developer.adobe.com/xmp/docs/XMPSpecifications/
|
||||||
// and Dublin Core: https://www.dublincore.org/specifications/dublin-core/
|
// 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)
|
return (doc.DocumentElement?.SelectSingleNode(path + "//rdf:li", ns)
|
||||||
?? doc.DocumentElement?.SelectSingleNode(path, ns))?.InnerText;
|
?? 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);
|
var nodes = doc.DocumentElement?.SelectNodes(path + "//rdf:li", ns);
|
||||||
|
|
||||||
@ -1421,7 +1398,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
if (list.Length > 0)
|
if (list.Length > 0)
|
||||||
{
|
{
|
||||||
list.Append(",");
|
list.Append(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
list.Append(n.InnerText);
|
list.Append(n.InnerText);
|
||||||
@ -1437,7 +1414,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
_metadata[key] = value;
|
_metadata[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReadMetadataFromXML(long meta)
|
private void ReadMetadataFromXml(long meta)
|
||||||
{
|
{
|
||||||
if (meta < 1 || meta >= _objectOffsets.Length || _objectOffsets[meta] == 0) return;
|
if (meta < 1 || meta >= _objectOffsets.Length || _objectOffsets[meta] == 0) return;
|
||||||
|
|
||||||
@ -1446,7 +1423,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
|
|
||||||
var token = _lexer.NextToken();
|
var token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.ObjectStart)
|
if (token.Type != PdfLexer.TokenType.ObjectStart)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected object header");
|
throw new PdfMetadataExtractorException("Expected object header");
|
||||||
}
|
}
|
||||||
@ -1460,7 +1437,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "Type":
|
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");
|
throw new PdfMetadataExtractorException("Expected /Type to be /Metadata");
|
||||||
}
|
}
|
||||||
@ -1468,7 +1445,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Subtype":
|
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");
|
throw new PdfMetadataExtractorException("Expected /Subtype to be /XML");
|
||||||
}
|
}
|
||||||
@ -1476,22 +1453,22 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Length":
|
case "Length":
|
||||||
if (value.type != PdfLexer.TokenType.Int)
|
if (value.Type != PdfLexer.TokenType.Int)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected integer after /Length");
|
throw new PdfMetadataExtractorException("Expected integer after /Length");
|
||||||
}
|
}
|
||||||
|
|
||||||
length = (long)value.value;
|
length = (long)value.Value;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "Filter":
|
case "Filter":
|
||||||
if (value.type != PdfLexer.TokenType.Name)
|
if (value.Type != PdfLexer.TokenType.Name)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected name after /Filter");
|
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");
|
throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported");
|
||||||
}
|
}
|
||||||
@ -1507,7 +1484,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
|
|
||||||
token = _lexer.NextToken();
|
token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.StreamStart)
|
if (token.Type != PdfLexer.TokenType.StreamStart)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected xref stream after dictionary");
|
throw new PdfMetadataExtractorException("Expected xref stream after dictionary");
|
||||||
}
|
}
|
||||||
@ -1567,7 +1544,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
var token = _lexer.NextToken();
|
var token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type != PdfLexer.TokenType.DictionaryStart)
|
if (token.Type != PdfLexer.TokenType.DictionaryStart)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected dictionary");
|
throw new PdfMetadataExtractorException("Expected dictionary");
|
||||||
}
|
}
|
||||||
@ -1576,15 +1553,16 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
token = _lexer.NextToken();
|
token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type == PdfLexer.TokenType.DictionaryEnd)
|
if (token.Type == PdfLexer.TokenType.DictionaryEnd)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
else if (token.type == PdfLexer.TokenType.Name)
|
|
||||||
|
if (token.Type == PdfLexer.TokenType.Name)
|
||||||
{
|
{
|
||||||
var value = _lexer.NextToken();
|
var value = _lexer.NextToken();
|
||||||
|
|
||||||
if (!handler((string)token.value, value)) {
|
if (!handler((string)token.Value, value)) {
|
||||||
SkipValue(value);
|
SkipValue(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1599,7 +1577,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
var token = existingToken ?? _lexer.NextToken();
|
var token = existingToken ?? _lexer.NextToken();
|
||||||
|
|
||||||
switch (token.type)
|
switch (token.Type)
|
||||||
{
|
{
|
||||||
case PdfLexer.TokenType.Bool:
|
case PdfLexer.TokenType.Bool:
|
||||||
case PdfLexer.TokenType.Int:
|
case PdfLexer.TokenType.Int:
|
||||||
@ -1608,17 +1586,16 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
case PdfLexer.TokenType.String:
|
case PdfLexer.TokenType.String:
|
||||||
case PdfLexer.TokenType.ObjectRef:
|
case PdfLexer.TokenType.ObjectRef:
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PdfLexer.TokenType.ArrayStart:
|
case PdfLexer.TokenType.ArrayStart:
|
||||||
|
{
|
||||||
SkipArray();
|
SkipArray();
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case PdfLexer.TokenType.DictionaryStart:
|
case PdfLexer.TokenType.DictionaryStart:
|
||||||
|
{
|
||||||
SkipDictionary();
|
SkipDictionary();
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new PdfMetadataExtractorException("Unexpected token in SkipValue");
|
throw new PdfMetadataExtractorException("Unexpected token in SkipValue");
|
||||||
}
|
}
|
||||||
@ -1630,7 +1607,7 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
var token = _lexer.NextToken();
|
var token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type == PdfLexer.TokenType.ArrayEnd)
|
if (token.Type == PdfLexer.TokenType.ArrayEnd)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -1645,11 +1622,11 @@ class PdfMetadataExtractor : IPdfMetadataExtractor
|
|||||||
{
|
{
|
||||||
var token = _lexer.NextToken();
|
var token = _lexer.NextToken();
|
||||||
|
|
||||||
if (token.type == PdfLexer.TokenType.DictionaryEnd)
|
if (token.Type == PdfLexer.TokenType.DictionaryEnd)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
else if (token.type != PdfLexer.TokenType.Name)
|
if (token.Type != PdfLexer.TokenType.Name)
|
||||||
{
|
{
|
||||||
throw new PdfMetadataExtractorException("Expected name in dictionary");
|
throw new PdfMetadataExtractorException("Expected name in dictionary");
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,7 @@ public class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
|
Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
|
||||||
? "http://localhost:5020" : "https://plus-next.kavitareader.com";
|
? "http://localhost:5020" : "https://plus.kavitareader.com";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
@ -39,6 +39,10 @@ public interface IDirectoryService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
string BookmarkDirectory { get; }
|
string BookmarkDirectory { get; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Used for random files needed, like images to check against, list of countries, etc
|
||||||
|
/// </summary>
|
||||||
|
string AssetsDirectory { get; }
|
||||||
|
/// <summary>
|
||||||
/// Lists out top-level folders for a given directory. Filters out System and Hidden folders.
|
/// Lists out top-level folders for a given directory. Filters out System and Hidden folders.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="rootPath">Absolute path of directory to scan.</param>
|
/// <param name="rootPath">Absolute path of directory to scan.</param>
|
||||||
@ -87,6 +91,7 @@ public class DirectoryService : IDirectoryService
|
|||||||
public string TempDirectory { get; }
|
public string TempDirectory { get; }
|
||||||
public string ConfigDirectory { get; }
|
public string ConfigDirectory { get; }
|
||||||
public string BookmarkDirectory { get; }
|
public string BookmarkDirectory { get; }
|
||||||
|
public string AssetsDirectory { get; }
|
||||||
public string SiteThemeDirectory { get; }
|
public string SiteThemeDirectory { get; }
|
||||||
public string FaviconDirectory { get; }
|
public string FaviconDirectory { get; }
|
||||||
public string LocalizationDirectory { get; }
|
public string LocalizationDirectory { get; }
|
||||||
@ -120,6 +125,8 @@ public class DirectoryService : IDirectoryService
|
|||||||
ExistOrCreate(TempDirectory);
|
ExistOrCreate(TempDirectory);
|
||||||
BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks");
|
BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks");
|
||||||
ExistOrCreate(BookmarkDirectory);
|
ExistOrCreate(BookmarkDirectory);
|
||||||
|
AssetsDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "Assets");
|
||||||
|
ExistOrCreate(AssetsDirectory);
|
||||||
SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes");
|
SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes");
|
||||||
ExistOrCreate(SiteThemeDirectory);
|
ExistOrCreate(SiteThemeDirectory);
|
||||||
FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons");
|
FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons");
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
@ -11,9 +10,11 @@ using API.Entities.Interfaces;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NetVips;
|
using NetVips;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
using SixLabors.ImageSharp.PixelFormats;
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
using SixLabors.ImageSharp.Processing;
|
using SixLabors.ImageSharp.Processing;
|
||||||
using SixLabors.ImageSharp.Processing.Processors.Quantization;
|
using SixLabors.ImageSharp.Processing.Processors.Quantization;
|
||||||
|
using Color = System.Drawing.Color;
|
||||||
using Image = NetVips.Image;
|
using Image = NetVips.Image;
|
||||||
|
|
||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
@ -748,6 +749,7 @@ public class ImageService : IImageService
|
|||||||
entity.SecondaryColor = colors.Secondary;
|
entity.SecondaryColor = colors.Secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static Color HexToRgb(string? hex)
|
public static Color HexToRgb(string? hex)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null");
|
if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null");
|
||||||
|
@ -752,7 +752,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
_unitOfWork.SeriesRepository.Update(series);
|
_unitOfWork.SeriesRepository.Update(series);
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
await DownloadAndSetCovers(upstreamArtists);
|
await DownloadAndSetPersonCovers(upstreamArtists);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -809,7 +809,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
_unitOfWork.SeriesRepository.Update(series);
|
_unitOfWork.SeriesRepository.Update(series);
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
await DownloadAndSetCovers(upstreamWriters);
|
await DownloadAndSetPersonCovers(upstreamWriters);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -1058,7 +1058,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false);
|
await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false, true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -1066,7 +1066,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DownloadAndSetCovers(List<SeriesStaffDto> people)
|
private async Task DownloadAndSetPersonCovers(List<SeriesStaffDto> people)
|
||||||
{
|
{
|
||||||
foreach (var staff in people)
|
foreach (var staff in people)
|
||||||
{
|
{
|
||||||
@ -1075,7 +1075,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value);
|
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value);
|
||||||
if (person != null && !string.IsNullOrEmpty(staff.ImageUrl) && string.IsNullOrEmpty(person.CoverImage))
|
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
|
try
|
||||||
{
|
{
|
||||||
return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids")
|
var ret = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids")
|
||||||
.WithKavitaPlusHeaders(license)
|
.WithKavitaPlusHeaders(license)
|
||||||
.PostJsonAsync(payload)
|
.PostJsonAsync(payload)
|
||||||
.ReceiveJson<ExternalSeriesDetailDto>();
|
.ReceiveJson<ExternalSeriesDetailDto>();
|
||||||
|
|
||||||
|
ret.Summary = StringHelper.SquashBreaklines(ret.Summary);
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
@ -33,6 +33,8 @@ public interface ICleanupService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Task CleanupWantToRead();
|
Task CleanupWantToRead();
|
||||||
|
|
||||||
|
Task ConsolidateProgress();
|
||||||
}
|
}
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cleans up after operations on reoccurring basis
|
/// Cleans up after operations on reoccurring basis
|
||||||
@ -74,13 +76,21 @@ public class CleanupService : ICleanupService
|
|||||||
|
|
||||||
_logger.LogInformation("Starting Cleanup");
|
_logger.LogInformation("Starting Cleanup");
|
||||||
await SendProgress(0F, "Starting cleanup");
|
await SendProgress(0F, "Starting cleanup");
|
||||||
|
|
||||||
_logger.LogInformation("Cleaning temp directory");
|
_logger.LogInformation("Cleaning temp directory");
|
||||||
_directoryService.ClearDirectory(_directoryService.TempDirectory);
|
_directoryService.ClearDirectory(_directoryService.TempDirectory);
|
||||||
|
|
||||||
await SendProgress(0.1F, "Cleaning temp directory");
|
await SendProgress(0.1F, "Cleaning temp directory");
|
||||||
CleanupCacheAndTempDirectories();
|
CleanupCacheAndTempDirectories();
|
||||||
|
|
||||||
await SendProgress(0.25F, "Cleaning old database backups");
|
await SendProgress(0.25F, "Cleaning old database backups");
|
||||||
_logger.LogInformation("Cleaning old database backups");
|
_logger.LogInformation("Cleaning old database backups");
|
||||||
await CleanupBackups();
|
await CleanupBackups();
|
||||||
|
|
||||||
|
await SendProgress(0.35F, "Consolidating Progress Events");
|
||||||
|
_logger.LogInformation("Consolidating Progress Events");
|
||||||
|
await ConsolidateProgress();
|
||||||
|
|
||||||
await SendProgress(0.50F, "Cleaning deleted cover images");
|
await SendProgress(0.50F, "Cleaning deleted cover images");
|
||||||
_logger.LogInformation("Cleaning deleted cover images");
|
_logger.LogInformation("Cleaning deleted cover images");
|
||||||
await DeleteSeriesCoverImages();
|
await DeleteSeriesCoverImages();
|
||||||
@ -226,6 +236,61 @@ public class CleanupService : ICleanupService
|
|||||||
_logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now);
|
_logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
public async Task CleanupLogs()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Performing cleanup of logs directory");
|
_logger.LogInformation("Performing cleanup of logs directory");
|
||||||
|
@ -19,6 +19,7 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NetVips;
|
using NetVips;
|
||||||
|
|
||||||
|
|
||||||
namespace API.Services.Tasks.Metadata;
|
namespace API.Services.Tasks.Metadata;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
@ -28,8 +29,8 @@ public interface ICoverDbService
|
|||||||
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
||||||
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat);
|
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat);
|
||||||
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url);
|
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url);
|
||||||
Task SetPersonCoverByUrl(Person person, 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);
|
Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -461,13 +462,39 @@ public class CoverDbService : ICoverDbService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true)
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="person"></param>
|
||||||
|
/// <param name="url"></param>
|
||||||
|
/// <param name="fromBase64"></param>
|
||||||
|
/// <param name="checkNoImagePlaceholder">Will check against all known null image placeholders to avoid writing it</param>
|
||||||
|
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))
|
if (!string.IsNullOrEmpty(url))
|
||||||
{
|
{
|
||||||
var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64);
|
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))
|
if (!string.IsNullOrEmpty(filePath))
|
||||||
{
|
{
|
||||||
person.CoverImage = filePath;
|
person.CoverImage = filePath;
|
||||||
@ -498,7 +525,8 @@ public class CoverDbService : ICoverDbService
|
|||||||
/// <param name="series"></param>
|
/// <param name="series"></param>
|
||||||
/// <param name="url"></param>
|
/// <param name="url"></param>
|
||||||
/// <param name="fromBase64"></param>
|
/// <param name="fromBase64"></param>
|
||||||
public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true)
|
/// <param name="chooseBetterImage">If images are similar, will choose the higher quality image</param>
|
||||||
|
public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(url))
|
if (!string.IsNullOrEmpty(url))
|
||||||
{
|
{
|
||||||
@ -506,6 +534,13 @@ public class CoverDbService : ICoverDbService
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(filePath))
|
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.CoverImage = filePath;
|
||||||
series.CoverImageLocked = true;
|
series.CoverImageLocked = true;
|
||||||
_imageService.UpdateColorScape(series);
|
_imageService.UpdateColorScape(series);
|
||||||
|
@ -515,7 +515,7 @@ public class ScannerService : IScannerService
|
|||||||
var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths));
|
var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths));
|
||||||
if (!shouldUseLibraryScan)
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
|
|
||||||
[GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)]
|
[GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)]
|
||||||
private static partial Regex BlogPartRegex();
|
private static partial Regex BlogPartRegex();
|
||||||
private static string _cacheFilePath;
|
private readonly string _cacheFilePath;
|
||||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
public VersionUpdaterService(ILogger<VersionUpdaterService> logger, IEventHub eventHub, IDirectoryService directoryService)
|
public VersionUpdaterService(ILogger<VersionUpdaterService> logger, IEventHub eventHub, IDirectoryService directoryService)
|
||||||
@ -131,6 +131,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
Theme = sections.TryGetValue("Theme", out var theme) ? theme : [],
|
Theme = sections.TryGetValue("Theme", out var theme) ? theme : [],
|
||||||
Developer = sections.TryGetValue("Developer", out var developer) ? developer : [],
|
Developer = sections.TryGetValue("Developer", out var developer) ? developer : [],
|
||||||
Api = sections.TryGetValue("Api", out var api) ? api : [],
|
Api = sections.TryGetValue("Api", out var api) ? api : [],
|
||||||
|
FeatureRequests = sections.TryGetValue("Feature Requests", out var frs) ? frs : [],
|
||||||
BlogPart = _markdown.Transform(blogPart.Trim()),
|
BlogPart = _markdown.Transform(blogPart.Trim()),
|
||||||
UpdateBody = _markdown.Transform(prInfo.Body.Trim())
|
UpdateBody = _markdown.Transform(prInfo.Body.Trim())
|
||||||
};
|
};
|
||||||
@ -305,7 +306,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
return updateDtos;
|
return updateDtos;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IList<UpdateNotificationDto>?> TryGetCachedReleases()
|
private async Task<IList<UpdateNotificationDto>?> TryGetCachedReleases()
|
||||||
{
|
{
|
||||||
if (!File.Exists(_cacheFilePath)) return null;
|
if (!File.Exists(_cacheFilePath)) return null;
|
||||||
|
|
||||||
@ -376,6 +377,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [],
|
Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [],
|
||||||
Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [],
|
Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [],
|
||||||
Api = parsedSections.TryGetValue("Api", out var api) ? api : [],
|
Api = parsedSections.TryGetValue("Api", out var api) ? api : [],
|
||||||
|
FeatureRequests = parsedSections.TryGetValue("Feature Requests", out var frs) ? frs : [],
|
||||||
BlogPart = blogPart
|
BlogPart = blogPart
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -492,7 +494,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class PullRequestInfo
|
private sealed class PullRequestInfo
|
||||||
{
|
{
|
||||||
public required string Title { get; init; }
|
public required string Title { get; init; }
|
||||||
public required string Body { get; init; }
|
public required string Body { get; init; }
|
||||||
@ -501,25 +503,25 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
public required int Number { get; init; }
|
public required int Number { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CommitInfo
|
private sealed class CommitInfo
|
||||||
{
|
{
|
||||||
public required string Sha { get; init; }
|
public required string Sha { get; init; }
|
||||||
public required CommitDetail Commit { get; init; }
|
public required CommitDetail Commit { get; init; }
|
||||||
public required string Html_Url { get; init; }
|
public required string Html_Url { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CommitDetail
|
private sealed class CommitDetail
|
||||||
{
|
{
|
||||||
public required string Message { get; init; }
|
public required string Message { get; init; }
|
||||||
public required CommitAuthor Author { get; init; }
|
public required CommitAuthor Author { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CommitAuthor
|
private sealed class CommitAuthor
|
||||||
{
|
{
|
||||||
public required string Date { get; init; }
|
public required string Date { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class NightlyInfo
|
private sealed class NightlyInfo
|
||||||
{
|
{
|
||||||
public required string Version { get; init; }
|
public required string Version { get; init; }
|
||||||
public required int PrNumber { get; init; }
|
public required int PrNumber { get; init; }
|
||||||
|
@ -4,6 +4,7 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs.Account;
|
using API.DTOs.Account;
|
||||||
@ -36,6 +37,7 @@ public class TokenService : ITokenService
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly SymmetricSecurityKey _key;
|
private readonly SymmetricSecurityKey _key;
|
||||||
private const string RefreshTokenName = "RefreshToken";
|
private const string RefreshTokenName = "RefreshToken";
|
||||||
|
private static readonly SemaphoreSlim _refreshTokenLock = new SemaphoreSlim(1, 1);
|
||||||
|
|
||||||
public TokenService(IConfiguration config, UserManager<AppUser> userManager, ILogger<TokenService> logger, IUnitOfWork unitOfWork)
|
public TokenService(IConfiguration config, UserManager<AppUser> userManager, ILogger<TokenService> logger, IUnitOfWork unitOfWork)
|
||||||
{
|
{
|
||||||
@ -81,6 +83,8 @@ public class TokenService : ITokenService
|
|||||||
|
|
||||||
public async Task<TokenRequestDto?> ValidateRefreshToken(TokenRequestDto request)
|
public async Task<TokenRequestDto?> ValidateRefreshToken(TokenRequestDto request)
|
||||||
{
|
{
|
||||||
|
await _refreshTokenLock.WaitAsync();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
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");
|
_logger.LogDebug("[RefreshToken] failed to validate due to not finding user in RefreshToken");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await _userManager.FindByNameAsync(username);
|
var user = await _userManager.FindByNameAsync(username);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
@ -98,13 +103,19 @@ public class TokenService : ITokenService
|
|||||||
return null;
|
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)))
|
if (!validated && tokenContent.ValidTo <= DateTime.UtcNow.Add(TimeSpan.FromHours(1)))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token");
|
_logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the old refresh token first
|
||||||
|
await _userManager.RemoveAuthenticationTokenAsync(user,
|
||||||
|
TokenOptions.DefaultProvider,
|
||||||
|
RefreshTokenName);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
user.UpdateLastActive();
|
user.UpdateLastActive();
|
||||||
@ -121,7 +132,8 @@ public class TokenService : ITokenService
|
|||||||
Token = await CreateToken(user),
|
Token = await CreateToken(user),
|
||||||
RefreshToken = await CreateRefreshToken(user)
|
RefreshToken = await CreateRefreshToken(user)
|
||||||
};
|
};
|
||||||
} catch (SecurityTokenExpiredException ex)
|
}
|
||||||
|
catch (SecurityTokenExpiredException ex)
|
||||||
{
|
{
|
||||||
// Handle expired token
|
// Handle expired token
|
||||||
_logger.LogError(ex, "Failed to validate refresh 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");
|
_logger.LogError(ex, "Failed to validate refresh token");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshTokenLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetJwtFromUser(AppUser user)
|
public async Task<string?> GetJwtFromUser(AppUser user)
|
||||||
|
@ -163,11 +163,15 @@ $image-width: 160px;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
|
|
||||||
|
:first-child {
|
||||||
|
min-width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 98px;
|
max-width: 90px;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -17,6 +17,7 @@ export interface UpdateVersionEvent {
|
|||||||
theme: Array<string>;
|
theme: Array<string>;
|
||||||
developer: Array<string>;
|
developer: Array<string>;
|
||||||
api: Array<string>;
|
api: Array<string>;
|
||||||
|
featureRequests: Array<string>;
|
||||||
/**
|
/**
|
||||||
* The part above the changelog part
|
* The part above the changelog part
|
||||||
*/
|
*/
|
||||||
|
@ -45,6 +45,9 @@ export class ThemeService {
|
|||||||
private themesSource = new ReplaySubject<SiteTheme[]>(1);
|
private themesSource = new ReplaySubject<SiteTheme[]>(1);
|
||||||
public themes$ = this.themesSource.asObservable();
|
public themes$ = this.themesSource.asObservable();
|
||||||
|
|
||||||
|
private darkModeSource = new ReplaySubject<boolean>(1);
|
||||||
|
public isDarkMode$ = this.darkModeSource.asObservable();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maintain a cache of themes. SignalR will inform us if we need to refresh cache
|
* 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.currentThemeSource.next(theme);
|
||||||
|
this.darkModeSource.next(this.isDarkTheme());
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.currentThemeSource.next(theme);
|
this.currentThemeSource.next(theme);
|
||||||
|
this.darkModeSource.next(this.isDarkTheme());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only time themes isn't already loaded is on first load
|
// Only time themes isn't already loaded is on first load
|
||||||
|
@ -76,7 +76,7 @@ export class VersionService implements OnDestroy{
|
|||||||
this.modalOpen = true;
|
this.modalOpen = true;
|
||||||
|
|
||||||
this.serverService.getChangelog(1).subscribe(changelog => {
|
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.version = version;
|
||||||
ref.componentInstance.update = changelog[0];
|
ref.componentInstance.update = changelog[0];
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@
|
|||||||
<div class="mb-3" style="width: 100%">
|
<div class="mb-3" style="width: 100%">
|
||||||
<app-setting-switch [title]="t('dont-match-label')" [subtitle]="t('dont-match-tooltip')">
|
<app-setting-switch [title]="t('dont-match-label')" [subtitle]="t('dont-match-tooltip')">
|
||||||
<ng-template #switch>
|
<ng-template #switch>
|
||||||
<div class="form-check form-switch float-end">
|
<div class="form-check form-switch">
|
||||||
<input id="dont-match" type="checkbox" class="form-check-input" formControlName="dontMatch" role="switch">
|
<input id="dont-match" type="checkbox" class="form-check-input" formControlName="dontMatch" role="switch">
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@ -50,8 +50,7 @@
|
|||||||
@if (!formGroup.get('dontMatch')?.value) {
|
@if (!formGroup.get('dontMatch')?.value) {
|
||||||
<app-loading [loading]="isLoading"></app-loading>
|
<app-loading [loading]="isLoading"></app-loading>
|
||||||
@for(item of matches; track item.series.name) {
|
@for(item of matches; track item.series.name) {
|
||||||
<app-match-series-result-item [item]="item" (selected)="selectMatch($event)"></app-match-series-result-item>
|
<app-match-series-result-item [item]="item" [isDarkMode]="(themeService.isDarkMode$ | async)!" (selected)="selectMatch($event)"></app-match-series-result-item>
|
||||||
<div class="setting-section-break"></div>
|
|
||||||
} @empty {
|
} @empty {
|
||||||
@if (!isLoading) {
|
@if (!isLoading) {
|
||||||
{{t('no-results')}}
|
{{t('no-results')}}
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
.setting-section-break {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
@ -10,11 +10,14 @@ import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-m
|
|||||||
import {ToastrService} from "ngx-toastr";
|
import {ToastrService} from "ngx-toastr";
|
||||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.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({
|
@Component({
|
||||||
selector: 'app-match-series-modal',
|
selector: 'app-match-series-modal',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
TranslocoDirective,
|
TranslocoDirective,
|
||||||
MatchSeriesResultItemComponent,
|
MatchSeriesResultItemComponent,
|
||||||
LoadingComponent,
|
LoadingComponent,
|
||||||
@ -31,6 +34,7 @@ export class MatchSeriesModalComponent implements OnInit {
|
|||||||
private readonly seriesService = inject(SeriesService);
|
private readonly seriesService = inject(SeriesService);
|
||||||
private readonly modalService = inject(NgbActiveModal);
|
private readonly modalService = inject(NgbActiveModal);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
|
protected readonly themeService = inject(ThemeService);
|
||||||
|
|
||||||
@Input({required: true}) series!: Series;
|
@Input({required: true}) series!: Series;
|
||||||
|
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
<ng-container *transloco="let t; read:'match-series-result-item'">
|
<ng-container *transloco="let t; read:'match-series-result-item'">
|
||||||
|
<div class="match-item-container p-3 mt-3 {{isDarkMode ? 'dark' : 'light'}}">
|
||||||
<div class="d-flex p-1 clickable" (click)="selectItem()">
|
<div class="d-flex clickable match-item" (click)="selectItem()">
|
||||||
<div style="width: 32px" class="me-1">
|
<div class="me-1">
|
||||||
@if (item.series.coverUrl) {
|
@if (item.series.coverUrl) {
|
||||||
<app-image class="me-3 search-result" width="32px" [imageUrl]="item.series.coverUrl"></app-image>
|
<app-image class="me-3 search-result" width="100px" [imageUrl]="item.series.coverUrl"></app-image>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-1">
|
<div class="ms-1">
|
||||||
<div>{{item.series.name}}</div>
|
<div><span class="title">{{item.series.name}}</span> <span class="me-1 float-end">({{item.matchRating | translocoPercent}})</span></div>
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
@for(synm of item.series.synonyms; track synm; let last = $last) {
|
@for(synm of item.series.synonyms; track synm; let last = $last) {
|
||||||
{{synm}}
|
{{synm}}
|
||||||
@ -19,6 +19,7 @@
|
|||||||
@if (item.series.summary) {
|
@if (item.series.summary) {
|
||||||
<div>
|
<div>
|
||||||
<app-read-more [text]="item.series.summary" [showToggle]="false"></app-read-more>
|
<app-read-more [text]="item.series.summary" [showToggle]="false"></app-read-more>
|
||||||
|
<span class="me-1"><a (click)="$event.stopPropagation()" [href]="item.series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@ -30,8 +31,7 @@
|
|||||||
<span class="ms-2">{{t('updating-metadata-status')}}</span>
|
<span class="ms-2">{{t('updating-metadata-status')}}</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="d-flex p-1 justify-content-between">
|
<div class="d-flex pt-3 justify-content-between">
|
||||||
<span class="me-1"><a (click)="$event.stopPropagation()" [href]="item.series.siteUrl" rel="noreferrer noopener" target="_blank">{{t('details')}}</a></span>
|
|
||||||
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
|
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
|
||||||
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
|
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
|
||||||
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
|
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
|
||||||
@ -40,11 +40,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<span class="me-1">{{item.series.plusMediaFormat | plusMediaFormat}}</span>
|
<span class="me-1">{{item.series.plusMediaFormat | plusMediaFormat}}</span>
|
||||||
<span class="me-1">({{item.matchRating | translocoPercent}})</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -37,6 +37,7 @@ export class MatchSeriesResultItemComponent {
|
|||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
@Input({required: true}) item!: ExternalSeriesMatch;
|
@Input({required: true}) item!: ExternalSeriesMatch;
|
||||||
|
@Input({required: true}) isDarkMode = true;
|
||||||
@Output() selected: EventEmitter<ExternalSeriesMatch> = new EventEmitter();
|
@Output() selected: EventEmitter<ExternalSeriesMatch> = new EventEmitter();
|
||||||
|
|
||||||
isSelected = false;
|
isSelected = false;
|
||||||
|
@ -165,7 +165,7 @@
|
|||||||
@if(settingsForm.get('blacklist'); as formControl) {
|
@if(settingsForm.get('blacklist'); as formControl) {
|
||||||
<app-setting-item [title]="t('blacklist-label')" [subtitle]="t('blacklist-tooltip')">
|
<app-setting-item [title]="t('blacklist-label')" [subtitle]="t('blacklist-tooltip')">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
@let val = (formControl.value || '').split(',');
|
@let val = breakTags(formControl.value);
|
||||||
|
|
||||||
@for(opt of val; track opt) {
|
@for(opt of val; track opt) {
|
||||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||||
@ -184,7 +184,7 @@
|
|||||||
@if(settingsForm.get('whitelist'); as formControl) {
|
@if(settingsForm.get('whitelist'); as formControl) {
|
||||||
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
|
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
@let val = (formControl.value || '').split(',');
|
@let val = breakTags(formControl.value);
|
||||||
|
|
||||||
@for(opt of val; track opt) {
|
@for(opt of val; track opt) {
|
||||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||||
|
@ -149,6 +149,15 @@ export class ManageMetadataSettingsComponent implements OnInit {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
breakTags(csString: string) {
|
||||||
|
if (csString) {
|
||||||
|
return csString.split(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
packData(withFieldMappings: boolean = true) {
|
packData(withFieldMappings: boolean = true) {
|
||||||
const model = this.settingsForm.value;
|
const model = this.settingsForm.value;
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
<app-update-section [items]="update.theme" [title]="t('theme')"></app-update-section>
|
<app-update-section [items]="update.theme" [title]="t('theme')"></app-update-section>
|
||||||
<app-update-section [items]="update.removed" [title]="t('removed')"></app-update-section>
|
<app-update-section [items]="update.removed" [title]="t('removed')"></app-update-section>
|
||||||
<app-update-section [items]="update.api" [title]="t('api')"></app-update-section>
|
<app-update-section [items]="update.api" [title]="t('api')"></app-update-section>
|
||||||
|
<app-update-section [items]="update.featureRequests" [title]="t('feature-requests')"></app-update-section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showExtras) {
|
@if (showExtras) {
|
||||||
|
@ -33,7 +33,7 @@ export class ChangelogComponent implements OnInit {
|
|||||||
isLoading: boolean = true;
|
isLoading: boolean = true;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.serverService.getChangelog(10).subscribe(updates => {
|
this.serverService.getChangelog(30).subscribe(updates => {
|
||||||
this.updates = updates;
|
this.updates = updates;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<ng-container *transloco="let t; read:'new-version-modal'">
|
<ng-container *transloco="let t; read:'new-version-modal'">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body scrollable-modal">
|
<div class="modal-body scrollable-modal">
|
||||||
@if (update) {
|
@if (update) {
|
||||||
@ -9,7 +8,6 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
|
|
||||||
<button type="button" class="btn btn-primary" (click)="refresh()">{{t('refresh')}}</button>
|
<button type="button" class="btn btn-primary" (click)="refresh()">{{t('refresh')}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -649,7 +649,7 @@
|
|||||||
|
|
||||||
<h4>{{t('volumes-title')}}</h4>
|
<h4>{{t('volumes-title')}}</h4>
|
||||||
@if (isLoadingVolumes) {
|
@if (isLoadingVolumes) {
|
||||||
<div class="spinner-border text-secondary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
<span class="visually-hidden">{{t('loading')}}</span>
|
<span class="visually-hidden">{{t('loading')}}</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
|
@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode';
|
|||||||
import { FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from '../../_models/reader-enums';
|
import { FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from '../../_models/reader-enums';
|
||||||
import { ReaderSetting } from '../../_models/reader-setting';
|
import { ReaderSetting } from '../../_models/reader-setting';
|
||||||
import { ImageRenderer } from '../../_models/renderer';
|
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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
|
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
|
||||||
import { NgClass, AsyncPipe } from '@angular/common';
|
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 {
|
ngOnInit(): void {
|
||||||
this.readerSettings$.pipe(takeUntilDestroyed(this.destroyRef), tap((value: ReaderSetting) => {
|
this.readerSettings$.pipe(takeUntilDestroyed(this.destroyRef), tap((value: ReaderSetting) => {
|
||||||
|
@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode';
|
|||||||
import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
|
import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
|
||||||
import { ReaderSetting } from '../../_models/reader-setting';
|
import { ReaderSetting } from '../../_models/reader-setting';
|
||||||
import { DEBUG_MODES } from '../../_models/renderer';
|
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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
|
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) { }
|
@Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode';
|
|||||||
import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
|
import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
|
||||||
import { ReaderSetting } from '../../_models/reader-setting';
|
import { ReaderSetting } from '../../_models/reader-setting';
|
||||||
import { DEBUG_MODES, ImageRenderer } from '../../_models/renderer';
|
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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
|
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ export class DoubleRendererComponent implements OnInit, ImageRenderer {
|
|||||||
protected readonly LayoutMode = LayoutMode;
|
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) { }
|
@Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
@ -18,7 +18,7 @@ import { LayoutMode } from '../../_models/layout-mode';
|
|||||||
import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
|
import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
|
||||||
import { ReaderSetting } from '../../_models/reader-setting';
|
import { ReaderSetting } from '../../_models/reader-setting';
|
||||||
import { DEBUG_MODES, ImageRenderer } from '../../_models/renderer';
|
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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
|
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) { }
|
@Inject(DOCUMENT) private document: Document, public readerService: ReaderService) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
@ -22,7 +22,7 @@ import { ScrollService } from 'src/app/_services/scroll.service';
|
|||||||
import { ReaderService } from '../../../_services/reader.service';
|
import { ReaderService } from '../../../_services/reader.service';
|
||||||
import { PAGING_DIRECTION } from '../../_models/reader-enums';
|
import { PAGING_DIRECTION } from '../../_models/reader-enums';
|
||||||
import { WebtoonImage } from '../../_models/webtoon-image';
|
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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {InfiniteScrollModule} from "ngx-infinite-scroll";
|
import {InfiniteScrollModule} from "ngx-infinite-scroll";
|
||||||
@ -66,7 +66,7 @@ const enum DEBUG_MODES {
|
|||||||
})
|
})
|
||||||
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit {
|
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 readerService = inject(ReaderService);
|
||||||
private readonly renderer = inject(Renderer2);
|
private readonly renderer = inject(Renderer2);
|
||||||
private readonly scrollService = inject(ScrollService);
|
private readonly scrollService = inject(ScrollService);
|
||||||
|
@ -52,7 +52,7 @@ import {ReaderService} from 'src/app/_services/reader.service';
|
|||||||
import {LayoutMode} from '../../_models/layout-mode';
|
import {LayoutMode} from '../../_models/layout-mode';
|
||||||
import {FITTING_OPTION, PAGING_DIRECTION} from '../../_models/reader-enums';
|
import {FITTING_OPTION, PAGING_DIRECTION} from '../../_models/reader-enums';
|
||||||
import {ReaderSetting} from '../../_models/reader-setting';
|
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 {CanvasRendererComponent} from '../canvas-renderer/canvas-renderer.component';
|
||||||
import {DoubleRendererComponent} from '../double-renderer/double-renderer.component';
|
import {DoubleRendererComponent} from '../double-renderer/double-renderer.component';
|
||||||
import {DoubleReverseRendererComponent} from '../double-reverse-renderer/double-reverse-renderer.component';
|
import {DoubleReverseRendererComponent} from '../double-reverse-renderer/double-reverse-renderer.component';
|
||||||
@ -99,7 +99,7 @@ enum KeyDirection {
|
|||||||
templateUrl: './manga-reader.component.html',
|
templateUrl: './manga-reader.component.html',
|
||||||
styleUrls: ['./manga-reader.component.scss'],
|
styleUrls: ['./manga-reader.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
providers: [ManagaReaderService],
|
providers: [MangaReaderService],
|
||||||
animations: [
|
animations: [
|
||||||
trigger('slideFromTop', [
|
trigger('slideFromTop', [
|
||||||
state('in', style({ transform: 'translateY(0)' })),
|
state('in', style({ transform: 'translateY(0)' })),
|
||||||
@ -153,7 +153,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
public readonly readerService = inject(ReaderService);
|
public readonly readerService = inject(ReaderService);
|
||||||
public readonly utilityService = inject(UtilityService);
|
public readonly utilityService = inject(UtilityService);
|
||||||
public readonly mangaReaderService = inject(ManagaReaderService);
|
public readonly mangaReaderService = inject(MangaReaderService);
|
||||||
|
|
||||||
protected readonly KeyDirection = KeyDirection;
|
protected readonly KeyDirection = KeyDirection;
|
||||||
protected readonly ReaderMode = ReaderMode;
|
protected readonly ReaderMode = ReaderMode;
|
||||||
|
@ -17,7 +17,7 @@ import { LayoutMode } from '../../_models/layout-mode';
|
|||||||
import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
|
import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
|
||||||
import { ReaderSetting } from '../../_models/reader-setting';
|
import { ReaderSetting } from '../../_models/reader-setting';
|
||||||
import { ImageRenderer } from '../../_models/renderer';
|
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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
|
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
|
|||||||
get ReaderMode() {return ReaderMode;}
|
get ReaderMode() {return ReaderMode;}
|
||||||
get LayoutMode() {return LayoutMode;}
|
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) { }
|
@Inject(DOCUMENT) private document: Document) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
@ -6,12 +6,11 @@ import { ChapterInfo } from '../_models/chapter-info';
|
|||||||
import { DimensionMap } from '../_models/file-dimension';
|
import { DimensionMap } from '../_models/file-dimension';
|
||||||
import { FITTING_OPTION } from '../_models/reader-enums';
|
import { FITTING_OPTION } from '../_models/reader-enums';
|
||||||
import { BookmarkInfo } from 'src/app/_models/manga-reader/bookmark-info';
|
import { BookmarkInfo } from 'src/app/_models/manga-reader/bookmark-info';
|
||||||
import {ReaderMode} from "../../_models/preferences/reader-mode";
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ManagaReaderService {
|
export class MangaReaderService {
|
||||||
|
|
||||||
private pageDimensions: DimensionMap = {};
|
private pageDimensions: DimensionMap = {};
|
||||||
private pairs: {[key: number]: number} = {};
|
private pairs: {[key: number]: number} = {};
|
||||||
@ -168,7 +167,7 @@ export class ManagaReaderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Boost score if width is small (≤ 800px, common in webtoons)
|
// 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
|
score += 0.5; // Adjust weight as needed
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
|||||||
<ng-container *transloco="let t;">
|
<ng-container *transloco="let t;">
|
||||||
<div>
|
<div>
|
||||||
<div class="settings-row g-0 row">
|
<div class="settings-row g-0 row">
|
||||||
<div class="col-10 setting-title">
|
<div class="col-auto setting-title edit">
|
||||||
<h6 class="section-title">
|
<h6 class="section-title">
|
||||||
@if (labelId) {
|
@if (labelId) {
|
||||||
<label class="reset-label" [for]="labelId">{{title}}</label>
|
<label class="reset-label" [for]="labelId">{{title}}</label>
|
||||||
@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 text-end align-self-end justify-content-end">
|
<div class="col-auto text-end align-self-end justify-content-end edit-btn">
|
||||||
@if (showEdit) {
|
@if (showEdit) {
|
||||||
<button type="button" class="btn btn-text btn-sm" (click)="toggleEditMode()" [disabled]="!canEdit">
|
<button type="button" class="btn btn-text btn-sm" (click)="toggleEditMode()" [disabled]="!canEdit">
|
||||||
{{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}}
|
{{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}}
|
||||||
|
@ -11,3 +11,24 @@
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-title.edit:hover ~ .edit-btn{
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease-out;
|
||||||
|
transition-delay: 0.5s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
<ng-container *transloco="let t;">
|
<ng-container *transloco="let t;">
|
||||||
<div>
|
<div>
|
||||||
<div class="row g-0 mb-2">
|
<div class="row g-0 mb-2">
|
||||||
<div class="col-11">
|
<div class="col-auto">
|
||||||
<h6 class="section-title" [id]="id || title">{{title}}</h6>
|
|
||||||
</div>
|
|
||||||
<div class="col-1">
|
|
||||||
@if (switchRef) {
|
@if (switchRef) {
|
||||||
<ng-container [ngTemplateOutlet]="switchRef"></ng-container>
|
<ng-container [ngTemplateOutlet]="switchRef"></ng-container>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<h6 class="section-title" [id]="id || title">{{title}}</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,6 +7,8 @@ h2 {
|
|||||||
|
|
||||||
.main-container {
|
.main-container {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
max-width: 1920px;
|
||||||
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .content-wrapper:not(.closed) {
|
::ng-deep .content-wrapper:not(.closed) {
|
||||||
|
@ -119,7 +119,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
|||||||
|
|
||||||
get Locale() {
|
get Locale() {
|
||||||
if (!this.settingsForm.get('locale')) return 'English';
|
if (!this.settingsForm.get('locale')) return 'English';
|
||||||
console.log(this.locales.filter(l => l.isoCode === this.settingsForm.get('locale')!.value)[0])
|
|
||||||
return this.locales.filter(l => l.isoCode === this.settingsForm.get('locale')!.value)[0].title;
|
return this.locales.filter(l => l.isoCode === this.settingsForm.get('locale')!.value)[0].title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -656,7 +656,8 @@
|
|||||||
"removed": "Removed",
|
"removed": "Removed",
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"published-label": "Published: ",
|
"published-label": "Published: ",
|
||||||
"installed": "{{changelog.installed}}"
|
"installed": "{{changelog.installed}}",
|
||||||
|
"feature-requests": "Feature Requests"
|
||||||
},
|
},
|
||||||
|
|
||||||
"new-version-modal": {
|
"new-version-modal": {
|
||||||
|
@ -51,6 +51,7 @@
|
|||||||
@import './theme/utilities/utilities';
|
@import './theme/utilities/utilities';
|
||||||
@import './theme/utilities/animations';
|
@import './theme/utilities/animations';
|
||||||
@import './theme/utilities/global';
|
@import './theme/utilities/global';
|
||||||
|
@import "./theme/utilities/spinners";
|
||||||
|
|
||||||
|
|
||||||
// Global Styles
|
// Global Styles
|
||||||
|
@ -196,7 +196,7 @@
|
|||||||
|
|
||||||
.side-nav-header {
|
.side-nav-header {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 0.9375rem;
|
font-size: 1rem;
|
||||||
|
|
||||||
&:first-of-type {
|
&:first-of-type {
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.7rem;
|
||||||
@ -204,7 +204,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-nav-item {
|
.side-nav-item {
|
||||||
font-size: 0.9rem;
|
font-size: 1rem;
|
||||||
min-height: 1.875rem;
|
min-height: 1.875rem;
|
||||||
justify-content: unset;
|
justify-content: unset;
|
||||||
margin-left: 1.125rem;
|
margin-left: 1.125rem;
|
||||||
@ -218,7 +218,7 @@
|
|||||||
.side-nav-text {
|
.side-nav-text {
|
||||||
text-align: unset;
|
text-align: unset;
|
||||||
margin-left: 0.75rem;
|
margin-left: 0.75rem;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.9rem;
|
||||||
color: #999999;
|
color: #999999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
4
UI/Web/src/theme/utilities/_spinners.scss
Normal file
4
UI/Web/src/theme/utilities/_spinners.scss
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
}
|
@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.1",
|
"openapi": "3.0.1",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Kavita",
|
"title": "Kavita",
|
||||||
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.15",
|
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.16",
|
||||||
"license": {
|
"license": {
|
||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
@ -24273,6 +24273,13 @@
|
|||||||
},
|
},
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"featureRequests": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"blogPart": {
|
"blogPart": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The part above the changelog part",
|
"description": "The part above the changelog part",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user