Kavita/Kavita.Services/Plus/SmartCollectionSyncService.cs
Fesaa c62b20f54b
BE Tech Debt (#4497)
Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
Co-authored-by: Joe Milazzo <josephmajora@gmail.com>
2026-03-07 10:04:08 -08:00

245 lines
9.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Flurl.Http;
using Kavita.API.Database;
using Kavita.API.Repositories;
using Kavita.API.Services.Plus;
using Kavita.API.Services.SignalR;
using Kavita.Common;
using Kavita.Common.Extensions;
using Kavita.Common.Helpers;
using Kavita.Models.DTOs.KavitaPlus.ExternalMetadata;
using Kavita.Models.DTOs.SignalR;
using Kavita.Models.Entities;
using Kavita.Models.Entities.Enums;
using Kavita.Models.Entities.User;
using Kavita.Models.Extensions;
using Microsoft.Extensions.Logging;
namespace Kavita.Services.Plus;
internal sealed class SeriesCollection
{
public required IList<ExternalMetadataIdsDto> Series { get; set; }
public required string Summary { get; set; }
public required string Title { get; set; }
/// <summary>
/// Total items in the source, not what was matched
/// </summary>
public int TotalItems { get; set; }
}
public class SmartCollectionSyncService(
IUnitOfWork unitOfWork,
ILogger<SmartCollectionSyncService> logger,
IEventHub eventHub,
ILicenseService licenseService)
: ISmartCollectionSyncService
{
private const int SyncDelta = -2;
// Allow 50 requests per 24 hours
private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false);
/// <summary>
/// For every Sync-eligible collection, synchronize with upstream
/// </summary>
/// <param name="ct"></param>
/// <returns></returns>
public async Task Sync(CancellationToken ct = default)
{
if (!await licenseService.HasActiveLicense(ct: ct)) return;
var expirationTime = DateTime.UtcNow.AddDays(SyncDelta).Truncate(TimeSpan.TicksPerHour);
var collections = (await unitOfWork.CollectionTagRepository.GetAllCollectionsForSyncing(expirationTime, ct))
.Where(CanSync)
.ToList();
logger.LogInformation("Found {Count} collections to synchronize", collections.Count);
foreach (var collection in collections)
{
try
{
await SyncCollection(collection, ct);
}
catch (RateLimitException)
{
break;
}
}
logger.LogInformation("Synchronization complete");
}
public async Task Sync(int collectionId, CancellationToken ct = default)
{
if (!await licenseService.HasActiveLicense(ct: ct)) return;
var collection = await unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId, CollectionIncludes.Series, ct);
if (!CanSync(collection))
{
logger.LogInformation("Requested to sync {CollectionName} but not applicable to sync", collection!.Title);
return;
}
try
{
await SyncCollection(collection!, ct);
} catch (RateLimitException) {/* Swallow */}
}
private static bool CanSync(AppUserCollection? collection)
{
if (collection is not {Source: ScrobbleProvider.Mal}) return false;
if (string.IsNullOrEmpty(collection.SourceUrl)) return false;
if (collection.LastSyncUtc.Truncate(TimeSpan.TicksPerHour) >= DateTime.UtcNow.AddDays(SyncDelta).Truncate(TimeSpan.TicksPerHour)) return false;
return true;
}
private async Task SyncCollection(AppUserCollection collection, CancellationToken ct = default)
{
if (!RateLimiter.TryAcquire(string.Empty))
{
// Request not allowed due to rate limit
logger.LogDebug("Rate Limit hit for Smart Collection Sync");
throw new RateLimitException();
}
var info = await GetStackInfo(GetStackId(collection.SourceUrl!));
if (info == null)
{
logger.LogInformation("Unable to find collection through Kavita+");
return;
}
// Check each series in the collection against what's in the target
// For everything that's not there, link it up for this user.
logger.LogInformation("Starting Sync on {CollectionName} with {SeriesCount} Series", info.Title, info.TotalItems);
await eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, string.Empty, 0, info.TotalItems, ProgressEventType.Started), ct: ct);
var missingCount = 0;
var missingSeries = new StringBuilder();
var counter = -1;
foreach (var seriesInfo in info.Series.OrderBy(s => s.SeriesName))
{
counter++;
try
{
// Normalize series name and localized name
var normalizedSeriesName = seriesInfo.SeriesName?.ToNormalized();
var normalizedLocalizedSeriesName = seriesInfo.LocalizedSeriesName?.ToNormalized();
// Search for existing series in the collection
var formats = seriesInfo.PlusMediaFormat.GetMangaFormats();
var existingSeries = collection.Items.FirstOrDefault(s =>
(s.Name.ToNormalized() == normalizedSeriesName ||
s.NormalizedName == normalizedSeriesName ||
s.LocalizedName.ToNormalized() == normalizedLocalizedSeriesName ||
s.NormalizedLocalizedName == normalizedLocalizedSeriesName ||
s.NormalizedName == normalizedLocalizedSeriesName ||
s.NormalizedLocalizedName == normalizedSeriesName)
&& formats.Contains(s.Format));
logger.LogDebug("Trying to find {SeriesName} with formats ({Formats}) within Kavita for linking. Found: {ExistingSeriesName} ({ExistingSeriesId})",
seriesInfo.SeriesName, formats, existingSeries?.Name, existingSeries?.Id);
if (existingSeries != null)
{
await eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, seriesInfo.SeriesName, counter, info.TotalItems, ProgressEventType.Updated), ct: ct);
continue;
}
// Series not found in the collection, try to find it in the server
var newSeries = await unitOfWork.SeriesRepository.GetSeriesByAnyName(seriesInfo.SeriesName,
seriesInfo.LocalizedSeriesName,
formats, collection.AppUserId, ct: ct);
collection.Items ??= new List<Series>();
if (newSeries != null)
{
// Add the new series to the collection
collection.Items.Add(newSeries);
}
else
{
logger.LogDebug("{Series} not found in the server", seriesInfo.SeriesName);
missingCount++;
missingSeries.Append(
$"<a href='{ScrobblingService.MalWeblinkWebsite}{seriesInfo.MalId}' target='_blank' rel='noopener noreferrer'>{seriesInfo.SeriesName}</a>");
missingSeries.Append("<br/>");
}
}
catch (Exception ex)
{
logger.LogError(ex, "An exception occured when linking up a series to the collection. Skipping");
missingCount++;
missingSeries.Append(
$"<a href='{ScrobblingService.MalWeblinkWebsite}{seriesInfo.MalId}' target='_blank' rel='noopener noreferrer'>{seriesInfo.SeriesName}</a>");
missingSeries.Append("<br/>");
}
await eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, seriesInfo.SeriesName, counter, info.TotalItems, ProgressEventType.Updated), ct: ct);
}
// At this point, all series in the info have been checked and added if necessary
collection.LastSyncUtc = DateTime.UtcNow.Truncate(TimeSpan.TicksPerHour);
collection.TotalSourceCount = info.TotalItems;
collection.Summary = info.Summary;
collection.MissingSeriesFromSource = missingSeries.ToString();
unitOfWork.CollectionTagRepository.Update(collection);
try
{
await unitOfWork.CommitAsync(ct);
await unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collection, ct);
await eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, string.Empty, info.TotalItems, info.TotalItems, ProgressEventType.Ended), ct: ct);
await eventHub.SendMessageAsync(MessageFactory.CollectionUpdated,
MessageFactory.CollectionUpdatedEvent(collection.Id), false, ct);
logger.LogInformation("Finished Syncing Collection {CollectionName} - Missing {MissingCount} series",
collection.Title, missingCount);
}
catch (Exception ex)
{
logger.LogError(ex, "There was an error during saving the collection");
}
}
private static long GetStackId(string url)
{
var tokens = url.Split("/");
return long.Parse(tokens[^1], CultureInfo.InvariantCulture);
}
private async Task<SeriesCollection?> GetStackInfo(long stackId)
{
logger.LogDebug("Fetching Kavita+ for MAL Stack");
var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var seriesForStack = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stack?stackId=" + stackId)
.WithKavitaPlusHeaders(license)
.GetJsonAsync<SeriesCollection>();
return seriesForStack;
}
}