Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-02-19 15:06:54 -06:00 committed by GitHub
parent b858729c9e
commit 9565fe7360
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 777 additions and 314 deletions

View file

@ -39,6 +39,10 @@ public interface IDirectoryService
/// </summary>
string BookmarkDirectory { get; }
/// <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.
/// </summary>
/// <param name="rootPath">Absolute path of directory to scan.</param>
@ -87,6 +91,7 @@ public class DirectoryService : IDirectoryService
public string TempDirectory { get; }
public string ConfigDirectory { get; }
public string BookmarkDirectory { get; }
public string AssetsDirectory { get; }
public string SiteThemeDirectory { get; }
public string FaviconDirectory { get; }
public string LocalizationDirectory { get; }
@ -120,6 +125,8 @@ public class DirectoryService : IDirectoryService
ExistOrCreate(TempDirectory);
BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks");
ExistOrCreate(BookmarkDirectory);
AssetsDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "Assets");
ExistOrCreate(AssetsDirectory);
SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes");
ExistOrCreate(SiteThemeDirectory);
FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons");

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Numerics;
@ -11,9 +10,11 @@ using API.Entities.Interfaces;
using API.Extensions;
using Microsoft.Extensions.Logging;
using NetVips;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
using Color = System.Drawing.Color;
using Image = NetVips.Image;
namespace API.Services;
@ -748,6 +749,7 @@ public class ImageService : IImageService
entity.SecondaryColor = colors.Secondary;
}
public static Color HexToRgb(string? hex)
{
if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null");

View file

@ -752,7 +752,7 @@ public class ExternalMetadataService : IExternalMetadataService
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await DownloadAndSetCovers(upstreamArtists);
await DownloadAndSetPersonCovers(upstreamArtists);
return true;
}
@ -809,7 +809,7 @@ public class ExternalMetadataService : IExternalMetadataService
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await DownloadAndSetCovers(upstreamWriters);
await DownloadAndSetPersonCovers(upstreamWriters);
return true;
}
@ -1058,7 +1058,7 @@ public class ExternalMetadataService : IExternalMetadataService
{
try
{
await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false);
await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false, true);
}
catch (Exception ex)
{
@ -1066,7 +1066,7 @@ public class ExternalMetadataService : IExternalMetadataService
}
}
private async Task DownloadAndSetCovers(List<SeriesStaffDto> people)
private async Task DownloadAndSetPersonCovers(List<SeriesStaffDto> people)
{
foreach (var staff in people)
{
@ -1075,7 +1075,7 @@ public class ExternalMetadataService : IExternalMetadataService
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value);
if (person != null && !string.IsNullOrEmpty(staff.ImageUrl) && string.IsNullOrEmpty(person.CoverImage))
{
await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false);
await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false, true);
}
}
}
@ -1326,11 +1326,15 @@ public class ExternalMetadataService : IExternalMetadataService
}
try
{
return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids")
var ret = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids")
.WithKavitaPlusHeaders(license)
.PostJsonAsync(payload)
.ReceiveJson<ExternalSeriesDetailDto>();
ret.Summary = StringHelper.SquashBreaklines(ret.Summary);
return ret;
}
catch (Exception e)
{

View file

@ -33,6 +33,8 @@ public interface ICleanupService
/// </summary>
/// <returns></returns>
Task CleanupWantToRead();
Task ConsolidateProgress();
}
/// <summary>
/// Cleans up after operations on reoccurring basis
@ -74,13 +76,21 @@ public class CleanupService : ICleanupService
_logger.LogInformation("Starting Cleanup");
await SendProgress(0F, "Starting cleanup");
_logger.LogInformation("Cleaning temp directory");
_directoryService.ClearDirectory(_directoryService.TempDirectory);
await SendProgress(0.1F, "Cleaning temp directory");
CleanupCacheAndTempDirectories();
await SendProgress(0.25F, "Cleaning old database backups");
_logger.LogInformation("Cleaning old database backups");
await CleanupBackups();
await SendProgress(0.35F, "Consolidating Progress Events");
_logger.LogInformation("Consolidating Progress Events");
await ConsolidateProgress();
await SendProgress(0.50F, "Cleaning deleted cover images");
_logger.LogInformation("Cleaning deleted cover images");
await DeleteSeriesCoverImages();
@ -226,6 +236,61 @@ public class CleanupService : ICleanupService
_logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now);
}
/// <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()
{
_logger.LogInformation("Performing cleanup of logs directory");

View file

@ -19,6 +19,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NetVips;
namespace API.Services.Tasks.Metadata;
#nullable enable
@ -28,8 +29,8 @@ public interface ICoverDbService
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat);
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url);
Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true);
Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true);
Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false);
Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false);
}
@ -461,13 +462,39 @@ public class CoverDbService : ICoverDbService
return null;
}
public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true)
/// <summary>
///
/// </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))
{
var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64);
// Additional check to see if downloaded image is similar and we have a higher resolution
if (checkNoImagePlaceholder)
{
var matchRating = Path.Join(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg").GetSimilarity(Path.Join(_directoryService.CoverImageDirectory, filePath))!;
if (matchRating >= 0.9f)
{
if (string.IsNullOrEmpty(person.CoverImage))
{
filePath = null;
}
else
{
filePath = Path.GetFileName(Path.Join(_directoryService.CoverImageDirectory, person.CoverImage));
}
}
}
if (!string.IsNullOrEmpty(filePath))
{
person.CoverImage = filePath;
@ -498,7 +525,8 @@ public class CoverDbService : ICoverDbService
/// <param name="series"></param>
/// <param name="url"></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))
{
@ -506,6 +534,13 @@ public class CoverDbService : ICoverDbService
if (!string.IsNullOrEmpty(filePath))
{
// Additional check to see if downloaded image is similar and we have a higher resolution
if (chooseBetterImage)
{
var betterImage = Path.Join(_directoryService.CoverImageDirectory, series.CoverImage).GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!;
filePath = Path.GetFileName(betterImage);
}
series.CoverImage = filePath;
series.CoverImageLocked = true;
_imageService.UpdateColorScape(series);
@ -540,6 +575,6 @@ public class CoverDbService : ICoverDbService
filename, encodeFormat, coverImageSize.GetDimensions().Width);
}
return await DownloadImageFromUrl(filename, encodeFormat, url);
return await DownloadImageFromUrl(filename, encodeFormat, url);
}
}

View file

@ -515,7 +515,7 @@ public class ScannerService : IScannerService
var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths));
if (!shouldUseLibraryScan)
{
_logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name);
_logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders as a library root, using series scan", library.Name);
}

View file

@ -67,7 +67,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
[GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)]
private static partial Regex BlogPartRegex();
private static string _cacheFilePath;
private readonly string _cacheFilePath;
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
public VersionUpdaterService(ILogger<VersionUpdaterService> logger, IEventHub eventHub, IDirectoryService directoryService)
@ -131,6 +131,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
Theme = sections.TryGetValue("Theme", out var theme) ? theme : [],
Developer = sections.TryGetValue("Developer", out var developer) ? developer : [],
Api = sections.TryGetValue("Api", out var api) ? api : [],
FeatureRequests = sections.TryGetValue("Feature Requests", out var frs) ? frs : [],
BlogPart = _markdown.Transform(blogPart.Trim()),
UpdateBody = _markdown.Transform(prInfo.Body.Trim())
};
@ -305,7 +306,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
return updateDtos;
}
private static async Task<IList<UpdateNotificationDto>?> TryGetCachedReleases()
private async Task<IList<UpdateNotificationDto>?> TryGetCachedReleases()
{
if (!File.Exists(_cacheFilePath)) return null;
@ -376,6 +377,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [],
Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [],
Api = parsedSections.TryGetValue("Api", out var api) ? api : [],
FeatureRequests = parsedSections.TryGetValue("Feature Requests", out var frs) ? frs : [],
BlogPart = blogPart
};
}
@ -492,7 +494,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
return item;
}
sealed class PullRequestInfo
private sealed class PullRequestInfo
{
public required string Title { get; init; }
public required string Body { get; init; }
@ -501,25 +503,25 @@ public partial class VersionUpdaterService : IVersionUpdaterService
public required int Number { get; init; }
}
sealed class CommitInfo
private sealed class CommitInfo
{
public required string Sha { get; init; }
public required CommitDetail Commit { get; init; }
public required string Html_Url { get; init; }
}
sealed class CommitDetail
private sealed class CommitDetail
{
public required string Message { get; init; }
public required CommitAuthor Author { get; init; }
}
sealed class CommitAuthor
private sealed class CommitAuthor
{
public required string Date { get; init; }
}
sealed class NightlyInfo
private sealed class NightlyInfo
{
public required string Version { get; init; }
public required int PrNumber { get; init; }

View file

@ -4,6 +4,7 @@ using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Account;
@ -36,6 +37,7 @@ public class TokenService : ITokenService
private readonly IUnitOfWork _unitOfWork;
private readonly SymmetricSecurityKey _key;
private const string RefreshTokenName = "RefreshToken";
private static readonly SemaphoreSlim _refreshTokenLock = new SemaphoreSlim(1, 1);
public TokenService(IConfiguration config, UserManager<AppUser> userManager, ILogger<TokenService> logger, IUnitOfWork unitOfWork)
{
@ -81,6 +83,8 @@ public class TokenService : ITokenService
public async Task<TokenRequestDto?> ValidateRefreshToken(TokenRequestDto request)
{
await _refreshTokenLock.WaitAsync();
try
{
var tokenHandler = new JwtSecurityTokenHandler();
@ -91,6 +95,7 @@ public class TokenService : ITokenService
_logger.LogDebug("[RefreshToken] failed to validate due to not finding user in RefreshToken");
return null;
}
var user = await _userManager.FindByNameAsync(username);
if (user == null)
{
@ -98,13 +103,19 @@ public class TokenService : ITokenService
return null;
}
var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken);
var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider,
RefreshTokenName, request.RefreshToken);
if (!validated && tokenContent.ValidTo <= DateTime.UtcNow.Add(TimeSpan.FromHours(1)))
{
_logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token");
return null;
}
// Remove the old refresh token first
await _userManager.RemoveAuthenticationTokenAsync(user,
TokenOptions.DefaultProvider,
RefreshTokenName);
try
{
user.UpdateLastActive();
@ -121,7 +132,8 @@ public class TokenService : ITokenService
Token = await CreateToken(user),
RefreshToken = await CreateRefreshToken(user)
};
} catch (SecurityTokenExpiredException ex)
}
catch (SecurityTokenExpiredException ex)
{
// Handle expired token
_logger.LogError(ex, "Failed to validate refresh token");
@ -133,6 +145,10 @@ public class TokenService : ITokenService
_logger.LogError(ex, "Failed to validate refresh token");
return null;
}
finally
{
_refreshTokenLock.Release();
}
}
public async Task<string?> GetJwtFromUser(AppUser user)