Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stats & More Polish on Metadata Matching #3538

Merged
merged 16 commits into from
Feb 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
10d4f8b
Refactored the code so that when running on debug it will hit the loc…
majora2007 Feb 9, 2025
5b16c59
Rearranged the ordering for Manage Metadata preference item.
majora2007 Feb 9, 2025
34822d1
Properly fixed the api so whitelist isn't set with an empty string, a…
majora2007 Feb 9, 2025
b56539a
Hide Valid util from matched metadata screen because it's only applic…
majora2007 Feb 9, 2025
834c49c
Fixed a long standing bug where background series processing (fetchin…
majora2007 Feb 9, 2025
305381b
Don't overwrite summary/release year when unlocked and no override.
majora2007 Feb 9, 2025
32a9f6d
Ensure Localized name doesn't get set if there was existing data and …
majora2007 Feb 9, 2025
b5110a5
Fixed inability to turn off Do not match on a series
majora2007 Feb 9, 2025
e0b454c
Reverted a change to the read more which made it not wide enough for me.
majora2007 Feb 9, 2025
909d5b0
Cleaned up the localized name setting code to only allow roman charac…
majora2007 Feb 9, 2025
11e3ae6
Fixed allow metadata matching library setting not showing disabled an…
majora2007 Feb 9, 2025
af696ce
Preemptively remove a manually matched series once the modal closes o…
majora2007 Feb 9, 2025
990e0fe
Fixed a bug where Needs Manual Match (error) was showing stuff in Don…
majora2007 Feb 9, 2025
4d731da
Fixed add device being disabled for everyone
majora2007 Feb 9, 2025
f193f35
Fixed a bug where an admin editing a user was unable to save the form…
majora2007 Feb 9, 2025
a527f25
Ensure links to person detail page are encoded correctly
majora2007 Feb 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions API/Controllers/SettingsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -572,8 +572,8 @@ public async Task<ActionResult<MetadataSettingsDto>> UpdateMetadataSettings(Meta

existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? [];

existingMetadataSetting.Blacklist = dto.Blacklist.DistinctBy(d => d.ToNormalized()).ToList() ?? [];
existingMetadataSetting.Whitelist = dto.Whitelist.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.Overrides = dto.Overrides.ToList() ?? [];

// Handle Field Mappings
Expand Down
5 changes: 5 additions & 0 deletions API/DTOs/Stats/V3/ServerInfoV3Dto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ public class ServerInfoV3Dto
/// </summary>
/// <remarks>This pings a health check and does not capture any IP Information</remarks>
public long TimeToPingKavitaStatsApi { get; set; }
/// <summary>
/// If using the downloading metadata feature
/// </summary>
/// <remarks>Kavita+ Only</remarks>
public bool MatchedMetadataEnabled { get; set; }



Expand Down
8 changes: 5 additions & 3 deletions API/Data/Repositories/ExternalSeriesMetadataRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public interface IExternalSeriesMetadataRepository
Task<SeriesDetailPlusDto?> GetSeriesDetailPlusDto(int seriesId);
Task LinkRecommendationsToSeries(Series series);
Task<bool> IsBlacklistedSeries(int seriesId);
Task<IList<int>> GetAllSeriesIdsWithoutMetadata(int limit);
Task<IList<int>> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false);
Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter);
}

Expand Down Expand Up @@ -209,11 +209,13 @@ public Task<bool> IsBlacklistedSeries(int seriesId)
}


public async Task<IList<int>> GetAllSeriesIdsWithoutMetadata(int limit)
public async Task<IList<int>> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false)
{
return await _context.Series
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
.Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow)
.WhereIf(includeStaleData, s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow)
.Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue)
.Where(s => s.Library.AllowMetadataMatching)
.OrderByDescending(s => s.Library.Type)
.ThenBy(s => s.NormalizedName)
.Select(s => s.Id)
Expand Down
12 changes: 12 additions & 0 deletions API/Extensions/FlurlExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,16 @@ public static IFlurlRequest WithKavitaPlusHeaders(this string request, string li
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs));
}

public static IFlurlRequest WithBasicHeaders(this string request, string apiKey)
{
return request
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", apiKey)
.WithHeader("x-installId", HashUtil.ServerToken())
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs));
}
}
2 changes: 1 addition & 1 deletion API/Extensions/QueryExtensions/QueryableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ public static IQueryable<Series> FilterMatchState(this IQueryable<Series> query,
MatchStateOption.NotMatched => query.
Include(s => s.ExternalSeriesMetadata)
.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted && !s.DontMatch),
MatchStateOption.Error => query.Where(s => s.IsBlacklisted),
MatchStateOption.Error => query.Where(s => s.IsBlacklisted && !s.DontMatch),
MatchStateOption.DontMatch => query.Where(s => s.DontMatch),
_ => query
};
Expand Down
47 changes: 30 additions & 17 deletions API/Services/Plus/ExternalMetadataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ public interface IExternalMetadataService
/// </summary>
/// <param name="seriesId"></param>
/// <param name="libraryType"></param>
/// <returns></returns>
Task FetchSeriesMetadata(int seriesId, LibraryType libraryType);
/// <returns>If the fetch was made</returns>
Task<bool> FetchSeriesMetadata(int seriesId, LibraryType libraryType);

Task<IList<MalStackDto>> GetStacksForUser(int userId);
Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto);
Expand Down Expand Up @@ -73,6 +73,7 @@ public class ExternalMetadataService : IExternalMetadataService
};
// Allow 50 requests per 24 hours
private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false);
static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$");

public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper,
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService)
Expand Down Expand Up @@ -109,7 +110,7 @@ public static bool IsPlusEligible(LibraryType type)
public async Task FetchExternalDataTask()
{
// Find all Series that are eligible and limit
var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeriesIdsWithoutMetadata(25);
var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25);
if (ids.Count == 0) return;

_logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count);
Expand All @@ -118,9 +119,9 @@ public async Task FetchExternalDataTask()
foreach (var seriesId in ids)
{
var libraryType = libTypes[seriesId];
await FetchSeriesMetadata(seriesId, libraryType);
var success = await FetchSeriesMetadata(seriesId, libraryType);
if (success) count++;
await Task.Delay(1500);
count++;
}
_logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count);
}
Expand All @@ -131,25 +132,25 @@ public async Task FetchExternalDataTask()
/// </summary>
/// <param name="seriesId"></param>
/// <param name="libraryType"></param>
public async Task FetchSeriesMetadata(int seriesId, LibraryType libraryType)
public async Task<bool> FetchSeriesMetadata(int seriesId, LibraryType libraryType)
{
if (!IsPlusEligible(libraryType)) return;
if (!await _licenseService.HasActiveLicense()) return;
if (!IsPlusEligible(libraryType)) return false;
if (!await _licenseService.HasActiveLicense()) return false;

// Generate key based on seriesId and libraryType or any unique identifier for the request
// Check if the request is allowed based on the rate limit
if (!RateLimiter.TryAcquire(string.Empty))
{
// Request not allowed due to rate limit
_logger.LogDebug("Rate Limit hit for Kavita+ prefetch");
return;
return false;
}

_logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId);

// Prefetch SeriesDetail data
await GetSeriesDetailPlus(seriesId, libraryType);

return true;
}

public async Task<IList<MalStackDto>> GetStacksForUser(int userId)
Expand Down Expand Up @@ -512,31 +513,43 @@ private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto e

var madeModification = false;

if (settings.EnableLocalizedName && (!series.LocalizedNameLocked || settings.HasOverride(MetadataSettingField.LocalizedName)))
if (settings.EnableLocalizedName && (settings.HasOverride(MetadataSettingField.LocalizedName)
|| !series.LocalizedNameLocked && !string.IsNullOrWhiteSpace(series.LocalizedName)))
{
// We need to make the best appropriate guess
if (externalMetadata.Name == series.Name)
{
// Choose closest (usually last) synonym
series.LocalizedName = externalMetadata.Synonyms.Last();
var validSynonyms = externalMetadata.Synonyms
.Where(IsRomanCharacters)
.Where(s => s.ToNormalized() != series.Name.ToNormalized())
.ToList();
if (validSynonyms.Count != 0)
{
series.LocalizedName = validSynonyms[^1];
series.LocalizedNameLocked = true;
}
}
else
else if (IsRomanCharacters(externalMetadata.Name))
{
series.LocalizedName = externalMetadata.Name;
series.LocalizedNameLocked = true;
}


madeModification = true;
}

if (settings.EnableSummary && (!series.Metadata.SummaryLocked ||
settings.HasOverride(MetadataSettingField.Summary)))
if (settings.EnableSummary && (settings.HasOverride(MetadataSettingField.Summary) ||
(!series.Metadata.SummaryLocked && !string.IsNullOrWhiteSpace(series.Metadata.Summary))))
{
series.Metadata.Summary = CleanSummary(externalMetadata.Summary);
madeModification = true;
}

if (settings.EnableStartDate && externalMetadata.StartDate.HasValue && (!series.Metadata.ReleaseYearLocked ||
settings.HasOverride(MetadataSettingField.StartDate)))
if (settings.EnableStartDate && externalMetadata.StartDate.HasValue && (settings.HasOverride(MetadataSettingField.StartDate) ||
(!series.Metadata.ReleaseYearLocked &&
series.Metadata.ReleaseYear == 0)))
{
series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year;
madeModification = true;
Expand Down
2 changes: 2 additions & 0 deletions API/Services/SeriesService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -382,10 +382,12 @@ public static async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, IColle
// Check if the person exists in the dictionary
if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p))
{
// TODO: Should I add more controls here to map back?
if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId)
{
p.AniListId = personDto.AniListId;
}
p.Description = string.IsNullOrEmpty(p.Description) ? personDto.Description : p.Description;
continue; // If we ever want to update metadata for existing people, we'd do it here
}

Expand Down
38 changes: 16 additions & 22 deletions API/Services/Tasks/StatsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@
using API.DTOs.Stats.V3;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace API.Services.Tasks;
Expand All @@ -45,12 +48,12 @@ public class StatsService : IStatsService
private readonly UserManager<AppUser> _userManager;
private readonly IEmailService _emailService;
private readonly ICacheService _cacheService;
private const string ApiUrl = "https://stats.kavitareader.com";
private readonly string _apiUrl = "";
private const string ApiKey = "MsnvA2DfQqxSK5jh"; // It's not important this is public, just a way to keep bots from hitting the API willy-nilly

public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context,
ILicenseService licenseService, UserManager<AppUser> userManager, IEmailService emailService,
ICacheService cacheService)
ICacheService cacheService, IHostEnvironment environment)
{
_logger = logger;
_unitOfWork = unitOfWork;
Expand All @@ -60,7 +63,9 @@ public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataCo
_emailService = emailService;
_cacheService = cacheService;

FlurlConfiguration.ConfigureClientForUrl(ApiUrl);
FlurlConfiguration.ConfigureClientForUrl(Configuration.StatsApiUrl);

_apiUrl = environment.IsDevelopment() ? "http://localhost:5001" : Configuration.StatsApiUrl;
}

/// <summary>
Expand Down Expand Up @@ -98,13 +103,8 @@ private async Task SendDataToStatsServer(ServerInfoV3Dto data)

try
{
var response = await (ApiUrl + "/api/v3/stats")
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", ApiKey)
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(30))
var response = await (_apiUrl + "/api/v3/stats")
.WithBasicHeaders(ApiKey)
.PostJsonAsync(data);

if (response.StatusCode != StatusCodes.Status200OK)
Expand Down Expand Up @@ -151,12 +151,8 @@ public async Task SendCancellation()

try
{
var response = await (ApiUrl + "/api/v2/stats/opt-out?installId=" + installId)
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", ApiKey)
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
var response = await (_apiUrl + "/api/v2/stats/opt-out?installId=" + installId)
.WithBasicHeaders(ApiKey)
.WithTimeout(TimeSpan.FromSeconds(30))
.PostAsync();

Expand All @@ -180,12 +176,8 @@ private static async Task<long> PingStatsApi()
try
{
var sw = Stopwatch.StartNew();
var response = await (ApiUrl + "/api/health/")
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", ApiKey)
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
var response = await (Configuration.StatsApiUrl + "/api/health/")
.WithBasicHeaders(ApiKey)
.WithTimeout(TimeSpan.FromSeconds(30))
.GetAsync();

Expand Down Expand Up @@ -244,6 +236,7 @@ private async Task<int> MaxChaptersInASeries()
private async Task<ServerInfoV3Dto> GetStatV3Payload()
{
var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var mediaSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings();
var dto = new ServerInfoV3Dto()
{
InstallId = serverSettings.InstallId,
Expand All @@ -256,6 +249,7 @@ private async Task<ServerInfoV3Dto> GetStatV3Payload()
DotnetVersion = Environment.Version.ToString(),
OpdsEnabled = serverSettings.EnableOpds,
EncodeMediaAs = serverSettings.EncodeMediaAs,
MatchedMetadataEnabled = mediaSettings.Enabled
};

dto.OsLocale = CultureInfo.CurrentCulture.EnglishName;
Expand Down
1 change: 1 addition & 0 deletions Kavita.Common/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public static class Configuration
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());

public static string KavitaPlusApiUrl = "https://plus.kavitareader.com";
public static string StatsApiUrl = "https://stats.kavitareader.com";

public static int Port
{
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ your reading collection with your friends and family!
- Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc
- Ability to customize your dashboard and side nav with smart filters, custom order and visibility toggles
- Full Localization Support
- Ability to download metadata (available via [Kavita+](https://wiki.kavitareader.com/kavita+))


## Support
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ export class MatchSeriesModalComponent implements OnInit {
const model: any = this.formGroup.value;
model.seriesId = this.series.id;

const dontMatchChanged = this.series.dontMatch !== model.dontMatch;

// We need to update the dontMatch status
if (model.dontMatch) {
if (dontMatchChanged) {
this.seriesService.updateDontMatch(this.series.id, model.dontMatch).subscribe(_ => {
this.modalService.close(true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
[limit]="pageInfo.size"
>

<ngx-datatable-column name="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ngx-datatable-column prop="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('last-modified-header')}}
</ng-template>
Expand All @@ -46,7 +46,7 @@
</ng-template>
</ngx-datatable-column>

<ngx-datatable-column name="scrobbleEventType" [sortable]="true" [draggable]="false" [resizeable]="false">
<ngx-datatable-column prop="scrobbleEventType" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('type-header')}}
</ng-template>
Expand All @@ -55,7 +55,7 @@
</ng-template>
</ngx-datatable-column>

<ngx-datatable-column name="seriesName" [sortable]="true" [draggable]="false" [resizeable]="false">
<ngx-datatable-column prop="seriesName" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-header')}}
</ng-template>
Expand All @@ -64,7 +64,7 @@
</ng-template>
</ngx-datatable-column>

<ngx-datatable-column name="data" [sortable]="false" [draggable]="false" [resizeable]="false">
<ngx-datatable-column prop="data" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('data-header')}}
</ng-template>
Expand Down Expand Up @@ -98,7 +98,7 @@
</ng-template>
</ngx-datatable-column>

<ngx-datatable-column name="isPorcessed" [sortable]="true" [draggable]="false" [resizeable]="false">
<ngx-datatable-column prop="isPorcessed" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('is-processed-header')}}
</ng-template>
Expand Down
Loading
Loading