mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Metadata Downloading (#3525)
This commit is contained in:
parent
eb66763078
commit
f4fd7230ea
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.Common;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using System.Linq;
|
||||
@ -20,7 +21,7 @@ using NSubstitute;
|
||||
|
||||
namespace API.Tests;
|
||||
|
||||
public abstract class AbstractDbTest
|
||||
public abstract class AbstractDbTest : IDisposable
|
||||
{
|
||||
protected readonly DbConnection _connection;
|
||||
protected readonly DataContext _context;
|
||||
@ -28,6 +29,7 @@ public abstract class AbstractDbTest
|
||||
|
||||
|
||||
protected const string CacheDirectory = "C:/kavita/config/cache/";
|
||||
protected const string CacheLongDirectory = "C:/kavita/config/cache-long/";
|
||||
protected const string CoverImageDirectory = "C:/kavita/config/covers/";
|
||||
protected const string BackupDirectory = "C:/kavita/config/backups/";
|
||||
protected const string LogDirectory = "C:/kavita/config/logs/";
|
||||
@ -38,21 +40,22 @@ public abstract class AbstractDbTest
|
||||
|
||||
protected AbstractDbTest()
|
||||
{
|
||||
var contextOptions = new DbContextOptionsBuilder()
|
||||
var contextOptions = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(CreateInMemoryDatabase())
|
||||
.Options;
|
||||
|
||||
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
|
||||
|
||||
_context = new DataContext(contextOptions);
|
||||
|
||||
_context.Database.EnsureCreated(); // Ensure DB schema is created
|
||||
|
||||
Task.Run(SeedDb).GetAwaiter().GetResult();
|
||||
|
||||
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
|
||||
var mapper = config.CreateMapper();
|
||||
|
||||
// Set up Hangfire to use in-memory storage for testing
|
||||
GlobalConfiguration.Configuration.UseInMemoryStorage();
|
||||
|
||||
|
||||
_unitOfWork = new UnitOfWork(_context, mapper, null);
|
||||
}
|
||||
|
||||
@ -66,29 +69,43 @@ public abstract class AbstractDbTest
|
||||
|
||||
private async Task<bool> SeedDb()
|
||||
{
|
||||
await _context.Database.MigrateAsync();
|
||||
var filesystem = CreateFileSystem();
|
||||
try
|
||||
{
|
||||
await _context.Database.EnsureCreatedAsync();
|
||||
var filesystem = CreateFileSystem();
|
||||
|
||||
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
|
||||
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
|
||||
|
||||
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
|
||||
setting.Value = CacheDirectory;
|
||||
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
|
||||
setting.Value = CacheDirectory;
|
||||
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
|
||||
setting.Value = BackupDirectory;
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
|
||||
setting.Value = BackupDirectory;
|
||||
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
|
||||
setting.Value = BookmarkDirectory;
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
|
||||
setting.Value = BookmarkDirectory;
|
||||
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync();
|
||||
setting.Value = "10";
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync();
|
||||
setting.Value = "10";
|
||||
|
||||
_context.ServerSetting.Update(setting);
|
||||
_context.ServerSetting.Update(setting);
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Manga")
|
||||
.WithFolderPath(new FolderPathBuilder("C:/data/").Build())
|
||||
.Build());
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Manga")
|
||||
.WithFolderPath(new FolderPathBuilder(DataDirectory).Build())
|
||||
.Build());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await Seed.SeedMetadataSettings(_context);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SeedDb] Error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract Task ResetDb();
|
||||
@ -99,6 +116,7 @@ public abstract class AbstractDbTest
|
||||
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
|
||||
fileSystem.AddDirectory("C:/kavita/config/");
|
||||
fileSystem.AddDirectory(CacheDirectory);
|
||||
fileSystem.AddDirectory(CacheLongDirectory);
|
||||
fileSystem.AddDirectory(CoverImageDirectory);
|
||||
fileSystem.AddDirectory(BackupDirectory);
|
||||
fileSystem.AddDirectory(BookmarkDirectory);
|
||||
@ -109,4 +127,10 @@ public abstract class AbstractDbTest
|
||||
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
|
@ -932,7 +932,8 @@ public class SeriesFilterTests : AbstractDbTest
|
||||
|
||||
var seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
|
||||
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
|
||||
Substitute.For<IScrobblingService>(), Substitute.For<ILocalizationService>());
|
||||
Substitute.For<IScrobblingService>(), Substitute.For<ILocalizationService>()
|
||||
, Substitute.For<IImageService>());
|
||||
|
||||
// Select 0 Rating
|
||||
var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2);
|
||||
|
@ -56,7 +56,7 @@ public class SeriesServiceTests : AbstractDbTest
|
||||
|
||||
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
|
||||
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
|
||||
Substitute.For<IScrobblingService>(), locService);
|
||||
Substitute.For<IScrobblingService>(), locService, Substitute.For<IImageService>());
|
||||
}
|
||||
#region Setup
|
||||
|
||||
|
@ -197,6 +197,7 @@
|
||||
<Content Include="EmailTemplates\**">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Folder Include="Data\ManualMigrations\v0.8.3\" />
|
||||
<Folder Include="Extensions\KavitaPlus\" />
|
||||
<None Include="I18N\**" />
|
||||
</ItemGroup>
|
||||
|
@ -81,7 +81,8 @@ public class LibraryController : BaseApiController
|
||||
.WithIncludeInDashboard(dto.IncludeInDashboard)
|
||||
.WithManageCollections(dto.ManageCollections)
|
||||
.WithManageReadingLists(dto.ManageReadingLists)
|
||||
.WIthAllowScrobbling(dto.AllowScrobbling)
|
||||
.WithAllowScrobbling(dto.AllowScrobbling)
|
||||
.WithAllowMetadataMatching(dto.AllowMetadataMatching)
|
||||
.Build();
|
||||
|
||||
library.LibraryFileTypes = dto.FileGroupTypes
|
||||
|
@ -631,13 +631,13 @@ public class SeriesController : BaseApiController
|
||||
/// <summary>
|
||||
/// This will perform the fix match
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <param name="aniListId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-match")]
|
||||
public async Task<ActionResult> UpdateSeriesMatch(ExternalSeriesDetailDto dto, [FromQuery] int seriesId)
|
||||
public async Task<ActionResult> UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int aniListId)
|
||||
{
|
||||
await _externalMetadataService.FixSeriesMatch(seriesId, dto);
|
||||
await _externalMetadataService.FixSeriesMatch(seriesId, aniListId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Email;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -534,4 +535,73 @@ public class SettingsController : BaseApiController
|
||||
if (string.IsNullOrEmpty(user?.Email)) return BadRequest("Your account has no email on record. Cannot email.");
|
||||
return Ok(await _emailService.SendTestEmail(user!.Email));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the metadata settings for Kavita+ users.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("metadata-settings")]
|
||||
public async Task<ActionResult<MetadataSettingsDto>> GetMetadataSettings()
|
||||
{
|
||||
return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto());
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the metadata settings for Kavita+ users
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("metadata-settings")]
|
||||
public async Task<ActionResult<MetadataSettingsDto>> UpdateMetadataSettings(MetadataSettingsDto dto)
|
||||
{
|
||||
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings();
|
||||
existingMetadataSetting.Enabled = dto.Enabled;
|
||||
existingMetadataSetting.EnableSummary = dto.EnableSummary;
|
||||
existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus;
|
||||
existingMetadataSetting.EnableRelationships = dto.EnableRelationships;
|
||||
existingMetadataSetting.EnablePeople = dto.EnablePeople;
|
||||
existingMetadataSetting.EnableStartDate = dto.EnableStartDate;
|
||||
existingMetadataSetting.EnableGenres = dto.EnableGenres;
|
||||
existingMetadataSetting.EnableTags = dto.EnableTags;
|
||||
existingMetadataSetting.PersonRoles = dto.PersonRoles;
|
||||
existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming;
|
||||
|
||||
existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? [];
|
||||
|
||||
existingMetadataSetting.Blacklist = dto.Blacklist.DistinctBy(d => d.ToNormalized()).ToList() ?? [];
|
||||
existingMetadataSetting.Whitelist = dto.Whitelist.DistinctBy(d => d.ToNormalized()).ToList() ?? [];
|
||||
|
||||
// Handle Field Mappings
|
||||
if (dto.FieldMappings != null)
|
||||
{
|
||||
// Clear existing mappings
|
||||
existingMetadataSetting.FieldMappings ??= [];
|
||||
_unitOfWork.SettingsRepository.RemoveRange(existingMetadataSetting.FieldMappings);
|
||||
|
||||
existingMetadataSetting.FieldMappings.Clear();
|
||||
|
||||
|
||||
// Add new mappings
|
||||
foreach (var mappingDto in dto.FieldMappings)
|
||||
{
|
||||
existingMetadataSetting.FieldMappings.Add(new MetadataFieldMapping
|
||||
{
|
||||
SourceType = mappingDto.SourceType,
|
||||
DestinationType = mappingDto.DestinationType,
|
||||
SourceValue = mappingDto.SourceValue,
|
||||
DestinationValue = mappingDto.DestinationValue,
|
||||
ExcludeFromSource = mappingDto.ExcludeFromSource
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save changes
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
// Return updated settings
|
||||
return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto());
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ using API.DTOs.Uploads;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.SignalR;
|
||||
using Flurl.Http;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -31,11 +32,12 @@ public class UploadController : BaseApiController
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly IReadingListService _readingListService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly ICoverDbService _coverDbService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger,
|
||||
ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService,
|
||||
ILocalizationService localizationService)
|
||||
ILocalizationService localizationService, ICoverDbService coverDbService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_imageService = imageService;
|
||||
@ -45,6 +47,7 @@ public class UploadController : BaseApiController
|
||||
_eventHub = eventHub;
|
||||
_readingListService = readingListService;
|
||||
_localizationService = localizationService;
|
||||
_coverDbService = coverDbService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -495,34 +498,8 @@ public class UploadController : BaseApiController
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id);
|
||||
if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
|
||||
|
||||
if (!string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetPersonFormat(uploadFileDto.Id)}");
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
person.CoverImage = filePath;
|
||||
person.CoverImageLocked = true;
|
||||
_imageService.UpdateColorScape(person);
|
||||
_unitOfWork.PersonRepository.Update(person);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
person.CoverImage = string.Empty;
|
||||
person.CoverImageLocked = false;
|
||||
_imageService.UpdateColorScape(person);
|
||||
_unitOfWork.PersonRepository.Update(person);
|
||||
}
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
await _coverDbService.SetPersonCoverImage(person, uploadFileDto.Url, true);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
@ -135,6 +135,14 @@ public class UsersController : BaseApiController
|
||||
existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
|
||||
existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
|
||||
|
||||
if (await _licenseService.HasActiveLicense())
|
||||
{
|
||||
existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled;
|
||||
existingPreferences.WantToReadSync = preferencesDto.WantToReadSync;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id)
|
||||
{
|
||||
var theme = await _unitOfWork.SiteThemeRepository.GetTheme(preferencesDto.Theme!.Id);
|
||||
@ -147,6 +155,7 @@ public class UsersController : BaseApiController
|
||||
existingPreferences.Locale = preferencesDto.Locale;
|
||||
}
|
||||
|
||||
|
||||
_unitOfWork.UserRepository.Update(existingPreferences);
|
||||
|
||||
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-pref"));
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.DTOs.SeriesDetail;
|
||||
|
||||
@ -9,6 +10,7 @@ internal class SeriesDetailPlusApiDto
|
||||
public IEnumerable<MediaRecommendationDto> Recommendations { get; set; }
|
||||
public IEnumerable<UserReviewDto> Reviews { get; set; }
|
||||
public IEnumerable<RatingDto> Ratings { get; set; }
|
||||
public ExternalSeriesDetailDto? Series { get; set; }
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
}
|
||||
|
@ -1,10 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.Services.Plus;
|
||||
|
||||
namespace API.DTOs.Recommendation;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// This is AniListSeries
|
||||
/// </summary>
|
||||
public class ExternalSeriesDetailDto
|
||||
{
|
||||
public string Name { get; set; }
|
||||
@ -18,7 +23,15 @@ public class ExternalSeriesDetailDto
|
||||
public IList<SeriesStaffDto> Staff { get; set; }
|
||||
public IList<MetadataTagDto> Tags { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int? VolumeCount { get; set; }
|
||||
public int? ChapterCount { get; set; }
|
||||
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList;
|
||||
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public int AverageScore { get; set; }
|
||||
public int Chapters { get; set; }
|
||||
public int Volumes { get; set; }
|
||||
public IList<SeriesRelationship>? Relations { get; set; }
|
||||
public IList<SeriesCharacter>? Characters { get; set; }
|
||||
|
||||
|
||||
}
|
22
API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs
Normal file
22
API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.KavitaPlus.Metadata;
|
||||
|
||||
public class MetadataFieldMappingDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public MetadataFieldType SourceType { get; set; }
|
||||
public MetadataFieldType DestinationType { get; set; }
|
||||
/// <summary>
|
||||
/// The string in the source
|
||||
/// </summary>
|
||||
public string SourceValue { get; set; }
|
||||
/// <summary>
|
||||
/// Write the string as this in the Destination (can also just be the Source)
|
||||
/// </summary>
|
||||
public string DestinationValue { get; set; }
|
||||
/// <summary>
|
||||
/// If true, the tag will be Moved over vs Copied over
|
||||
/// </summary>
|
||||
public bool ExcludeFromSource { get; set; }
|
||||
}
|
70
API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs
Normal file
70
API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs
Normal file
@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.KavitaPlus.Metadata;
|
||||
|
||||
public class MetadataSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow the Summary to be written
|
||||
/// </summary>
|
||||
public bool EnableSummary { get; set; }
|
||||
/// <summary>
|
||||
/// Allow Publication status to be derived and updated
|
||||
/// </summary>
|
||||
public bool EnablePublicationStatus { get; set; }
|
||||
/// <summary>
|
||||
/// Allow Relationships between series to be set
|
||||
/// </summary>
|
||||
public bool EnableRelationships { get; set; }
|
||||
/// <summary>
|
||||
/// Allow People to be created (including downloading images)
|
||||
/// </summary>
|
||||
public bool EnablePeople { get; set; }
|
||||
/// <summary>
|
||||
/// Allow Start date to be set within the Series
|
||||
/// </summary>
|
||||
public bool EnableStartDate { get; set; }
|
||||
/// <summary>
|
||||
/// Allow setting the Localized name
|
||||
/// </summary>
|
||||
public bool EnableLocalizedName { get; set; }
|
||||
|
||||
// Need to handle the Genre/tags stuff
|
||||
public bool EnableGenres { get; set; } = true;
|
||||
public bool EnableTags { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names.
|
||||
/// </summary>
|
||||
public bool FirstLastPeopleNaming { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.
|
||||
/// </summary>
|
||||
public Dictionary<string, AgeRating> AgeRatingMappings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A list of rules that allow mapping a genre/tag to another genre/tag
|
||||
/// </summary>
|
||||
public List<MetadataFieldMappingDto> FieldMappings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Do not allow any Genre/Tag in this list to be written to Kavita
|
||||
/// </summary>
|
||||
public List<string> Blacklist { get; set; }
|
||||
/// <summary>
|
||||
/// Only allow these Tags to be written to Kavita
|
||||
/// </summary>
|
||||
public List<string> Whitelist { get; set; }
|
||||
/// <summary>
|
||||
/// Which Roles to allow metadata downloading for
|
||||
/// </summary>
|
||||
public List<PersonRole> PersonRoles { get; set; }
|
||||
}
|
9
API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs
Normal file
9
API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace API.DTOs.KavitaPlus.Metadata;
|
||||
|
||||
public class SeriesCharacter
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public required string Description { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
}
|
24
API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs
Normal file
24
API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Services.Plus;
|
||||
|
||||
namespace API.DTOs.KavitaPlus.Metadata;
|
||||
|
||||
public class ALMediaTitle
|
||||
{
|
||||
public string? EnglishTitle { get; set; }
|
||||
public string RomajiTitle { get; set; }
|
||||
public string NativeTitle { get; set; }
|
||||
public string PreferredTitle { get; set; }
|
||||
}
|
||||
|
||||
public class SeriesRelationship
|
||||
{
|
||||
public int AniListId { get; set; }
|
||||
public int? MalId { get; set; }
|
||||
public ALMediaTitle SeriesName { get; set; }
|
||||
public RelationKind Relation { get; set; }
|
||||
public ScrobbleProvider Provider { get; set; }
|
||||
public PlusMediaFormat PlusMediaFormat { get; set; } = PlusMediaFormat.Manga;
|
||||
}
|
@ -61,4 +61,10 @@ public class LibraryDto
|
||||
/// A set of globs that will exclude matching content from being scanned
|
||||
/// </summary>
|
||||
public ICollection<string> ExcludePatterns { get; set; }
|
||||
/// <summary>
|
||||
/// Allow any series within this Library to download metadata.
|
||||
/// </summary>
|
||||
/// <remarks>This does not exclude the library from being linked to wrt Series Relationships</remarks>
|
||||
/// <remarks>Requires a valid LicenseKey</remarks>
|
||||
public bool AllowMetadataMatching { get; set; } = true;
|
||||
}
|
||||
|
@ -4,6 +4,8 @@
|
||||
public class SeriesStaffDto
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public required string Role { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
|
@ -1,6 +1,9 @@
|
||||
namespace API.DTOs.Scrobbling;
|
||||
|
||||
public record PlusSeriesDto
|
||||
/// <summary>
|
||||
/// Represents information about a potential Series for Kavita+
|
||||
/// </summary>
|
||||
public record PlusSeriesRequestDto
|
||||
{
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
|
@ -12,4 +12,5 @@ public class SeriesDetailPlusDto
|
||||
public RecommendationDto? Recommendations { get; set; }
|
||||
public IEnumerable<UserReviewDto> Reviews { get; set; }
|
||||
public IEnumerable<RatingDto>? Ratings { get; set; }
|
||||
public ExternalSeriesDetailDto? Series { get; set; }
|
||||
}
|
||||
|
@ -26,6 +26,8 @@ public class UpdateLibraryDto
|
||||
public bool ManageReadingLists { get; init; }
|
||||
[Required]
|
||||
public bool AllowScrobbling { get; init; }
|
||||
[Required]
|
||||
public bool AllowMetadataMatching { get; init; }
|
||||
/// <summary>
|
||||
/// What types of files to allow the scanner to pickup
|
||||
/// </summary>
|
||||
|
@ -175,5 +175,12 @@ public class UserPreferencesDto
|
||||
[Required]
|
||||
public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Kavita+: Should this account have Scrobbling enabled for AniList
|
||||
/// </summary>
|
||||
public bool AniListScrobblingEnabled { get; set; }
|
||||
/// <summary>
|
||||
/// Kavita+: Should this account have Want to Read Sync enabled
|
||||
/// </summary>
|
||||
public bool WantToReadSync { get; set; }
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
@ -13,6 +15,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace API.Data;
|
||||
|
||||
@ -70,7 +73,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
public DbSet<ChapterPeople> ChapterPeople { get; set; } = null!;
|
||||
public DbSet<SeriesMetadataPeople> SeriesMetadataPeople { get; set; } = null!;
|
||||
public DbSet<EmailHistory> EmailHistory { get; set; } = null!;
|
||||
|
||||
public DbSet<MetadataSettings> MetadataSettings { get; set; } = null!;
|
||||
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@ -120,10 +124,19 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
.Property(b => b.Locale)
|
||||
.IsRequired(true)
|
||||
.HasDefaultValue("en");
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.AniListScrobblingEnabled)
|
||||
.HasDefaultValue(true);
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.WantToReadSync)
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Entity<Library>()
|
||||
.Property(b => b.AllowScrobbling)
|
||||
.HasDefaultValue(true);
|
||||
builder.Entity<Library>()
|
||||
.Property(b => b.AllowMetadataMatching)
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Entity<Chapter>()
|
||||
.Property(b => b.WebLinks)
|
||||
@ -189,6 +202,31 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
.WithMany(p => p.SeriesMetadataPeople)
|
||||
.HasForeignKey(smp => smp.PersonId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.Entity<MetadataSettings>()
|
||||
.Property(x => x.AgeRatingMappings)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
|
||||
v => JsonSerializer.Deserialize<Dictionary<string, AgeRating>>(v, JsonSerializerOptions.Default)
|
||||
);
|
||||
|
||||
// Ensure blacklist is stored as a JSON array
|
||||
builder.Entity<MetadataSettings>()
|
||||
.Property(x => x.Blacklist)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
|
||||
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)
|
||||
);
|
||||
|
||||
// Configure one-to-many relationship
|
||||
builder.Entity<MetadataSettings>()
|
||||
.HasMany(x => x.FieldMappings)
|
||||
.WithOne(x => x.MetadataSettings)
|
||||
.HasForeignKey(x => x.MetadataSettingsId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.Entity<MetadataSettings>()
|
||||
.Property(b => b.Enabled)
|
||||
.HasDefaultValue(true);
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
|
@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities.History;
|
||||
using API.Entities.Metadata;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Data.ManualMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// v0.8.5 - Migrating Kavita+ Series that are Blacklisted but have valid ExternalSeries row
|
||||
/// </summary>
|
||||
public static class ManualMigrateInvalidBlacklistSeries
|
||||
{
|
||||
public static async Task Migrate(DataContext context, ILogger<Program> logger)
|
||||
{
|
||||
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateInvalidBlacklistSeries"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Please be patient, this may take some time. This is not an error");
|
||||
|
||||
// Get all series in the Blacklist table and set their IsBlacklist = true
|
||||
var blacklistedSeries = await context.Series
|
||||
.Include(s => s.ExternalSeriesMetadata)
|
||||
.Where(s => s.IsBlacklisted && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue)
|
||||
.ToListAsync();
|
||||
foreach (var series in blacklistedSeries)
|
||||
{
|
||||
series.IsBlacklisted = false;
|
||||
context.Series.Entry(series).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
if (context.ChangeTracker.HasChanges())
|
||||
{
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "ManualMigrateInvalidBlacklistSeries",
|
||||
ProductVersion = BuildInfo.Version.ToString(),
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Completed. This is not an error");
|
||||
}
|
||||
}
|
3382
API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs
generated
Normal file
3382
API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,112 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class KavitaPlusUserAndMetadataSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowMetadataMatching",
|
||||
table: "Library",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AniListScrobblingEnabled",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "WantToReadSync",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MetadataSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Enabled = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
|
||||
EnableSummary = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
EnablePublicationStatus = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
EnableRelationships = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
EnablePeople = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
EnableStartDate = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
EnableLocalizedName = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
EnableGenres = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
EnableTags = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
FirstLastPeopleNaming = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
AgeRatingMappings = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Blacklist = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Whitelist = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PersonRoles = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MetadataSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MetadataFieldMapping",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SourceType = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DestinationType = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
SourceValue = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DestinationValue = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ExcludeFromSource = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
MetadataSettingsId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MetadataFieldMapping", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MetadataFieldMapping_MetadataSettings_MetadataSettingsId",
|
||||
column: x => x.MetadataSettingsId,
|
||||
principalTable: "MetadataSettings",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MetadataFieldMapping_MetadataSettingsId",
|
||||
table: "MetadataFieldMapping",
|
||||
column: "MetadataSettingsId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "MetadataFieldMapping");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MetadataSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowMetadataMatching",
|
||||
table: "Library");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AniListScrobblingEnabled",
|
||||
table: "AppUserPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WantToReadSync",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.0");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.1");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
@ -353,6 +353,11 @@ namespace API.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AniListScrobblingEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -460,6 +465,11 @@ namespace API.Data.Migrations
|
||||
b.Property<int?>("ThemeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WantToReadSync")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId")
|
||||
@ -1093,12 +1103,37 @@ namespace API.Data.Migrations
|
||||
b.ToTable("Genre");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProductVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("RanAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ManualMigrationHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Library", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowMetadataMatching")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowScrobbling")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@ -1247,26 +1282,6 @@ namespace API.Data.Migrations
|
||||
b.ToTable("MangaFile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ManualMigrationHistory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProductVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("RanAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ManualMigrationHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MediaError", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -1594,6 +1609,92 @@ namespace API.Data.Migrations
|
||||
b.ToTable("SeriesRelation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DestinationType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DestinationValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ExcludeFromSource")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MetadataSettingsId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SourceType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SourceValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MetadataSettingsId");
|
||||
|
||||
b.ToTable("MetadataFieldMapping");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MetadataSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AgeRatingMappings")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Blacklist")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EnableGenres")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EnableLocalizedName")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EnablePeople")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EnablePublicationStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EnableRelationships")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EnableStartDate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EnableSummary")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EnableTags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("FirstLastPeopleNaming")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.PrimitiveCollection<string>("PersonRoles")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.PrimitiveCollection<string>("Whitelist")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MetadataSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -2824,6 +2925,17 @@ namespace API.Data.Migrations
|
||||
b.Navigation("TargetSeries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.MetadataSettings", "MetadataSettings")
|
||||
.WithMany("FieldMappings")
|
||||
.HasForeignKey("MetadataSettingsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("MetadataSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
@ -3223,6 +3335,11 @@ namespace API.Data.Migrations
|
||||
b.Navigation("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MetadataSettings", b =>
|
||||
{
|
||||
b.Navigation("FieldMappings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person", b =>
|
||||
{
|
||||
b.Navigation("ChapterPeople");
|
||||
|
@ -43,6 +43,7 @@ public interface IPersonRepository
|
||||
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
|
||||
Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role);
|
||||
Task<IList<Person>> GetPeopleByNames(List<string> normalizedNames);
|
||||
Task<Person?> GetPersonByAniListId(int aniListId);
|
||||
}
|
||||
|
||||
public class PersonRepository : IPersonRepository
|
||||
@ -263,6 +264,13 @@ public class PersonRepository : IPersonRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Person?> GetPersonByAniListId(int aniListId)
|
||||
{
|
||||
return await _context.Person
|
||||
.Where(p => p.AniListId == aniListId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<Person>> GetAllPeople()
|
||||
{
|
||||
return await _context.Person
|
||||
|
@ -79,6 +79,7 @@ public class ScrobbleRepository : IScrobbleRepository
|
||||
.Include(s => s.Series)
|
||||
.ThenInclude(s => s.Metadata)
|
||||
.Include(s => s.AppUser)
|
||||
.ThenInclude(u => u.UserPreferences)
|
||||
.Where(s => s.ScrobbleEventType == type)
|
||||
.Where(s => s.IsProcessed == isProcessed)
|
||||
.AsSplitQuery()
|
||||
|
@ -146,7 +146,7 @@ public interface ISeriesRepository
|
||||
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
|
||||
int userId, SeriesIncludes includes = SeriesIncludes.None);
|
||||
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
|
||||
Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats, int userId);
|
||||
Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None);
|
||||
public Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId,
|
||||
MangaFormat format);
|
||||
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
|
||||
@ -164,7 +164,7 @@ public interface ISeriesRepository
|
||||
Task RemoveFromOnDeck(int seriesId, int userId);
|
||||
Task ClearOnDeckRemoval(int seriesId, int userId);
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None);
|
||||
Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId);
|
||||
Task<PlusSeriesRequestDto?> GetPlusSeriesDto(int seriesId);
|
||||
Task<int> GetCountAsync();
|
||||
Task<Series?> MatchSeries(ExternalSeriesDetailDto externalSeries);
|
||||
}
|
||||
@ -699,17 +699,16 @@ public class SeriesRepository : ISeriesRepository
|
||||
|
||||
var retSeries = query
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
//.AsSplitQuery()
|
||||
.AsNoTracking();
|
||||
|
||||
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId)
|
||||
public async Task<PlusSeriesRequestDto?> GetPlusSeriesDto(int seriesId)
|
||||
{
|
||||
return await _context.Series
|
||||
.Where(s => s.Id == seriesId)
|
||||
.Select(series => new PlusSeriesDto()
|
||||
.Select(series => new PlusSeriesRequestDto()
|
||||
{
|
||||
MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
|
||||
SeriesName = series.Name,
|
||||
@ -1725,24 +1724,36 @@ public class SeriesRepository : ISeriesRepository
|
||||
#nullable enable
|
||||
}
|
||||
|
||||
public async Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats, int userId)
|
||||
public async Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats,
|
||||
int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None)
|
||||
{
|
||||
var libraryIds = GetLibraryIdsForUser(userId);
|
||||
var normalizedSeries = seriesName.ToNormalized();
|
||||
var normalizedLocalized = localizedName.ToNormalized();
|
||||
|
||||
return await _context.Series
|
||||
var query = _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Where(s => formats.Contains(s.Format))
|
||||
.Where(s =>
|
||||
.Where(s => formats.Contains(s.Format));
|
||||
|
||||
if (aniListId.HasValue && aniListId.Value > 0)
|
||||
{
|
||||
// If AniList ID is provided, override name checks
|
||||
query = query.Where(s => s.ExternalSeriesMetadata.AniListId == aniListId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Otherwise, use name checks
|
||||
query = query.Where(s =>
|
||||
s.NormalizedName.Equals(normalizedSeries)
|
||||
|| s.NormalizedName.Equals(normalizedLocalized)
|
||||
|
||||
|| s.NormalizedLocalizedName.Equals(normalizedSeries)
|
||||
|| (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized))
|
||||
|
||||
|| (s.OriginalName != null && s.OriginalName.Equals(seriesName))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return await query
|
||||
.Includes(includes)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
@ -14,11 +16,15 @@ namespace API.Data.Repositories;
|
||||
public interface ISettingsRepository
|
||||
{
|
||||
void Update(ServerSetting settings);
|
||||
void Update(MetadataSettings settings);
|
||||
void RemoveRange(List<MetadataFieldMapping> fieldMappings);
|
||||
Task<ServerSettingDto> GetSettingsDtoAsync();
|
||||
Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
|
||||
Task<IEnumerable<ServerSetting>> GetSettingsAsync();
|
||||
void Remove(ServerSetting setting);
|
||||
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
|
||||
Task<MetadataSettings> GetMetadataSettings();
|
||||
Task<MetadataSettingsDto> GetMetadataSettingDto();
|
||||
}
|
||||
public class SettingsRepository : ISettingsRepository
|
||||
{
|
||||
@ -36,6 +42,16 @@ public class SettingsRepository : ISettingsRepository
|
||||
_context.Entry(settings).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(MetadataSettings settings)
|
||||
{
|
||||
_context.Entry(settings).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void RemoveRange(List<MetadataFieldMapping> fieldMappings)
|
||||
{
|
||||
_context.MetadataFieldMapping.RemoveRange(fieldMappings);
|
||||
}
|
||||
|
||||
public void Remove(ServerSetting setting)
|
||||
{
|
||||
_context.Remove(setting);
|
||||
@ -48,6 +64,21 @@ public class SettingsRepository : ISettingsRepository
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<MetadataSettings> GetMetadataSettings()
|
||||
{
|
||||
return await _context.MetadataSettings
|
||||
.Include(m => m.FieldMappings)
|
||||
.FirstAsync();
|
||||
}
|
||||
|
||||
public async Task<MetadataSettingsDto> GetMetadataSettingDto()
|
||||
{
|
||||
return await _context.MetadataSettings
|
||||
.Include(m => m.FieldMappings)
|
||||
.ProjectTo<MetadataSettingsDto>(_mapper.ConfigurationProvider)
|
||||
.FirstAsync();
|
||||
}
|
||||
|
||||
public async Task<ServerSettingDto> GetSettingsDtoAsync()
|
||||
{
|
||||
var settings = await _context.ServerSetting
|
||||
|
@ -262,12 +262,11 @@ public static class Seed
|
||||
new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"},
|
||||
new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()},
|
||||
new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString()},
|
||||
|
||||
}.ToArray());
|
||||
|
||||
foreach (var defaultSetting in DefaultSettings)
|
||||
{
|
||||
var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key);
|
||||
var existing = await context.ServerSetting.FirstOrDefaultAsync(s => s.Key == defaultSetting.Key);
|
||||
if (existing == null)
|
||||
{
|
||||
await context.ServerSetting.AddAsync(defaultSetting);
|
||||
@ -291,6 +290,35 @@ public static class Seed
|
||||
|
||||
}
|
||||
|
||||
public static async Task SeedMetadataSettings(DataContext context)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
var existing = await context.MetadataSettings.FirstOrDefaultAsync();
|
||||
if (existing == null)
|
||||
{
|
||||
existing = new MetadataSettings()
|
||||
{
|
||||
Enabled = true,
|
||||
EnablePeople = true,
|
||||
EnableRelationships = true,
|
||||
EnableSummary = true,
|
||||
EnablePublicationStatus = true,
|
||||
EnableStartDate = true,
|
||||
EnableTags = false,
|
||||
EnableGenres = true,
|
||||
EnableLocalizedName = false,
|
||||
FirstLastPeopleNaming = false,
|
||||
PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]
|
||||
};
|
||||
await context.MetadataSettings.AddAsync(existing);
|
||||
}
|
||||
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
}
|
||||
|
||||
public static async Task SeedUserApiKeys(DataContext context)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
@ -160,7 +160,17 @@ public class AppUserPreferences
|
||||
/// UI Site Global Setting: The language locale that should be used for the user
|
||||
/// </summary>
|
||||
public string Locale { get; set; }
|
||||
#endregion
|
||||
|
||||
#region KavitaPlus
|
||||
/// <summary>
|
||||
/// Should this account have Scrobbling enabled for AniList
|
||||
/// </summary>
|
||||
public bool AniListScrobblingEnabled { get; set; }
|
||||
/// <summary>
|
||||
/// Should this account have Want to Read Sync enabled
|
||||
/// </summary>
|
||||
public bool WantToReadSync { get; set; }
|
||||
#endregion
|
||||
|
||||
public AppUser AppUser { get; set; } = null!;
|
||||
|
7
API/Entities/Enums/MetadataFieldType.cs
Normal file
7
API/Entities/Enums/MetadataFieldType.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace API.Entities.Enums;
|
||||
|
||||
public enum MetadataFieldType
|
||||
{
|
||||
Genre = 0,
|
||||
Tag = 1,
|
||||
}
|
@ -40,8 +40,14 @@ public class Library : IEntityDate, IHasCoverImage
|
||||
/// <summary>
|
||||
/// Should this library allow Scrobble events to emit from it
|
||||
/// </summary>
|
||||
/// <remarks>Scrobbling requires a valid LicenseKey</remarks>
|
||||
/// <remarks>Requires a valid LicenseKey</remarks>
|
||||
public bool AllowScrobbling { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Allow any series within this Library to download metadata.
|
||||
/// </summary>
|
||||
/// <remarks>This does not exclude the library from being linked to wrt Series Relationships</remarks>
|
||||
/// <remarks>Requires a valid LicenseKey</remarks>
|
||||
public bool AllowMetadataMatching { get; set; } = true;
|
||||
|
||||
|
||||
public DateTime Created { get; set; }
|
||||
|
25
API/Entities/MetadataMatching/MetadataFieldMapping.cs
Normal file
25
API/Entities/MetadataMatching/MetadataFieldMapping.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
public class MetadataFieldMapping
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public MetadataFieldType SourceType { get; set; }
|
||||
public MetadataFieldType DestinationType { get; set; }
|
||||
/// <summary>
|
||||
/// The string in the source
|
||||
/// </summary>
|
||||
public string SourceValue { get; set; }
|
||||
/// <summary>
|
||||
/// Write the string as this in the Destination (can also just be the Source)
|
||||
/// </summary>
|
||||
public string DestinationValue { get; set; }
|
||||
/// <summary>
|
||||
/// If true, the tag will be Moved over vs Copied over
|
||||
/// </summary>
|
||||
public bool ExcludeFromSource { get; set; }
|
||||
|
||||
public int MetadataSettingsId { get; set; }
|
||||
public virtual MetadataSettings MetadataSettings { get; set; }
|
||||
}
|
75
API/Entities/MetadataMatching/MetadataSettings.cs
Normal file
75
API/Entities/MetadataMatching/MetadataSettings.cs
Normal file
@ -0,0 +1,75 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the metadata settings for Kavita+
|
||||
/// </summary>
|
||||
public class MetadataSettings
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow the Summary to be written
|
||||
/// </summary>
|
||||
public bool EnableSummary { get; set; }
|
||||
/// <summary>
|
||||
/// Allow Publication status to be derived and updated
|
||||
/// </summary>
|
||||
public bool EnablePublicationStatus { get; set; }
|
||||
/// <summary>
|
||||
/// Allow Relationships between series to be set
|
||||
/// </summary>
|
||||
public bool EnableRelationships { get; set; }
|
||||
/// <summary>
|
||||
/// Allow People to be created (including downloading images)
|
||||
/// </summary>
|
||||
public bool EnablePeople { get; set; }
|
||||
/// <summary>
|
||||
/// Allow Start date to be set within the Series
|
||||
/// </summary>
|
||||
public bool EnableStartDate { get; set; }
|
||||
/// <summary>
|
||||
/// Allow setting the Localized name
|
||||
/// </summary>
|
||||
public bool EnableLocalizedName { get; set; }
|
||||
|
||||
// Need to handle the Genre/tags stuff
|
||||
public bool EnableGenres { get; set; } = true;
|
||||
public bool EnableTags { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names.
|
||||
/// </summary>
|
||||
public bool FirstLastPeopleNaming { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.
|
||||
/// </summary>
|
||||
public Dictionary<string, AgeRating> AgeRatingMappings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A list of rules that allow mapping a genre/tag to another genre/tag
|
||||
/// </summary>
|
||||
public List<MetadataFieldMapping> FieldMappings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Do not allow any Genre/Tag in this list to be written to Kavita
|
||||
/// </summary>
|
||||
public List<string> Blacklist { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Only allow these Tags to be written to Kavita
|
||||
/// </summary>
|
||||
public List<string> Whitelist { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Which Roles to allow metadata downloading for
|
||||
/// </summary>
|
||||
public List<PersonRole> PersonRoles { get; set; }
|
||||
}
|
@ -12,6 +12,7 @@ using API.SignalR.Presence;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@ -113,6 +114,8 @@ public static class ApplicationServiceExtensions
|
||||
});
|
||||
options.EnableDetailedErrors();
|
||||
options.EnableSensitiveDataLogging();
|
||||
options.ConfigureWarnings(warnings =>
|
||||
warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -33,18 +33,22 @@ public static class PlusMediaFormatExtensions
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public static IList<MangaFormat> GetMangaFormats(this PlusMediaFormat? mediaFormat)
|
||||
{
|
||||
if (mediaFormat == null) return [MangaFormat.Archive];
|
||||
return mediaFormat.HasValue ? mediaFormat.Value.GetMangaFormats() : [MangaFormat.Archive];
|
||||
}
|
||||
|
||||
public static IList<MangaFormat> GetMangaFormats(this PlusMediaFormat mediaFormat)
|
||||
{
|
||||
return mediaFormat switch
|
||||
{
|
||||
PlusMediaFormat.Manga => [MangaFormat.Archive, MangaFormat.Image],
|
||||
PlusMediaFormat.Comic => [MangaFormat.Archive],
|
||||
PlusMediaFormat.LightNovel => [MangaFormat.Epub, MangaFormat.Pdf],
|
||||
PlusMediaFormat.Book => [MangaFormat.Epub, MangaFormat.Pdf],
|
||||
PlusMediaFormat.Unknown => [MangaFormat.Archive],
|
||||
_ => [MangaFormat.Archive]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ using API.DTOs.Email;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.KavitaPlus.Manage;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.MediaErrors;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Progress;
|
||||
@ -359,5 +360,11 @@ public class AutoMapperProfiles : Profile
|
||||
.ForMember(dest => dest.VolumeTitle, opt => opt.MapFrom(src => src.Volume.Name))
|
||||
.ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId))
|
||||
.ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type));
|
||||
|
||||
CreateMap<MetadataSettings, MetadataSettingsDto>()
|
||||
.ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List<string>()))
|
||||
.ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List<string>()));
|
||||
CreateMap<MetadataFieldMapping, MetadataFieldMappingDto>();
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -104,7 +104,13 @@ public class LibraryBuilder : IEntityBuilder<Library>
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryBuilder WIthAllowScrobbling(bool allowScrobbling)
|
||||
public LibraryBuilder WithAllowMetadataMatching(bool allow)
|
||||
{
|
||||
_library.AllowMetadataMatching = allow;
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryBuilder WithAllowScrobbling(bool allowScrobbling)
|
||||
{
|
||||
_library.AllowScrobbling = allowScrobbling;
|
||||
return this;
|
||||
|
@ -7,10 +7,10 @@ using API.Services.Plus;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class PlusSeriesDtoBuilder : IEntityBuilder<PlusSeriesDto>
|
||||
public class PlusSeriesDtoBuilder : IEntityBuilder<PlusSeriesRequestDto>
|
||||
{
|
||||
private readonly PlusSeriesDto _seriesDto;
|
||||
public PlusSeriesDto Build() => _seriesDto;
|
||||
private readonly PlusSeriesRequestDto _seriesRequestDto;
|
||||
public PlusSeriesRequestDto Build() => _seriesRequestDto;
|
||||
|
||||
/// <summary>
|
||||
/// This must be a FULL Series
|
||||
@ -18,7 +18,7 @@ public class PlusSeriesDtoBuilder : IEntityBuilder<PlusSeriesDto>
|
||||
/// <param name="series"></param>
|
||||
public PlusSeriesDtoBuilder(Series series)
|
||||
{
|
||||
_seriesDto = new PlusSeriesDto()
|
||||
_seriesRequestDto = new PlusSeriesRequestDto()
|
||||
{
|
||||
MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
|
||||
SeriesName = series.Name,
|
||||
|
@ -73,13 +73,19 @@ public static class GenreHelper
|
||||
|
||||
public static void UpdateGenreList(ICollection<GenreTagDto>? existingGenres, Series series,
|
||||
IReadOnlyCollection<Genre> newGenres, Action<Genre> handleAdd, Action onModified)
|
||||
{
|
||||
UpdateGenreList(existingGenres.DefaultIfEmpty().Select(t => t.Title).ToList(), series, newGenres, handleAdd, onModified);
|
||||
}
|
||||
|
||||
public static void UpdateGenreList(ICollection<string>? existingGenres, Series series,
|
||||
IReadOnlyCollection<Genre> newGenres, Action<Genre> handleAdd, Action onModified)
|
||||
{
|
||||
if (existingGenres == null) return;
|
||||
|
||||
var isModified = false;
|
||||
|
||||
// Convert tags and existing genres to hash sets for quick lookups by normalized title
|
||||
var tagSet = new HashSet<string>(existingGenres.Select(t => t.Title.ToNormalized()));
|
||||
var tagSet = new HashSet<string>(existingGenres.Select(t => t.ToNormalized()));
|
||||
var genreSet = new HashSet<string>(series.Metadata.Genres.Select(g => g.NormalizedTitle));
|
||||
|
||||
// Remove tags that are no longer present in the input tags
|
||||
@ -99,7 +105,7 @@ public static class GenreHelper
|
||||
// Add new tags from the input list
|
||||
foreach (var tagDto in existingGenres)
|
||||
{
|
||||
var normalizedTitle = tagDto.Title.ToNormalized();
|
||||
var normalizedTitle = tagDto.ToNormalized();
|
||||
|
||||
if (genreSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres
|
||||
|
||||
@ -109,7 +115,7 @@ public static class GenreHelper
|
||||
}
|
||||
else
|
||||
{
|
||||
handleAdd(new GenreBuilder(tagDto.Title).Build()); // Add new genre if not found
|
||||
handleAdd(new GenreBuilder(tagDto).Build()); // Add new genre if not found
|
||||
}
|
||||
isModified = true;
|
||||
}
|
||||
|
@ -103,13 +103,18 @@ public static class TagHelper
|
||||
|
||||
|
||||
public static void UpdateTagList(ICollection<TagDto>? existingDbTags, Series series, IReadOnlyCollection<Tag> newTags, Action<Tag> handleAdd, Action onModified)
|
||||
{
|
||||
UpdateTagList(existingDbTags.Select(t => t.Title).ToList(), series, newTags, handleAdd, onModified);
|
||||
}
|
||||
|
||||
public static void UpdateTagList(ICollection<string>? existingDbTags, Series series, IReadOnlyCollection<Tag> newTags, Action<Tag> handleAdd, Action onModified)
|
||||
{
|
||||
if (existingDbTags == null) return;
|
||||
|
||||
var isModified = false;
|
||||
|
||||
// Convert tags and existing genres to hash sets for quick lookups by normalized title
|
||||
var existingTagSet = new HashSet<string>(existingDbTags.Select(t => t.Title.ToNormalized()));
|
||||
var existingTagSet = new HashSet<string>(existingDbTags.Select(t => t.ToNormalized()));
|
||||
var dbTagSet = new HashSet<string>(series.Metadata.Tags.Select(g => g.NormalizedTitle));
|
||||
|
||||
// Remove tags that are no longer present in the input tags
|
||||
@ -129,7 +134,7 @@ public static class TagHelper
|
||||
// Add new tags from the input list
|
||||
foreach (var tagDto in existingDbTags)
|
||||
{
|
||||
var normalizedTitle = tagDto.Title.ToNormalized();
|
||||
var normalizedTitle = tagDto.ToNormalized();
|
||||
|
||||
if (dbTagSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres
|
||||
|
||||
@ -139,7 +144,7 @@ public static class TagHelper
|
||||
}
|
||||
else
|
||||
{
|
||||
handleAdd(new TagBuilder(tagDto.Title).Build()); // Add new genre if not found
|
||||
handleAdd(new TagBuilder(tagDto).Build()); // Add new genre if not found
|
||||
}
|
||||
isModified = true;
|
||||
}
|
||||
|
@ -58,7 +58,7 @@ public class Program
|
||||
}
|
||||
|
||||
Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
|
||||
? "http://localhost:5020" : "https://plus.kavitareader.com";
|
||||
? "http://localhost:5020" : "https://plus-next.kavitareader.com";
|
||||
|
||||
try
|
||||
{
|
||||
@ -129,6 +129,7 @@ public class Program
|
||||
await Seed.SeedDefaultStreams(unitOfWork);
|
||||
await Seed.SeedDefaultSideNavStreams(unitOfWork);
|
||||
await Seed.SeedUserApiKeys(context);
|
||||
await Seed.SeedMetadataSettings(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
@ -5,17 +5,10 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Extensions;
|
||||
using EasyCaching.Core;
|
||||
using Flurl;
|
||||
using Flurl.Http;
|
||||
using HtmlAgilityPack;
|
||||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetVips;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
@ -58,6 +51,7 @@ public interface IImageService
|
||||
/// <param name="encodeFormat"></param>
|
||||
/// <returns></returns>
|
||||
string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Converts the passed image to encoding and outputs it in the same directory
|
||||
/// </summary>
|
||||
@ -601,6 +595,7 @@ public class ImageService : IImageService
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name format for a chapter cover image
|
||||
/// </summary>
|
||||
|
@ -1,12 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Collection;
|
||||
using API.DTOs.KavitaPlus.ExternalMetadata;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.Metadata.Matching;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.Scrobbling;
|
||||
@ -16,6 +18,8 @@ using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Flurl.Http;
|
||||
@ -33,7 +37,6 @@ public interface IExternalMetadataService
|
||||
{
|
||||
Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId);
|
||||
Task<SeriesDetailPlusDto?> GetSeriesDetailPlus(int seriesId, LibraryType libraryType);
|
||||
//Task ForceKavitaPlusRefresh(int seriesId);
|
||||
Task FetchExternalDataTask();
|
||||
/// <summary>
|
||||
/// This is an entry point and provides a level of protection against calling upstream API. Will only allow 100 new
|
||||
@ -46,7 +49,7 @@ public interface IExternalMetadataService
|
||||
|
||||
Task<IList<MalStackDto>> GetStacksForUser(int userId);
|
||||
Task<IList<ExternalSeriesMatchDto>> MatchSeries(MatchSeriesDto dto);
|
||||
Task FixSeriesMatch(int seriesId, ExternalSeriesDetailDto dto);
|
||||
Task FixSeriesMatch(int seriesId, int anilistId);
|
||||
Task UpdateSeriesDontMatch(int seriesId, bool dontMatch);
|
||||
}
|
||||
|
||||
@ -58,6 +61,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
private readonly ILicenseService _licenseService;
|
||||
private readonly IScrobblingService _scrobblingService;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ICoverDbService _coverDbService;
|
||||
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30);
|
||||
public static readonly HashSet<LibraryType> NonEligibleLibraryTypes =
|
||||
[LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine];
|
||||
@ -71,7 +75,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false);
|
||||
|
||||
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper,
|
||||
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub)
|
||||
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
@ -79,6 +83,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
_licenseService = licenseService;
|
||||
_scrobblingService = scrobblingService;
|
||||
_eventHub = eventHub;
|
||||
_coverDbService = coverDbService;
|
||||
|
||||
FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl);
|
||||
}
|
||||
@ -120,29 +125,6 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
_logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes from Blacklist and Invalidates the cache
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
// public async Task ForceKavitaPlusRefresh(int seriesId)
|
||||
// {
|
||||
// // TODO: I think we can remove this now
|
||||
// if (!await _licenseService.HasActiveLicense()) return;
|
||||
// var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId);
|
||||
// if (!IsPlusEligible(libraryType)) return;
|
||||
//
|
||||
// // Remove from Blacklist if applicable
|
||||
// var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
// series!.IsBlacklisted = false;
|
||||
// _unitOfWork.SeriesRepository.Update(series);
|
||||
//
|
||||
// var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
|
||||
// if (metadata == null) return;
|
||||
//
|
||||
// metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache);
|
||||
// await _unitOfWork.CommitAsync();
|
||||
// }
|
||||
|
||||
/// <summary>
|
||||
/// Fetches data from Kavita+
|
||||
@ -165,9 +147,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
|
||||
_logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId);
|
||||
// Prefetch SeriesDetail data
|
||||
await GetSeriesDetailPlus(seriesId, libraryType);
|
||||
|
||||
// TODO: Fetch Series Metadata (Summary, etc)
|
||||
var metadata = await GetSeriesDetailPlus(seriesId, libraryType);
|
||||
|
||||
}
|
||||
|
||||
@ -266,7 +246,13 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
return string.Empty; // Return as is if null, empty, or whitespace.
|
||||
}
|
||||
|
||||
return summary.Replace("<br/>", string.Empty);
|
||||
// Remove all variations of <br> tags (case-insensitive)
|
||||
summary = Regex.Replace(summary, @"<br\s*/?>", " ", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// Normalize whitespace (replace multiple spaces with a single space)
|
||||
summary = Regex.Replace(summary, @"\s+", " ").Trim();
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
|
||||
@ -329,8 +315,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
/// This will override any sort of matching that was done prior and force it to be what the user Selected
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="dto"></param>
|
||||
public async Task FixSeriesMatch(int seriesId, ExternalSeriesDetailDto dto)
|
||||
/// <param name="anilistId"></param>
|
||||
public async Task FixSeriesMatch(int seriesId, int anilistId)
|
||||
{
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
|
||||
if (series == null) return;
|
||||
@ -341,14 +327,18 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
|
||||
// Refetch metadata with a Direct lookup
|
||||
await FetchExternalMetadataForSeries(seriesId, series.Library.Type, new PlusSeriesDto()
|
||||
var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type, new PlusSeriesRequestDto()
|
||||
{
|
||||
SeriesName = dto.Name,
|
||||
AniListId = dto.AniListId,
|
||||
MalId = dto.MALId,
|
||||
MediaFormat = dto.PlusMediaFormat,
|
||||
AniListId = anilistId,
|
||||
SeriesName = string.Empty // Required field
|
||||
});
|
||||
|
||||
if (metadata.Series == null)
|
||||
{
|
||||
_logger.LogError("Unable to Match {SeriesName} with Kavita+ Series AniList Id: {AniListId}", series.Name, anilistId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all scrobble events and rewrite them to be the correct
|
||||
var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId);
|
||||
_unitOfWork.ScrobbleRepository.Remove(events);
|
||||
@ -356,11 +346,12 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
|
||||
// Regenerate all events for the series for all users
|
||||
BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistoryForSeries(seriesId));
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Info,
|
||||
MessageFactory.InfoEvent($"Fix Match: {series.Name}", "Scrobble Events are regenerating with the new match"));
|
||||
// await _eventHub.SendMessageAsync(MessageFactory.Info,
|
||||
// MessageFactory.InfoEvent($"Fix Match: {series.Name}", "Scrobble Events are regenerating with the new match"));
|
||||
|
||||
|
||||
_logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, dto.Name);
|
||||
// Name can be null on Series even with a direct match
|
||||
_logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, metadata.Series.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -398,15 +389,15 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
/// <param name="libraryType"></param>
|
||||
/// <param name="data"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<SeriesDetailPlusDto> FetchExternalMetadataForSeries(int seriesId, LibraryType libraryType, PlusSeriesDto data)
|
||||
private async Task<SeriesDetailPlusDto> FetchExternalMetadataForSeries(int seriesId, LibraryType libraryType, PlusSeriesRequestDto data)
|
||||
{
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
|
||||
if (series == null) return _defaultReturn;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", data.SeriesName);
|
||||
_logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName);
|
||||
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||
var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
|
||||
.WithKavitaPlusHeaders(license)
|
||||
@ -415,7 +406,6 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
|
||||
|
||||
// Clear out existing results
|
||||
|
||||
var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series!);
|
||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews);
|
||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings);
|
||||
@ -450,13 +440,33 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
|
||||
if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value;
|
||||
if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value;
|
||||
|
||||
// If there is metadata and the user has metadata download turned on
|
||||
var madeMetadataModification = false;
|
||||
if (result.Series != null && series.Library.AllowMetadataMatching)
|
||||
{
|
||||
madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId);
|
||||
if (madeMetadataModification)
|
||||
{
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
if (madeMetadataModification)
|
||||
{
|
||||
// Inform the UI of the update
|
||||
await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(series.LibraryId, series.Id, series.Name), false);
|
||||
}
|
||||
|
||||
return new SeriesDetailPlusDto()
|
||||
{
|
||||
Recommendations = recs,
|
||||
Ratings = result.Ratings,
|
||||
Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map<UserReviewDto>(r))
|
||||
Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map<UserReviewDto>(r)),
|
||||
Series = result.Series
|
||||
};
|
||||
}
|
||||
catch (FlurlHttpException ex)
|
||||
@ -478,6 +488,413 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
return _defaultReturn;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given external metadata from Kavita+, write as much as possible to the Kavita series as possible
|
||||
/// </summary>
|
||||
/// <param name="externalMetadata"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<bool> WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId)
|
||||
{
|
||||
var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
|
||||
if (!settings.Enabled) return false;
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Related);
|
||||
if (series == null) return false;
|
||||
var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
|
||||
_logger.LogInformation("Writing External metadata to Series {SeriesName}", series.Name);
|
||||
|
||||
var madeModification = false;
|
||||
|
||||
if (!series.Metadata.SummaryLocked && string.IsNullOrEmpty(series.Metadata.Summary) && settings.EnableSummary)
|
||||
{
|
||||
series.Metadata.Summary = CleanSummary(externalMetadata.Summary);
|
||||
madeModification = true;
|
||||
}
|
||||
|
||||
if (settings.EnableStartDate && externalMetadata.StartDate.HasValue)
|
||||
{
|
||||
series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year;
|
||||
madeModification = true;
|
||||
}
|
||||
|
||||
var processedGenres = new List<string>();
|
||||
var processedTags = new List<string>();
|
||||
|
||||
#region Genres and Tags
|
||||
|
||||
// Process Genres
|
||||
if (externalMetadata.Genres != null)
|
||||
{
|
||||
foreach (var genre in externalMetadata.Genres.Where(g => !settings.Blacklist.Contains(g)))
|
||||
{
|
||||
// Apply field mappings
|
||||
var mappedGenre = ApplyFieldMapping(genre, MetadataFieldType.Genre, settings.FieldMappings);
|
||||
if (mappedGenre != null)
|
||||
{
|
||||
processedGenres.Add(mappedGenre);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip blacklisted items from processedGenres
|
||||
processedGenres = processedGenres.Distinct().Where(g => !settings.Blacklist.Contains(g)).ToList();
|
||||
|
||||
if (settings.EnableGenres && processedGenres.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name);
|
||||
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList();
|
||||
series.Metadata.Genres ??= [];
|
||||
GenreHelper.UpdateGenreList(processedGenres, series, allGenres, genre =>
|
||||
{
|
||||
series.Metadata.Genres.Add(genre);
|
||||
madeModification = true;
|
||||
}, () => series.Metadata.GenresLocked = true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Process Tags
|
||||
if (externalMetadata.Tags != null)
|
||||
{
|
||||
foreach (var tag in externalMetadata.Tags.Select(t => t.Name))
|
||||
{
|
||||
// Apply field mappings
|
||||
var mappedTag = ApplyFieldMapping(tag, MetadataFieldType.Tag, settings.FieldMappings);
|
||||
if (mappedTag != null)
|
||||
{
|
||||
processedTags.Add(mappedTag);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip blacklisted items from processedTags
|
||||
processedTags = processedTags.Distinct()
|
||||
.Where(g => !settings.Blacklist.Contains(g))
|
||||
.Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g))
|
||||
.ToList();
|
||||
|
||||
// Set the tags for the series and ensure they are in the DB
|
||||
if (settings.EnableTags && processedTags.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name);
|
||||
var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize)))
|
||||
.ToList();
|
||||
series.Metadata.Tags ??= [];
|
||||
TagHelper.UpdateTagList(processedTags, series, allTags, tag =>
|
||||
{
|
||||
series.Metadata.Tags.Add(tag);
|
||||
madeModification = true;
|
||||
}, () => series.Metadata.TagsLocked = true);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Age Rating
|
||||
|
||||
// Determine Age Rating
|
||||
var ageRating = DetermineAgeRating(processedGenres.Concat(processedTags), settings.AgeRatingMappings);
|
||||
if (!series.Metadata.AgeRatingLocked && series.Metadata.AgeRating <= ageRating)
|
||||
{
|
||||
series.Metadata.AgeRating = ageRating;
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
madeModification = true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region People
|
||||
|
||||
if (settings.EnablePeople)
|
||||
{
|
||||
series.Metadata.People ??= new List<SeriesMetadataPeople>();
|
||||
|
||||
// Ensure all people are named correctly
|
||||
externalMetadata.Staff = externalMetadata.Staff.Select(s =>
|
||||
{
|
||||
if (settings.FirstLastPeopleNaming)
|
||||
{
|
||||
s.Name = s.FirstName + " " + s.LastName;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.Name = s.LastName + " " + s.FirstName;
|
||||
}
|
||||
|
||||
return s;
|
||||
}).ToList();
|
||||
|
||||
// Roles: Character Design, Story, Art
|
||||
|
||||
var allWriters = externalMetadata.Staff
|
||||
.Where(s => s.Role is "Story" or "Story & Art")
|
||||
.ToList();
|
||||
|
||||
var writers = allWriters
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name,
|
||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||
Description = CleanSummary(w.Description),
|
||||
}).ToList();
|
||||
|
||||
|
||||
// NOTE: PersonRoles can be a hashset
|
||||
if (!series.Metadata.WriterLocked && writers.Count > 0 && settings.PersonRoles.Contains(PersonRole.Writer))
|
||||
{
|
||||
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork);
|
||||
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
await DownloadAndSetCovers(allWriters);
|
||||
|
||||
madeModification = true;
|
||||
}
|
||||
|
||||
var allArtists = externalMetadata.Staff
|
||||
.Where(s => s.Role is "Art" or "Story & Art")
|
||||
.ToList();
|
||||
|
||||
var artists = allArtists
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name,
|
||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||
Description = CleanSummary(w.Description),
|
||||
}).ToList();
|
||||
|
||||
if (!series.Metadata.CoverArtistLocked && artists.Count > 0 && settings.PersonRoles.Contains(PersonRole.CoverArtist))
|
||||
{
|
||||
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork);
|
||||
|
||||
// Download the image and save it
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
await DownloadAndSetCovers(allArtists);
|
||||
|
||||
madeModification = true;
|
||||
}
|
||||
|
||||
if (externalMetadata.Characters != null && settings.PersonRoles.Contains(PersonRole.Character))
|
||||
{
|
||||
var characters = externalMetadata.Characters
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name,
|
||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
|
||||
Description = CleanSummary(w.Description),
|
||||
}).ToList();
|
||||
|
||||
|
||||
if (!series.Metadata.CharacterLocked && characters.Count > 0)
|
||||
{
|
||||
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork);
|
||||
|
||||
// Download the image and save it
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
foreach (var character in externalMetadata.Characters)
|
||||
{
|
||||
var aniListId = ScrobblingService.ExtractId<int>(character.Url, ScrobblingService.AniListCharacterWebsite);
|
||||
if (aniListId <= 0) continue;
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId);
|
||||
if (person != null && !string.IsNullOrEmpty(character.ImageUrl) && string.IsNullOrEmpty(person.CoverImage))
|
||||
{
|
||||
await _coverDbService.SetPersonCoverImage(person, character.ImageUrl, false);
|
||||
}
|
||||
}
|
||||
|
||||
madeModification = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus)
|
||||
{
|
||||
var chapters = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes.SelectMany(v => v.Chapters).ToList();
|
||||
var wasChanged = DeterminePublicationStatus(series, chapters, externalMetadata);
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
madeModification = madeModification || wasChanged;
|
||||
}
|
||||
|
||||
if (settings.EnableRelationships && externalMetadata.Relations != null && defaultAdmin != null)
|
||||
{
|
||||
|
||||
foreach (var relation in externalMetadata.Relations)
|
||||
{
|
||||
var relatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName(
|
||||
relation.SeriesName.NativeTitle,
|
||||
relation.SeriesName.PreferredTitle,
|
||||
relation.PlusMediaFormat.GetMangaFormats(),
|
||||
defaultAdmin.Id,
|
||||
relation.AniListId,
|
||||
SeriesIncludes.Related);
|
||||
|
||||
// Skip if no related series found or series is the parent
|
||||
if (relatedSeries == null || relatedSeries.Id == series.Id || relation.Relation == RelationKind.Parent) continue;
|
||||
|
||||
// Check if the relationship already exists
|
||||
var relationshipExists = series.Relations.Any(r =>
|
||||
r.TargetSeriesId == relatedSeries.Id && r.RelationKind == relation.Relation);
|
||||
|
||||
if (relationshipExists) continue;
|
||||
|
||||
series.Relations.Add(new SeriesRelation
|
||||
{
|
||||
RelationKind = relation.Relation,
|
||||
TargetSeries = relatedSeries,
|
||||
TargetSeriesId = relatedSeries.Id,
|
||||
Series = series,
|
||||
SeriesId = series.Id
|
||||
});
|
||||
|
||||
// Handle sequel/prequel: add reverse relationship
|
||||
if (relation.Relation is RelationKind.Prequel or RelationKind.Sequel)
|
||||
{
|
||||
var reverseExists = relatedSeries.Relations.Any(r =>
|
||||
r.TargetSeriesId == series.Id && r.RelationKind == GetReverseRelation(relation.Relation));
|
||||
|
||||
if (reverseExists) continue;
|
||||
|
||||
relatedSeries.Relations.Add(new SeriesRelation
|
||||
{
|
||||
RelationKind = GetReverseRelation(relation.Relation),
|
||||
TargetSeries = series,
|
||||
TargetSeriesId = series.Id,
|
||||
Series = relatedSeries,
|
||||
SeriesId = relatedSeries.Id
|
||||
});
|
||||
}
|
||||
|
||||
madeModification = true;
|
||||
}
|
||||
}
|
||||
|
||||
return madeModification;
|
||||
}
|
||||
|
||||
private static RelationKind GetReverseRelation(RelationKind relation)
|
||||
{
|
||||
return relation switch
|
||||
{
|
||||
RelationKind.Prequel => RelationKind.Sequel,
|
||||
RelationKind.Sequel => RelationKind.Prequel,
|
||||
_ => relation // For other relationships, no reverse needed
|
||||
};
|
||||
}
|
||||
|
||||
private async Task DownloadAndSetCovers(List<SeriesStaffDto> people)
|
||||
{
|
||||
foreach (var staff in people)
|
||||
{
|
||||
var aniListId = ScrobblingService.ExtractId<int?>(staff.Url, ScrobblingService.AniListStaffWebsite);
|
||||
if (aniListId is null or <= 0) continue;
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value);
|
||||
if (person != null && !string.IsNullOrEmpty(staff.ImageUrl) && string.IsNullOrEmpty(person.CoverImage))
|
||||
{
|
||||
await _coverDbService.SetPersonCoverImage(person, staff.ImageUrl, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool DeterminePublicationStatus(Series series, List<Chapter> chapters, ExternalSeriesDetailDto externalMetadata)
|
||||
{
|
||||
var madeModification = false;
|
||||
try
|
||||
{
|
||||
// Determine the expected total count based on local metadata
|
||||
series.Metadata.TotalCount = Math.Max(
|
||||
chapters.Max(chapter => chapter.TotalCount),
|
||||
externalMetadata.Volumes > 0 ? externalMetadata.Volumes : externalMetadata.Chapters
|
||||
);
|
||||
|
||||
// The actual number of count's defined across all chapter's metadata
|
||||
series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count);
|
||||
|
||||
var nonSpecialVolumes = series.Volumes
|
||||
.Where(v => v.MaxNumber.IsNot(Parser.SpecialVolumeNumber))
|
||||
.ToList();
|
||||
|
||||
var maxVolume = (int)(nonSpecialVolumes.Count != 0 ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0);
|
||||
var maxChapter = (int)chapters.Max(c => c.MaxNumber);
|
||||
|
||||
if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1)
|
||||
{
|
||||
series.Metadata.MaxCount = 1;
|
||||
}
|
||||
else if (series.Metadata.TotalCount <= 1 && chapters.Count == 1 && chapters[0].IsSpecial)
|
||||
{
|
||||
series.Metadata.MaxCount = series.Metadata.TotalCount;
|
||||
}
|
||||
else if ((maxChapter == Parser.DefaultChapterNumber || maxChapter > series.Metadata.TotalCount) &&
|
||||
maxVolume <= series.Metadata.TotalCount)
|
||||
{
|
||||
series.Metadata.MaxCount = maxVolume;
|
||||
}
|
||||
else if (maxVolume == series.Metadata.TotalCount)
|
||||
{
|
||||
series.Metadata.MaxCount = maxVolume;
|
||||
}
|
||||
else
|
||||
{
|
||||
series.Metadata.MaxCount = maxChapter;
|
||||
}
|
||||
|
||||
var status = PublicationStatus.OnGoing;
|
||||
|
||||
var hasExternalCounts = externalMetadata.Volumes > 0 || externalMetadata.Chapters > 0;
|
||||
|
||||
if (hasExternalCounts)
|
||||
{
|
||||
status = PublicationStatus.Ended;
|
||||
|
||||
// Check if all volumes/chapters match the total count
|
||||
if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0)
|
||||
{
|
||||
status = PublicationStatus.Completed;
|
||||
}
|
||||
|
||||
madeModification = true;
|
||||
}
|
||||
|
||||
series.Metadata.PublicationStatus = status;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogCritical(ex, "There was an issue determining Publication Status");
|
||||
series.Metadata.PublicationStatus = PublicationStatus.OnGoing;
|
||||
}
|
||||
|
||||
return madeModification;
|
||||
}
|
||||
|
||||
private static string? ApplyFieldMapping(string value, MetadataFieldType sourceType, List<MetadataFieldMappingDto> mappings)
|
||||
{
|
||||
// Find matching mapping
|
||||
var mapping = mappings
|
||||
.FirstOrDefault(m =>
|
||||
m.SourceType == sourceType &&
|
||||
m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (mapping == null) return value;
|
||||
|
||||
// If mapping exists, return destination or source value
|
||||
return mapping.DestinationValue ?? (mapping.ExcludeFromSource ? null : value);
|
||||
}
|
||||
|
||||
private static AgeRating DetermineAgeRating(IEnumerable<string> values, Dictionary<string, AgeRating> mappings)
|
||||
{
|
||||
// Find highest age rating from mappings
|
||||
return values
|
||||
.Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown)
|
||||
.DefaultIfEmpty(AgeRating.Unknown)
|
||||
.Max();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets from DB or creates a new one with just SeriesId
|
||||
|
@ -56,7 +56,7 @@ public interface IScrobblingService
|
||||
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
Task ProcessUpdatesSinceLastSync();
|
||||
Task CreateEventsFromExistingHistory(int userId = 0);
|
||||
Task CreateEventsFromExistingHistoryForSeries(int seriesId = 0);
|
||||
Task CreateEventsFromExistingHistoryForSeries(int seriesId);
|
||||
Task ClearEventsForSeries(int userId, int seriesId);
|
||||
}
|
||||
|
||||
@ -73,6 +73,9 @@ public class ScrobblingService : IScrobblingService
|
||||
public const string MalWeblinkWebsite = "https://myanimelist.net/manga/";
|
||||
public const string GoogleBooksWeblinkWebsite = "https://books.google.com/books?id=";
|
||||
public const string MangaDexWeblinkWebsite = "https://mangadex.org/title/";
|
||||
public const string AniListStaffWebsite = "https://anilist.co/staff/";
|
||||
public const string AniListCharacterWebsite = "https://anilist.co/character/";
|
||||
|
||||
|
||||
private static readonly IDictionary<string, int> WeblinkExtractionMap = new Dictionary<string, int>()
|
||||
{
|
||||
@ -80,6 +83,8 @@ public class ScrobblingService : IScrobblingService
|
||||
{MalWeblinkWebsite, 0},
|
||||
{GoogleBooksWeblinkWebsite, 0},
|
||||
{MangaDexWeblinkWebsite, 0},
|
||||
{AniListStaffWebsite, 0},
|
||||
{AniListCharacterWebsite, 0},
|
||||
};
|
||||
|
||||
private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90)
|
||||
@ -314,6 +319,9 @@ public class ScrobblingService : IScrobblingService
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
|
||||
if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return;
|
||||
|
||||
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
|
||||
|
||||
@ -365,6 +373,9 @@ public class ScrobblingService : IScrobblingService
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
|
||||
if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return;
|
||||
|
||||
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
|
||||
|
||||
@ -419,7 +430,10 @@ public class ScrobblingService : IScrobblingService
|
||||
if (!await _licenseService.HasActiveLicense()) return;
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
if (series == null || !series.Library.AllowScrobbling) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
|
||||
if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return;
|
||||
|
||||
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
|
||||
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
@ -639,55 +653,57 @@ public class ScrobblingService : IScrobblingService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateEventsFromExistingHistoryForSeries(int seriesId = 0)
|
||||
public async Task CreateEventsFromExistingHistoryForSeries(int seriesId)
|
||||
{
|
||||
if (!await _licenseService.HasActiveLicense()) return;
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
if (series == null) return;
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
|
||||
if (series == null || !series.Library.AllowScrobbling) return;
|
||||
|
||||
_logger.LogInformation("Creating Scrobbling events for Series {SeriesName}", series.Name);
|
||||
|
||||
var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync())
|
||||
.ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling);
|
||||
|
||||
var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync())
|
||||
.Select(u => u.Id);
|
||||
|
||||
foreach (var uId in userIds)
|
||||
{
|
||||
// Handle "Want to Read" updates specific to the series
|
||||
var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId);
|
||||
foreach (var wtr in wantToRead)
|
||||
foreach (var wtr in wantToRead.Where(wtr => wtr.Id == seriesId))
|
||||
{
|
||||
if (!libAllowsScrobbling[wtr.LibraryId]) continue;
|
||||
await ScrobbleWantToReadUpdate(uId, wtr.Id, true);
|
||||
}
|
||||
|
||||
// Handle ratings specific to the series
|
||||
var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(uId);
|
||||
foreach (var rating in ratings)
|
||||
foreach (var rating in ratings.Where(rating => rating.SeriesId == seriesId))
|
||||
{
|
||||
if (!libAllowsScrobbling[rating.Series.LibraryId]) continue;
|
||||
await ScrobbleRatingUpdate(uId, rating.SeriesId, rating.Rating);
|
||||
}
|
||||
|
||||
var seriesWithProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, uId,
|
||||
new UserParams(), new FilterDto()
|
||||
// Handle progress updates for the specific series
|
||||
var seriesProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(
|
||||
series.LibraryId,
|
||||
uId,
|
||||
new UserParams(),
|
||||
new FilterDto
|
||||
{
|
||||
ReadStatus = new ReadStatus()
|
||||
ReadStatus = new ReadStatus
|
||||
{
|
||||
Read = true,
|
||||
InProgress = true,
|
||||
NotRead = false
|
||||
},
|
||||
Libraries = libAllowsScrobbling.Keys.Where(k => libAllowsScrobbling[k]).ToList(),
|
||||
Libraries = new List<int> { series.LibraryId },
|
||||
SeriesNameQuery = series.Name
|
||||
});
|
||||
|
||||
foreach (var seriesProgress in seriesWithProgress)
|
||||
foreach (var progress in seriesProgress.Where(progress => progress.Id == seriesId))
|
||||
{
|
||||
if (!libAllowsScrobbling[seriesProgress.LibraryId]) continue;
|
||||
if (seriesProgress.PagesRead <= 0) continue; // Since we only scrobble when things are higher, we can
|
||||
await ScrobbleReadingUpdate(uId, seriesProgress.Id);
|
||||
if (progress.PagesRead > 0)
|
||||
{
|
||||
await ScrobbleReadingUpdate(uId, progress.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -784,6 +800,7 @@ public class ScrobblingService : IScrobblingService
|
||||
.Concat(removeWantToRead.Select(r => r.AppUser))
|
||||
.Concat(ratingEvents.Select(r => r.AppUser))
|
||||
.Where(user => !string.IsNullOrEmpty(user.AniListAccessToken))
|
||||
.Where(user => user.UserPreferences.AniListScrobblingEnabled) // TODO: Add more as we add more support
|
||||
.DistinctBy(u => u.Id)
|
||||
.ToList();
|
||||
foreach (var user in usersToScrobble)
|
||||
@ -891,8 +908,9 @@ public class ScrobblingService : IScrobblingService
|
||||
{
|
||||
_logger.LogDebug("Processing Reading Events: {Count} / {Total}", progressCounter, totalProgress);
|
||||
progressCounter++;
|
||||
|
||||
// Check if this media item can even be processed for this user
|
||||
if (!DoesUserHaveProviderAndValid(evt))
|
||||
if (!CanProcessScrobbleEvent(evt))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@ -997,7 +1015,7 @@ public class ScrobblingService : IScrobblingService
|
||||
}
|
||||
|
||||
|
||||
private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent)
|
||||
private static bool CanProcessScrobbleEvent(ScrobbleEvent readEvent)
|
||||
{
|
||||
var userProviders = GetUserProviders(readEvent.AppUser);
|
||||
if (readEvent.Series.Library.Type == LibraryType.Manga && MangaProviders.Intersect(userProviders).Any())
|
||||
@ -1052,6 +1070,12 @@ public class ScrobblingService : IScrobblingService
|
||||
if (int.TryParse(value, out var intValue))
|
||||
return (T)(object)intValue;
|
||||
}
|
||||
else if (typeof(T) == typeof(int))
|
||||
{
|
||||
if (int.TryParse(value, out var intValue))
|
||||
return (T)(object)intValue;
|
||||
return default;
|
||||
}
|
||||
else if (typeof(T) == typeof(long?))
|
||||
{
|
||||
if (long.TryParse(value, out var longValue))
|
||||
|
@ -45,8 +45,8 @@ public class WantToReadSyncService : IWantToReadSyncService
|
||||
|
||||
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||
|
||||
var users = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead);
|
||||
foreach (var user in users)
|
||||
var users = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead | AppUserIncludes.UserPreferences);
|
||||
foreach (var user in users.Where(u => u.UserPreferences.WantToReadSync))
|
||||
{
|
||||
if (string.IsNullOrEmpty(user.MalUserName) && string.IsNullOrEmpty(user.AniListAccessToken)) continue;
|
||||
|
||||
|
@ -14,6 +14,7 @@ using API.DTOs.CollectionTags;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
@ -44,6 +45,7 @@ public interface ISeriesService
|
||||
bool withHash);
|
||||
Task<string> FormatChapterName(int userId, LibraryType libraryType, bool withHash = false);
|
||||
Task<NextExpectedChapterDto> GetEstimatedChapterCreationDate(int seriesId, int userId);
|
||||
|
||||
}
|
||||
|
||||
public class SeriesService : ISeriesService
|
||||
@ -54,6 +56,7 @@ public class SeriesService : ISeriesService
|
||||
private readonly ILogger<SeriesService> _logger;
|
||||
private readonly IScrobblingService _scrobblingService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IImageService _imageService;
|
||||
|
||||
private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto
|
||||
{
|
||||
@ -63,7 +66,7 @@ public class SeriesService : ISeriesService
|
||||
};
|
||||
|
||||
public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler,
|
||||
ILogger<SeriesService> logger, IScrobblingService scrobblingService, ILocalizationService localizationService)
|
||||
ILogger<SeriesService> logger, IScrobblingService scrobblingService, ILocalizationService localizationService, IImageService imageService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
@ -71,6 +74,7 @@ public class SeriesService : ISeriesService
|
||||
_logger = logger;
|
||||
_scrobblingService = scrobblingService;
|
||||
_localizationService = localizationService;
|
||||
_imageService = imageService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -206,73 +210,73 @@ public class SeriesService : ISeriesService
|
||||
// Writers
|
||||
if (!series.Metadata.WriterLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer, _unitOfWork);
|
||||
}
|
||||
|
||||
// Cover Artists
|
||||
if (!series.Metadata.CoverArtistLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist, _unitOfWork);
|
||||
}
|
||||
|
||||
// Colorists
|
||||
if (!series.Metadata.ColoristLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist, _unitOfWork);
|
||||
}
|
||||
|
||||
// Editors
|
||||
if (!series.Metadata.EditorLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor, _unitOfWork);
|
||||
}
|
||||
|
||||
// Inkers
|
||||
if (!series.Metadata.InkerLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker, _unitOfWork);
|
||||
}
|
||||
|
||||
// Letterers
|
||||
if (!series.Metadata.LettererLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer, _unitOfWork);
|
||||
}
|
||||
|
||||
// Pencillers
|
||||
if (!series.Metadata.PencillerLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller, _unitOfWork);
|
||||
}
|
||||
|
||||
// Publishers
|
||||
if (!series.Metadata.PublisherLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher, _unitOfWork);
|
||||
}
|
||||
|
||||
// Imprints
|
||||
if (!series.Metadata.ImprintLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint, _unitOfWork);
|
||||
}
|
||||
|
||||
// Teams
|
||||
if (!series.Metadata.TeamLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team, _unitOfWork);
|
||||
}
|
||||
|
||||
// Locations
|
||||
if (!series.Metadata.LocationLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location, _unitOfWork);
|
||||
}
|
||||
|
||||
// Translators
|
||||
if (!series.Metadata.TranslatorLocked)
|
||||
{
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator);
|
||||
await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator, _unitOfWork);
|
||||
}
|
||||
|
||||
}
|
||||
@ -331,8 +335,10 @@ public class SeriesService : ISeriesService
|
||||
/// <param name="metadata"></param>
|
||||
/// <param name="peopleDtos"></param>
|
||||
/// <param name="role"></param>
|
||||
private async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection<PersonDto> peopleDtos, PersonRole role)
|
||||
public static async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection<PersonDto> peopleDtos, PersonRole role, IUnitOfWork unitOfWork)
|
||||
{
|
||||
// TODO: Cleanup this code so we aren't using UnitOfWork like this
|
||||
|
||||
// Normalize all names from the DTOs
|
||||
var normalizedNames = peopleDtos
|
||||
.Select(p => Parser.Normalize(p.Name))
|
||||
@ -340,7 +346,7 @@ public class SeriesService : ISeriesService
|
||||
.ToList();
|
||||
|
||||
// Bulk select people who already exist in the database
|
||||
var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
|
||||
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
|
||||
|
||||
// Use a dictionary for quick lookups
|
||||
var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName).ToDictionary(p => p.NormalizedName, p => p);
|
||||
@ -353,13 +359,26 @@ public class SeriesService : ISeriesService
|
||||
var normalizedPersonName = Parser.Normalize(personDto.Name);
|
||||
|
||||
// Check if the person exists in the dictionary
|
||||
if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out _)) continue;
|
||||
if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p))
|
||||
{
|
||||
if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId)
|
||||
{
|
||||
p.AniListId = personDto.AniListId;
|
||||
}
|
||||
continue; // If we ever want to update metadata for existing people, we'd do it here
|
||||
}
|
||||
|
||||
// Person doesn't exist, so create a new one
|
||||
var newPerson = new Person
|
||||
{
|
||||
Name = personDto.Name,
|
||||
NormalizedName = normalizedPersonName
|
||||
NormalizedName = normalizedPersonName,
|
||||
AniListId = personDto.AniListId,
|
||||
Description = personDto.Description,
|
||||
Asin = personDto.Asin,
|
||||
CoverImage = personDto.CoverImage,
|
||||
MalId = personDto.MalId,
|
||||
HardcoverId = personDto.HardcoverId,
|
||||
};
|
||||
|
||||
peopleToAdd.Add(newPerson);
|
||||
@ -369,7 +388,7 @@ public class SeriesService : ISeriesService
|
||||
// Add any new people to the database in bulk
|
||||
if (peopleToAdd.Count != 0)
|
||||
{
|
||||
_unitOfWork.PersonRepository.Attach(peopleToAdd);
|
||||
unitOfWork.PersonRepository.Attach(peopleToAdd);
|
||||
}
|
||||
|
||||
// Now that we have all the people (new and existing), update the SeriesMetadataPeople
|
||||
|
@ -4,10 +4,12 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.SignalR;
|
||||
using EasyCaching.Core;
|
||||
using Flurl;
|
||||
using Flurl.Http;
|
||||
@ -25,6 +27,8 @@ public interface ICoverDbService
|
||||
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
||||
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
||||
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat);
|
||||
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url);
|
||||
Task SetPersonCoverImage(Person person, string url, bool fromBase64 = true);
|
||||
}
|
||||
|
||||
|
||||
@ -34,6 +38,9 @@ public class CoverDbService : ICoverDbService
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IEasyCachingProviderFactory _cacheFactory;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
private const string NewHost = "https://www.kavitareader.com/CoversDB/";
|
||||
|
||||
@ -57,12 +64,16 @@ public class CoverDbService : ICoverDbService
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(1);
|
||||
|
||||
public CoverDbService(ILogger<CoverDbService> logger, IDirectoryService directoryService,
|
||||
IEasyCachingProviderFactory cacheFactory, IHostEnvironment env)
|
||||
IEasyCachingProviderFactory cacheFactory, IHostEnvironment env, IImageService imageService,
|
||||
IUnitOfWork unitOfWork, IEventHub eventHub)
|
||||
{
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
_cacheFactory = cacheFactory;
|
||||
_env = env;
|
||||
_imageService = imageService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
|
||||
@ -225,36 +236,37 @@ public class CoverDbService : ICoverDbService
|
||||
{
|
||||
throw new KavitaException($"Could not grab person image for {person.Name}");
|
||||
}
|
||||
return await DownloadPersonImageAsync(person, encodeFormat, personImageLink);
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading image for {PersonName}", person.Name);
|
||||
}
|
||||
|
||||
// Create the destination file path
|
||||
var filename = ImageService.GetPersonFormat(person.Id) + encodeFormat.GetExtension();
|
||||
var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure if file exists, we delete to overwrite
|
||||
|
||||
|
||||
_logger.LogTrace("Fetching publisher image from {Url}", personImageLink.Sanitize());
|
||||
// Download the file using Flurl
|
||||
var personStream = await personImageLink
|
||||
.AllowHttpStatus("2xx,304")
|
||||
.GetStreamAsync();
|
||||
|
||||
using var image = Image.NewFromStream(personStream);
|
||||
switch (encodeFormat)
|
||||
/// <summary>
|
||||
/// Attempts to download the Person cover image from a Url
|
||||
/// </summary>
|
||||
/// <param name="person"></param>
|
||||
/// <param name="encodeFormat"></param>
|
||||
/// <param name="url"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException"></exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException"></exception>
|
||||
public async Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var personImageLink = await GetCoverPersonImagePath(person);
|
||||
if (string.IsNullOrEmpty(personImageLink))
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(targetFile);
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(targetFile);
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(targetFile);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
throw new KavitaException($"Could not grab person image for {person.Name}");
|
||||
}
|
||||
|
||||
|
||||
var filename = await DownloadImageFromUrl(ImageService.GetPersonFormat(person.Id), encodeFormat, personImageLink);
|
||||
|
||||
_logger.LogDebug("Person image for {PersonName} downloaded and saved successfully", person.Name);
|
||||
|
||||
return filename;
|
||||
@ -266,6 +278,39 @@ public class CoverDbService : ICoverDbService
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string> DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url)
|
||||
{
|
||||
// Create the destination file path
|
||||
var filename = filenameWithoutExtension + encodeFormat.GetExtension();
|
||||
var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename);
|
||||
|
||||
// Ensure if file exists, we delete to overwrite
|
||||
|
||||
_logger.LogTrace("Fetching person image from {Url}", url.Sanitize());
|
||||
// Download the file using Flurl
|
||||
var personStream = await url
|
||||
.AllowHttpStatus("2xx,304")
|
||||
.GetStreamAsync();
|
||||
|
||||
using var image = Image.NewFromStream(personStream);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(targetFile);
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(targetFile);
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(targetFile);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
private async Task<string> GetCoverPersonImagePath(Person person)
|
||||
{
|
||||
var tempFile = Path.Join(_directoryService.LongTermCacheDirectory, "people.yml");
|
||||
@ -414,4 +459,49 @@ public class CoverDbService : ICoverDbService
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task SetPersonCoverImage(Person person, string url, bool fromBase64 = true)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64);
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
person.CoverImage = filePath;
|
||||
person.CoverImageLocked = true;
|
||||
_imageService.UpdateColorScape(person);
|
||||
_unitOfWork.PersonRepository.Update(person);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
person.CoverImage = string.Empty;
|
||||
person.CoverImageLocked = false;
|
||||
_imageService.UpdateColorScape(person);
|
||||
_unitOfWork.PersonRepository.Update(person);
|
||||
}
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> CreateThumbnail(string url, string filename, bool fromBase64 = true)
|
||||
{
|
||||
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
var encodeFormat = settings.EncodeMediaAs;
|
||||
var coverImageSize = settings.CoverImageSize;
|
||||
|
||||
if (fromBase64)
|
||||
{
|
||||
return _imageService.CreateThumbnailFromBase64(url,
|
||||
filename, encodeFormat, coverImageSize.GetDimensions().Width);
|
||||
}
|
||||
|
||||
return await DownloadImageFromUrl(filename, encodeFormat, url);
|
||||
}
|
||||
}
|
||||
|
@ -279,6 +279,7 @@ public class Startup
|
||||
|
||||
// v0.8.5
|
||||
await ManualMigrateBlacklistTableToSeries.Migrate(dataContext, logger);
|
||||
await ManualMigrateInvalidBlacklistSeries.Migrate(dataContext, logger);
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||
|
15
UI/Web/package-lock.json
generated
15
UI/Web/package-lock.json
generated
@ -465,6 +465,7 @@
|
||||
"version": "18.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz",
|
||||
"integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.25.2",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@ -492,6 +493,7 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@ -506,6 +508,7 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
|
||||
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
@ -4022,7 +4025,8 @@
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "8.3.6",
|
||||
@ -4529,6 +4533,7 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
@ -4538,6 +4543,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@ -7479,7 +7485,8 @@
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/replace-in-file": {
|
||||
"version": "7.1.0",
|
||||
@ -7750,7 +7757,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.77.6",
|
||||
@ -7784,6 +7791,7 @@
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
||||
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@ -8338,6 +8346,7 @@
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
@ -24,6 +24,7 @@ export interface Library {
|
||||
manageCollections: boolean;
|
||||
manageReadingLists: boolean;
|
||||
allowScrobbling: boolean;
|
||||
allowMetadataMatching: boolean;
|
||||
collapseSeriesRelationships: boolean;
|
||||
libraryFileTypes: Array<FileTypeGroup>;
|
||||
excludePatterns: Array<string>;
|
||||
|
@ -52,6 +52,10 @@ export interface Preferences {
|
||||
collapseSeriesRelationships: boolean;
|
||||
shareReviews: boolean;
|
||||
locale: string;
|
||||
|
||||
// Kavita+
|
||||
aniListScrobblingEnabled: boolean;
|
||||
wantToReadSync: boolean;
|
||||
}
|
||||
|
||||
export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}];
|
||||
|
@ -242,7 +242,7 @@ export class SeriesService {
|
||||
}
|
||||
|
||||
updateMatch(seriesId: number, series: ExternalSeriesDetail) {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'series/update-match?seriesId=' + seriesId, series, TextResonse);
|
||||
return this.httpClient.post<string>(this.baseUrl + 'series/update-match?seriesId=' + seriesId + '&aniListId=' + series.aniListId, {}, TextResonse);
|
||||
}
|
||||
|
||||
updateDontMatch(seriesId: number, dontMatch: boolean) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
<ng-container *transloco="let t; read:'match-series-result-item'">
|
||||
|
||||
<div class="d-flex p-1 clickable" (click)="selectItem()">
|
||||
<div style="width: 32px" class="me-1">
|
||||
@if (item.series.coverUrl) {
|
||||
@ -11,7 +12,7 @@
|
||||
@for(synm of item.series.synonyms; track synm; let last = $last) {
|
||||
{{synm}}
|
||||
@if (!last) {
|
||||
<span>, </span>
|
||||
<span>, </span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@ -23,16 +24,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex p-1 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.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) {
|
||||
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span>
|
||||
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span>
|
||||
} @else {
|
||||
<span class="me-1">{{t('releasing')}}</span>
|
||||
}
|
||||
@if (isSelected) {
|
||||
<div class="d-flex p-1 justify-content-center">
|
||||
<app-loading [absolute]="false" [loading]="true"></app-loading>
|
||||
<span class="ms-2">{{t('updating-metadata-status')}}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="d-flex p-1 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.volumeCount || 0) > 0 || (item.series.chapterCount || 0) > 0) {
|
||||
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span>
|
||||
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span>
|
||||
} @else {
|
||||
<span class="me-1">{{t('releasing')}}</span>
|
||||
}
|
||||
|
||||
<span class="me-1">{{item.series.plusMediaFormat | plusMediaFormat}}</span>
|
||||
<span class="me-1">({{item.matchRating | translocoPercent}})</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
<span class="me-1">{{item.series.plusMediaFormat | plusMediaFormat}}</span>
|
||||
<span class="me-1">({{item.matchRating | translocoPercent}})</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
@ -15,6 +15,7 @@ import {TranslocoPercentPipe} from "@jsverse/transloco-locale";
|
||||
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-match-series-result-item',
|
||||
@ -24,7 +25,8 @@ import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
|
||||
TranslocoPercentPipe,
|
||||
ReadMoreComponent,
|
||||
TranslocoDirective,
|
||||
PlusMediaFormatPipe
|
||||
PlusMediaFormatPipe,
|
||||
LoadingComponent
|
||||
],
|
||||
templateUrl: './match-series-result-item.component.html',
|
||||
styleUrl: './match-series-result-item.component.scss',
|
||||
@ -37,7 +39,13 @@ export class MatchSeriesResultItemComponent {
|
||||
@Input({required: true}) item!: ExternalSeriesMatch;
|
||||
@Output() selected: EventEmitter<ExternalSeriesMatch> = new EventEmitter();
|
||||
|
||||
isSelected = false;
|
||||
|
||||
selectItem() {
|
||||
if (this.isSelected) return;
|
||||
|
||||
this.isSelected = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.selected.emit(this.item);
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,8 @@
|
||||
|
||||
@if (tokenExpired) {
|
||||
<p class="alert alert-warning">{{t('token-expired')}}</p>
|
||||
} @else if (!(accountService.currentUser$ | async)!.preferences.aniListScrobblingEnabled) {
|
||||
<p class="alert alert-warning">{{t('scrobbling-disabled')}}</p>
|
||||
}
|
||||
|
||||
<p>{{t('description')}}</p>
|
||||
|
@ -18,6 +18,8 @@ import {ToastrService} from "ngx-toastr";
|
||||
import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter";
|
||||
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
|
||||
import {CardActionablesComponent} from "../card-actionables/card-actionables.component";
|
||||
import {AsyncPipe} from "@angular/common";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
|
||||
export interface DataTablePage {
|
||||
pageNumber: number,
|
||||
@ -29,8 +31,8 @@ export interface DataTablePage {
|
||||
@Component({
|
||||
selector: 'app-user-scrobble-history',
|
||||
standalone: true,
|
||||
imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule,
|
||||
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent],
|
||||
imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule,
|
||||
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, CardActionablesComponent, AsyncPipe],
|
||||
templateUrl: './user-scrobble-history.component.html',
|
||||
styleUrls: ['./user-scrobble-history.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -40,12 +42,14 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
||||
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
|
||||
protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber;
|
||||
protected readonly ColumnMode = ColumnMode;
|
||||
protected readonly ScrobbleEventType = ScrobbleEventType;
|
||||
|
||||
private readonly scrobblingService = inject(ScrobblingService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
protected readonly ScrobbleEventType = ScrobbleEventType;
|
||||
protected readonly accountService = inject(AccountService);
|
||||
|
||||
|
||||
|
||||
tokenExpired = false;
|
||||
|
34
UI/Web/src/app/admin/_models/metadata-settings.ts
Normal file
34
UI/Web/src/app/admin/_models/metadata-settings.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
import {PersonRole} from "../../_models/metadata/person";
|
||||
|
||||
export enum MetadataFieldType {
|
||||
Genre = 0,
|
||||
Tag = 1
|
||||
}
|
||||
|
||||
export interface MetadataFieldMapping {
|
||||
id: number;
|
||||
sourceType: MetadataFieldType;
|
||||
destinationType: MetadataFieldType;
|
||||
sourceValue: string;
|
||||
destinationValue: string;
|
||||
excludeFromSource: boolean;
|
||||
}
|
||||
|
||||
export interface MetadataSettings {
|
||||
enabled: boolean;
|
||||
enableSummary: boolean;
|
||||
enablePublicationStatus: boolean;
|
||||
enableRelationships: boolean;
|
||||
enablePeople: boolean;
|
||||
enableStartDate: boolean;
|
||||
enableLocalizedName: boolean;
|
||||
enableGenres: boolean;
|
||||
enableTags: boolean;
|
||||
firstLastPeopleNaming: boolean;
|
||||
ageRatingMappings: Map<string, AgeRating>;
|
||||
fieldMappings: Array<MetadataFieldMapping>;
|
||||
blacklist: Array<string>;
|
||||
whitelist: Array<string>;
|
||||
personRoles: Array<PersonRole>;
|
||||
}
|
@ -6,166 +6,165 @@
|
||||
|
||||
<div class="container-fluid">
|
||||
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}}</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="mt-2">
|
||||
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)" [isEditMode]="!isViewMode" [showEdit]="hasLicense">
|
||||
<ng-template #titleExtra>
|
||||
<button class="btn btn-icon btn-sm" (click)="loadLicenseInfo(true)">
|
||||
@if (isChecking) {
|
||||
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
|
||||
} @else if (hasLicense) {
|
||||
<span>
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="mt-2">
|
||||
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)" [isEditMode]="!isViewMode" [showEdit]="hasLicense">
|
||||
<ng-template #titleExtra>
|
||||
<button class="btn btn-icon btn-sm" (click)="loadLicenseInfo(true)">
|
||||
@if (isChecking) {
|
||||
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
|
||||
} @else if (hasLicense) {
|
||||
<span>
|
||||
<i class="fa-solid fa-refresh" tabindex="0" [ngbTooltip]="t('check')"></i>
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
</ng-template>
|
||||
<ng-template #view>
|
||||
@if (hasLicense) {
|
||||
<span class="me-1">*********</span>
|
||||
}
|
||||
</button>
|
||||
</ng-template>
|
||||
<ng-template #view>
|
||||
@if (hasLicense) {
|
||||
<span class="me-1">*********</span>
|
||||
|
||||
@if (isChecking) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
} @else {
|
||||
@if (licenseInfo?.isActive) {
|
||||
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
|
||||
<span class="visually-hidden">{{t('license-valid')}}</span>
|
||||
</i>
|
||||
@if (isChecking) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')">
|
||||
<span class="visually-hidden">{{t('license-not-valid')}}</span>
|
||||
@if (licenseInfo?.isActive) {
|
||||
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
|
||||
<span class="visually-hidden">{{t('license-valid')}}</span>
|
||||
</i>
|
||||
} @else {
|
||||
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')">
|
||||
<span class="visually-hidden">{{t('license-not-valid')}}</span>
|
||||
</i>
|
||||
}
|
||||
}
|
||||
@if (!isChecking && hasLicense && !licenseInfo) {
|
||||
<div><span class="error">{{t('license-mismatch')}}</span></div>
|
||||
}
|
||||
|
||||
} @else {
|
||||
{{t('no-license-key')}}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #edit>
|
||||
<div class="form-group mb-3">
|
||||
<label for="license-key">{{t('activate-license-label')}}</label>
|
||||
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label for="email">{{t('activate-email-label')}}</label>
|
||||
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label for="discordId">{{t('activate-discordId-label')}}</label>
|
||||
<i class="fa fa-circle-info ms-1" aria-hidden="true" [ngbTooltip]="t('activate-discordId-tooltip')"></i>
|
||||
<a class="ms-1" [href]="WikiLink.KavitaPlusDiscordId" target="_blank" rel="noopener noreferrer">{{t('help-label')}}</a>
|
||||
<input id="discordId" type="text" class="form-control" formControlName="discordId" autocomplete="off" [class.is-invalid]="formGroup.get('discordId')?.invalid && formGroup.get('discordId')?.touched"/>
|
||||
@if (formGroup.dirty || !formGroup.untouched) {
|
||||
<div id="inviteForm-validations" class="invalid-feedback">
|
||||
@if (formGroup.get('discordId')?.errors?.pattern) {
|
||||
<div>
|
||||
{{t('discord-validation')}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<!-- <button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"-->
|
||||
<!-- (click)="deleteLicense()">-->
|
||||
<!-- {{t('activate-delete')}}-->
|
||||
<!-- </button>-->
|
||||
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
|
||||
[ngbTooltip]="t('activate-reset-tooltip')"
|
||||
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="resetLicense()">
|
||||
{{t('activate-reset')}}
|
||||
</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header"
|
||||
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
|
||||
@if (!isSaving) {
|
||||
<span>{{t('activate-save')}}</span>
|
||||
}
|
||||
|
||||
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
|
||||
<ng-template #titleActions>
|
||||
@if (hasLicense) {
|
||||
@if (licenseInfo?.isActive) {
|
||||
<a class="btn btn-primary-outline btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
|
||||
} @else {
|
||||
<a class="btn btn-primary-outline btn-sm me-1"
|
||||
[ngbTooltip]="t('invalid-license-tooltip')"
|
||||
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
|
||||
>{{t('renew')}}</a>
|
||||
}
|
||||
} @else {
|
||||
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
|
||||
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
@if (hasLicense && licenseInfo) {
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<div class="row g-0 mt-3">
|
||||
<h3>{{t('info-title')}}</h3>
|
||||
<div class="mb-2 col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('license-active-label')">
|
||||
<ng-template #view>
|
||||
@if (isChecking) {
|
||||
{{null | defaultValue}}
|
||||
} @else {
|
||||
<i class="fas {{licenseInfo.isActive ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
|
||||
<span class="visually-hidden">{{licenseInfo.isActive ? t('valid') : t('invalid')}]</span>
|
||||
</i>
|
||||
}
|
||||
}
|
||||
@if (!isChecking && hasLicense && !licenseInfo) {
|
||||
<div><span class="error">{{t('license-mismatch')}}</span></div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
} @else {
|
||||
{{t('no-license-key')}}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #edit>
|
||||
<div class="form-group mb-3">
|
||||
<label for="license-key">{{t('activate-license-label')}}</label>
|
||||
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label for="email">{{t('activate-email-label')}}</label>
|
||||
<input id="email" type="email" class="form-control" formControlName="email" autocomplete="off"/>
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label for="discordId">{{t('activate-discordId-label')}}</label>
|
||||
<i class="fa fa-circle-info ms-1" aria-hidden="true" [ngbTooltip]="t('activate-discordId-tooltip')"></i>
|
||||
<a class="ms-1" [href]="WikiLink.KavitaPlusDiscordId" target="_blank" rel="noopener noreferrer">{{t('help-label')}}</a>
|
||||
<input id="discordId" type="text" class="form-control" formControlName="discordId" autocomplete="off" [class.is-invalid]="formGroup.get('discordId')?.invalid && formGroup.get('discordId')?.touched"/>
|
||||
@if (formGroup.dirty || !formGroup.untouched) {
|
||||
<div id="inviteForm-validations" class="invalid-feedback">
|
||||
@if (formGroup.get('discordId')?.errors?.pattern) {
|
||||
<div>
|
||||
{{t('discord-validation')}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<!-- <button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"-->
|
||||
<!-- (click)="deleteLicense()">-->
|
||||
<!-- {{t('activate-delete')}}-->
|
||||
<!-- </button>-->
|
||||
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
|
||||
[ngbTooltip]="t('activate-reset-tooltip')"
|
||||
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="resetLicense()">
|
||||
{{t('activate-reset')}}
|
||||
</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header"
|
||||
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
|
||||
@if (!isSaving) {
|
||||
<span>{{t('activate-save')}}</span>
|
||||
}
|
||||
|
||||
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
|
||||
<ng-template #titleActions>
|
||||
@if (hasLicense) {
|
||||
@if (licenseInfo?.isActive) {
|
||||
<a class="btn btn-primary-outline btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
|
||||
} @else {
|
||||
<a class="btn btn-primary-outline btn-sm me-1"
|
||||
[ngbTooltip]="t('invalid-license-tooltip')"
|
||||
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
|
||||
>{{t('renew')}}</a>
|
||||
}
|
||||
} @else {
|
||||
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
|
||||
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
@if (hasLicense && licenseInfo) {
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<div class="row g-0 mt-3">
|
||||
<h3 class="container-fluid">{{t('info-title')}}</h3>
|
||||
<div class="mb-2 col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('license-active-label')">
|
||||
<ng-template #view>
|
||||
@if (isChecking) {
|
||||
{{null | defaultValue}}
|
||||
} @else {
|
||||
<i class="fas {{licenseInfo.isActive ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
|
||||
<span class="visually-hidden">{{licenseInfo.isActive ? t('valid') : t('invalid')}]</span>
|
||||
<div class="mb-2 col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supported-version-label')">
|
||||
<ng-template #view>
|
||||
<i class="fas {{licenseInfo.isValidVersion ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
|
||||
<span class="visually-hidden">{{isValidVersion ? t('valid') : t('invalid')}]</span>
|
||||
</i>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supported-version-label')">
|
||||
<ng-template #view>
|
||||
<i class="fas {{licenseInfo.isValidVersion ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
|
||||
<span class="visually-hidden">{{isValidVersion ? t('valid') : t('invalid')}]</span>
|
||||
</i>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
<div class="mb-2 col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('expiration-label')">
|
||||
<ng-template #view>
|
||||
{{licenseInfo.expirationDate | utcToLocalTime | defaultValue}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('expiration-label')">
|
||||
<ng-template #view>
|
||||
{{licenseInfo.expirationDate | utcToLocalTime | defaultValue}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
<div class="mb-2 col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('total-subbed-months-label')">
|
||||
<ng-template #view>
|
||||
{{licenseInfo.totalMonthsSubbed | number}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('total-subbed-months-label')">
|
||||
<ng-template #view>
|
||||
{{licenseInfo.totalMonthsSubbed | number}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('email-label')">
|
||||
<ng-template #view>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('email-label')">
|
||||
<ng-template #view>
|
||||
<span (click)="toggleEmailShow()" class="col-12 clickable">
|
||||
@if (showEmail) {
|
||||
{{licenseInfo.registeredEmail}}
|
||||
@ -173,28 +172,31 @@
|
||||
***************
|
||||
}
|
||||
</span>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<!-- Actions around license -->
|
||||
<h3>{{t('actions-title')}}</h3>
|
||||
|
||||
<div class="mt-2 mb-2">
|
||||
<app-setting-button [subtitle]="t('delete-tooltip')">
|
||||
<button type="button" class="flex-fill btn btn-danger mt-1" aria-describedby="license-key-header" (click)="deleteLicense()">
|
||||
{{t('activate-delete')}}
|
||||
</button>
|
||||
</app-setting-button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 mb-2">
|
||||
<app-setting-button [subtitle]="t('manage-tooltip')">
|
||||
<a class="btn btn-primary btn-sm mt-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
|
||||
</app-setting-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<!-- Actions around license -->
|
||||
<h3 class="container-fluid">{{t('actions-title')}}</h3>
|
||||
|
||||
<div class="mt-2 mb-2">
|
||||
<app-setting-button [subtitle]="t('delete-tooltip')">
|
||||
<button type="button" class="flex-fill btn btn-danger mt-1" aria-describedby="license-key-header" (click)="deleteLicense()">
|
||||
{{t('activate-delete')}}
|
||||
</button>
|
||||
</app-setting-button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 mb-2">
|
||||
<app-setting-button [subtitle]="t('manage-tooltip')">
|
||||
<a class="btn btn-primary btn-sm mt-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
|
||||
</app-setting-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
@ -0,0 +1,266 @@
|
||||
<ng-container *transloco="let t; read:'manage-metadata-settings'">
|
||||
|
||||
|
||||
<p>{{t('description')}}</p>
|
||||
@if (isLoaded) {
|
||||
<form [formGroup]="settingsForm">
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('enabled'); as formControl) {
|
||||
<app-setting-switch [title]="t('enabled-label')" [subtitle]="t('enabled-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enabled" type="checkbox" class="form-check-input" formControlName="enabled">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('enableSummary'); as formControl) {
|
||||
<app-setting-switch [title]="t('summary-label')" [subtitle]="t('summary-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="summary" type="checkbox" class="form-check-input" formControlName="enableSummary">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('enablePublicationStatus'); as formControl) {
|
||||
<app-setting-switch [title]="t('derive-publication-status-label')" [subtitle]="t('derive-publication-status-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="derive-publication-status" type="checkbox" class="form-check-input" formControlName="enablePublicationStatus">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('enableRelationships'); as formControl) {
|
||||
<app-setting-switch [title]="t('enable-relations-label')" [subtitle]="t('enable-relations-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enable-relations-status" type="checkbox" class="form-check-input" formControlName="enableRelationships">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('enableStartDate'); as formControl) {
|
||||
<app-setting-switch [title]="t('enable-start-date-label')" [subtitle]="t('enable-start-date-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enable-start-date-status" type="checkbox" class="form-check-input" formControlName="enableStartDate">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if(settingsForm.get('enablePeople'); as formControl) {
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
|
||||
<div class="col-6">
|
||||
<app-setting-switch [title]="t('enable-people-label')" [subtitle]="t('enable-people-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enable-people-status" type="checkbox" class="form-check-input" formControlName="enablePeople">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<app-setting-switch [title]="t('first-last-name-label')" [subtitle]="t('first-last-name-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enable-first-last-name" type="checkbox" class="form-check-input" formControlName="firstLastPeopleNaming">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@if (settingsForm.get('personRoles')) {
|
||||
<h5>{{t('person-roles-label')}}</h5>
|
||||
<div class="row g-0 mt-4 mb-4" formArrayName="personRoles">
|
||||
@for(role of personRoles; track role; let i = $index) {
|
||||
<div class="col-md-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" [formControlName]="'personRole_' + i" [id]="'role-' + role">
|
||||
<label class="form-check-label" [for]="'role-' + role">{{ role | personRole }}</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
@if(settingsForm.get('enableGenres'); as formControl) {
|
||||
<app-setting-switch [title]="t('enable-genres-label')" [subtitle]="t('enable-genres-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enable-genres-status" type="checkbox" class="form-check-input" formControlName="enableGenres">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
@if(settingsForm.get('enableTags'); as formControl) {
|
||||
<app-setting-switch [title]="t('enable-tags-label')" [subtitle]="t('enable-tags-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enable-tags-status" type="checkbox" class="form-check-input" formControlName="enableTags">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('blacklist'); as formControl) {
|
||||
<app-setting-item [title]="t('blacklist-label')" [subtitle]="t('blacklist-tooltip')">
|
||||
<ng-template #view>
|
||||
@let val = (formControl.value || '').split(',');
|
||||
|
||||
@for(opt of val; track opt) {
|
||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</ng-template>s
|
||||
<ng-template #edit>
|
||||
<textarea rows="3" id="blacklist" class="form-control" formControlName="blacklist"></textarea>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('whitelist'); as formControl) {
|
||||
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
|
||||
<ng-template #view>
|
||||
@let val = (formControl.value || '').split(',');
|
||||
|
||||
@for(opt of val; track opt) {
|
||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</ng-template>s
|
||||
<ng-template #edit>
|
||||
<textarea rows="3" id="whitelist" class="form-control" formControlName="whitelist"></textarea>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<h4>{{t('age-rating-mapping-title')}}</h4>
|
||||
<p>{{t('age-rating-mapping-description')}}</p>
|
||||
|
||||
<div formArrayName="ageRatingMappings">
|
||||
@for(mapping of ageRatingMappings.controls; track mapping; let i = $index) {
|
||||
<div [formGroupName]="i" class="row mb-2">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" formControlName="str" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<i class="fa fa-arrow-right" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select class="form-select" formControlName="rating">
|
||||
@for (ageRating of ageRatings; track ageRating.value) {
|
||||
<option [value]="ageRating.value">
|
||||
{{ageRating.value | ageRating}}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-icon" (click)="removeAgeRatingMappingRow(i)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-secondary" (click)="addAgeRatingMapping()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-age-rating-mapping-label')}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<!-- Field Mapping Table -->
|
||||
<h4>{{t('field-mapping-title')}}</h4>
|
||||
<p>{{t('field-mapping-description')}}</p>
|
||||
<div formArrayName="fieldMappings">
|
||||
@for (mapping of fieldMappings.controls; track mapping; let i = $index) {
|
||||
<div [formGroupName]="i" class="row mb-2">
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" formControlName="sourceType">
|
||||
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
|
||||
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" class="form-control" formControlName="sourceValue"
|
||||
placeholder="Source genre/tag" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" formControlName="destinationType">
|
||||
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
|
||||
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" class="form-control" formControlName="destinationValue"
|
||||
placeholder="Destination genre/tag" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check">
|
||||
<input id="remove-source-tag-{{i}}" type="checkbox" class="form-check-input"
|
||||
formControlName="excludeFromSource">
|
||||
<label [for]="'remove-source-tag-' + i" class="form-check-label">
|
||||
{{t('remove-source-tag-label')}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-icon" (click)="removeFieldMappingRow(i)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-secondary float-end" (click)="addFieldMapping()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-field-mapping-label')}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
}
|
||||
</ng-container>
|
@ -0,0 +1,219 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
import {SettingsService} from "../settings.service";
|
||||
import {debounceTime, switchMap} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {filter, map} from "rxjs/operators";
|
||||
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
|
||||
import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-settings";
|
||||
import {PersonRole} from "../../_models/metadata/person";
|
||||
import {PersonRolePipe} from "../../_pipes/person-role.pipe";
|
||||
import {NgClass} from "@angular/common";
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-metadata-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
SettingSwitchComponent,
|
||||
SettingItemComponent,
|
||||
DefaultValuePipe,
|
||||
TagBadgeComponent,
|
||||
AgeRatingPipe,
|
||||
PersonRolePipe,
|
||||
],
|
||||
templateUrl: './manage-metadata-settings.component.html',
|
||||
styleUrl: './manage-metadata-settings.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ManageMetadataSettingsComponent implements OnInit {
|
||||
|
||||
protected readonly MetadataFieldType = MetadataFieldType;
|
||||
|
||||
private readonly settingService = inject(SettingsService);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
ageRatings: Array<AgeRatingDto> = [];
|
||||
ageRatingMappings = this.fb.array([]);
|
||||
fieldMappings = this.fb.array([]);
|
||||
personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character];
|
||||
isLoaded = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.metadataService.getAllAgeRatings().subscribe(ratings => {
|
||||
this.ageRatings = ratings;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
||||
this.settingsForm.addControl('ageRatingMappings', this.ageRatingMappings);
|
||||
this.settingsForm.addControl('fieldMappings', this.fieldMappings);
|
||||
|
||||
this.settingService.getMetadataSettings().subscribe(settings => {
|
||||
this.settingsForm.addControl('enabled', new FormControl(settings.enabled, []));
|
||||
this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, []));
|
||||
this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, []));
|
||||
this.settingsForm.addControl('enableRelations', new FormControl(settings.enableRelationships, []));
|
||||
this.settingsForm.addControl('enableGenres', new FormControl(settings.enableGenres, []));
|
||||
this.settingsForm.addControl('enableTags', new FormControl(settings.enableTags, []));
|
||||
this.settingsForm.addControl('enableRelationships', new FormControl(settings.enableRelationships, []));
|
||||
this.settingsForm.addControl('enablePeople', new FormControl(settings.enablePeople, []));
|
||||
this.settingsForm.addControl('enableStartDate', new FormControl(settings.enableStartDate, []));
|
||||
|
||||
this.settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), []));
|
||||
this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), []));
|
||||
this.settingsForm.addControl('firstLastPeopleNaming', new FormControl((settings.firstLastPeopleNaming), []));
|
||||
this.settingsForm.addControl('personRoles', this.fb.group(
|
||||
Object.fromEntries(
|
||||
this.personRoles.map((role, index) => [
|
||||
`personRole_${index}`,
|
||||
this.fb.control((settings.personRoles || this.personRoles).includes(role)),
|
||||
])
|
||||
)
|
||||
));
|
||||
|
||||
|
||||
if (settings.ageRatingMappings) {
|
||||
Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => {
|
||||
this.addAgeRatingMapping(str, rating);
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.fieldMappings) {
|
||||
settings.fieldMappings.forEach(mapping => {
|
||||
this.addFieldMapping(mapping);
|
||||
});
|
||||
}
|
||||
|
||||
this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => {
|
||||
const firstLastControl = this.settingsForm.get('firstLastPeopleNaming');
|
||||
if (enabled) {
|
||||
firstLastControl?.enable();
|
||||
} else {
|
||||
firstLastControl?.disable();
|
||||
}
|
||||
});
|
||||
|
||||
this.settingsForm.get('enablePeople')?.updateValueAndValidity();
|
||||
|
||||
// Disable personRoles checkboxes based on enablePeople state
|
||||
this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => {
|
||||
const personRolesArray = this.settingsForm.get('personRoles') as FormArray;
|
||||
if (enabled) {
|
||||
personRolesArray.enable();
|
||||
} else {
|
||||
personRolesArray.disable();
|
||||
}
|
||||
});
|
||||
|
||||
this.isLoaded = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
|
||||
this.settingsForm.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(_ => this.packData()),
|
||||
switchMap((data) => this.settingService.updateMetadataSettings(data)),
|
||||
).subscribe();
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
packData(withFieldMappings: boolean = true) {
|
||||
const model = this.settingsForm.value;
|
||||
|
||||
// Convert FormArray to dictionary
|
||||
const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc, control) => {
|
||||
// @ts-ignore
|
||||
const { str, rating } = control.value;
|
||||
if (str && rating) {
|
||||
// @ts-ignore
|
||||
acc[str] = parseInt(rating + '', 10) as AgeRating;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const fieldMappings = this.fieldMappings.controls.map((control) => {
|
||||
const value = control.value as MetadataFieldMapping;
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
sourceType: parseInt(value.sourceType + '', 10),
|
||||
destinationType: parseInt(value.destinationType + '', 10),
|
||||
sourceValue: value.sourceValue,
|
||||
destinationValue: value.destinationValue,
|
||||
excludeFromSource: value.excludeFromSource
|
||||
}
|
||||
}).filter(m => m.sourceValue.length > 0);
|
||||
|
||||
// Translate blacklist string -> Array<string>
|
||||
return {
|
||||
...model,
|
||||
ageRatingMappings,
|
||||
fieldMappings: withFieldMappings ? fieldMappings : [],
|
||||
blacklist: (model.blacklist || '').split(',').map((item: string) => item.trim()),
|
||||
whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()),
|
||||
personRoles: Object.entries(this.settingsForm.get('personRoles')!.value)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)])
|
||||
}
|
||||
}
|
||||
|
||||
addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) {
|
||||
const mappingGroup = this.fb.group({
|
||||
str: [str, Validators.required],
|
||||
rating: [rating, Validators.required]
|
||||
});
|
||||
// @ts-ignore
|
||||
this.ageRatingMappings.push(mappingGroup);
|
||||
}
|
||||
|
||||
removeAgeRatingMappingRow(index: number) {
|
||||
this.ageRatingMappings.removeAt(index);
|
||||
}
|
||||
|
||||
addFieldMapping(mapping: MetadataFieldMapping | null = null) {
|
||||
const mappingGroup = this.fb.group({
|
||||
id: [mapping?.id || 0],
|
||||
sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required],
|
||||
destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required],
|
||||
sourceValue: [mapping?.sourceValue || '', Validators.required],
|
||||
destinationValue: [mapping?.destinationValue || ''],
|
||||
excludeFromSource: [mapping?.excludeFromSource || false]
|
||||
});
|
||||
|
||||
// Autofill destination value if empty when source value loses focus
|
||||
mappingGroup.get('sourceValue')?.valueChanges
|
||||
.pipe(
|
||||
filter(() => !mappingGroup.get('destinationValue')?.value)
|
||||
)
|
||||
.subscribe(sourceValue => {
|
||||
mappingGroup.get('destinationValue')?.setValue(sourceValue);
|
||||
});
|
||||
|
||||
//@ts-ignore
|
||||
this.fieldMappings.push(mappingGroup);
|
||||
}
|
||||
|
||||
removeFieldMappingRow(index: number) {
|
||||
this.fieldMappings.removeAt(index);
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -4,6 +4,7 @@ import {map, of} from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { ServerSettings } from './_models/server-settings';
|
||||
import {MetadataSettings} from "./_models/metadata-settings";
|
||||
|
||||
/**
|
||||
* Used only for the Test Email Service call
|
||||
@ -27,6 +28,13 @@ export class SettingsService {
|
||||
return this.http.get<ServerSettings>(this.baseUrl + 'settings');
|
||||
}
|
||||
|
||||
getMetadataSettings() {
|
||||
return this.http.get<MetadataSettings>(this.baseUrl + 'settings/metadata-settings');
|
||||
}
|
||||
updateMetadataSettings(model: MetadataSettings) {
|
||||
return this.http.post<MetadataSettings>(this.baseUrl + 'settings/metadata-settings', model);
|
||||
}
|
||||
|
||||
updateServerSettings(model: ServerSettings) {
|
||||
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
|
||||
}
|
||||
|
@ -72,8 +72,8 @@ function deepClone(obj: any): any {
|
||||
@Component({
|
||||
selector: 'app-series-card',
|
||||
standalone: true,
|
||||
imports: [CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent,
|
||||
EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective,
|
||||
imports: [RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent,
|
||||
FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective,
|
||||
SeriesFormatComponent, DecimalPipe],
|
||||
templateUrl: './series-card.component.html',
|
||||
styleUrls: ['./series-card.component.scss'],
|
||||
@ -245,6 +245,13 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
case(Action.Edit):
|
||||
this.openEditModal(series);
|
||||
break;
|
||||
case Action.Match:
|
||||
this.actionService.matchSeries(this.series, (refreshNeeded) => {
|
||||
if (refreshNeeded) {
|
||||
this.reload.emit(series.id);
|
||||
}
|
||||
});
|
||||
break;
|
||||
case(Action.AddToReadingList):
|
||||
this.actionService.addSeriesToReadingList(series);
|
||||
break;
|
||||
|
@ -480,7 +480,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
} else if (event.event === EVENTS.ScanSeries) {
|
||||
const seriesScanEvent = event.payload as ScanSeriesEvent;
|
||||
if (seriesScanEvent.seriesId === this.seriesId) {
|
||||
//this.loadSeries(this.seriesId);
|
||||
this.loadPageSource.next(false);
|
||||
}
|
||||
} else if (event.event === EVENTS.CoverUpdate) {
|
||||
@ -491,7 +490,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
} else if (event.event === EVENTS.ChapterRemoved) {
|
||||
const removedEvent = event.payload as ChapterRemovedEvent;
|
||||
if (removedEvent.seriesId !== this.seriesId) return;
|
||||
//this.loadSeries(this.seriesId, false);
|
||||
this.loadPageSource.next(false);
|
||||
}
|
||||
});
|
||||
@ -751,6 +749,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
|
||||
.filter(action => action.action !== Action.Edit);
|
||||
|
||||
this.licenseService.hasValidLicense$.subscribe(hasLic => {
|
||||
if (!hasLic) {
|
||||
this.seriesActions = this.seriesActions.filter(action => action.action !== Action.Match);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
|
||||
this.relationShips = relations;
|
||||
|
@ -1,5 +1,5 @@
|
||||
<ng-container *transloco="let t;">
|
||||
<div class="container-fluid">
|
||||
<div>
|
||||
<ng-content></ng-content>
|
||||
|
||||
@if (subtitle) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
<ng-container *transloco="let t;">
|
||||
<div class="container-fluid">
|
||||
<div>
|
||||
<div class="settings-row g-0 row">
|
||||
<div class="col-10 setting-title">
|
||||
<h6 class="section-title">
|
||||
|
@ -1,5 +1,5 @@
|
||||
<ng-container *transloco="let t;">
|
||||
<div class="container-fluid">
|
||||
<div>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-11">
|
||||
<h6 class="section-title" [id]="id || title">{{title}}</h6>
|
||||
|
@ -122,6 +122,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.Metadata; prefetch on idle) {
|
||||
@if(hasActiveLicense && fragment === SettingsTabId.Metadata) {
|
||||
<div class="scale col-md-12">
|
||||
<app-manage-metadata-settings></app-manage-metadata-settings>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.ScrobblingHolds; prefetch on idle) {
|
||||
@if(hasActiveLicense && fragment === SettingsTabId.ScrobblingHolds) {
|
||||
<div class="scale col-md-12">
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user