mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-03 21:54:47 -04:00
Metadata Fixes (#3533)
Co-authored-by: Midhun Sudhir <60651970+midhun3301@users.noreply.github.com>
This commit is contained in:
parent
40bbdcb5f0
commit
bb9621a588
@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.DTOs.KavitaPlus.Metadata;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Enums.UserPreferences;
|
using API.Entities.Enums.UserPreferences;
|
||||||
@ -217,6 +218,12 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
|
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
|
||||||
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)
|
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)
|
||||||
);
|
);
|
||||||
|
builder.Entity<MetadataSettings>()
|
||||||
|
.Property(x => x.Whitelist)
|
||||||
|
.HasConversion(
|
||||||
|
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
|
||||||
|
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default)
|
||||||
|
);
|
||||||
|
|
||||||
// Configure one-to-many relationship
|
// Configure one-to-many relationship
|
||||||
builder.Entity<MetadataSettings>()
|
builder.Entity<MetadataSettings>()
|
||||||
@ -224,6 +231,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
.WithOne(x => x.MetadataSettings)
|
.WithOne(x => x.MetadataSettings)
|
||||||
.HasForeignKey(x => x.MetadataSettingsId)
|
.HasForeignKey(x => x.MetadataSettingsId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
builder.Entity<MetadataSettings>()
|
builder.Entity<MetadataSettings>()
|
||||||
.Property(b => b.Enabled)
|
.Property(b => b.Enabled)
|
||||||
.HasDefaultValue(true);
|
.HasDefaultValue(true);
|
||||||
|
@ -224,6 +224,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
|
|||||||
public async Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter)
|
public async Task<IList<ManageMatchSeriesDto>> GetAllSeries(ManageMatchFilterDto filter)
|
||||||
{
|
{
|
||||||
return await _context.Series
|
return await _context.Series
|
||||||
|
.Include(s => s.Library)
|
||||||
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
|
.Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type))
|
||||||
.FilterMatchState(filter.MatchStateOption)
|
.FilterMatchState(filter.MatchStateOption)
|
||||||
.OrderBy(s => s.NormalizedName)
|
.OrderBy(s => s.NormalizedName)
|
||||||
|
@ -308,7 +308,7 @@ public static class Seed
|
|||||||
EnableTags = false,
|
EnableTags = false,
|
||||||
EnableGenres = true,
|
EnableGenres = true,
|
||||||
EnableLocalizedName = false,
|
EnableLocalizedName = false,
|
||||||
FirstLastPeopleNaming = false,
|
FirstLastPeopleNaming = true,
|
||||||
PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]
|
PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character]
|
||||||
};
|
};
|
||||||
await context.MetadataSettings.AddAsync(existing);
|
await context.MetadataSettings.AddAsync(existing);
|
||||||
|
@ -288,11 +288,15 @@ public static class QueryableExtensions
|
|||||||
return stateOption switch
|
return stateOption switch
|
||||||
{
|
{
|
||||||
MatchStateOption.All => query,
|
MatchStateOption.All => query,
|
||||||
MatchStateOption.Matched => query.Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted),
|
MatchStateOption.Matched => query
|
||||||
MatchStateOption.NotMatched => query.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted),
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
|
.Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted),
|
||||||
|
MatchStateOption.NotMatched => query.
|
||||||
|
Include(s => s.ExternalSeriesMetadata)
|
||||||
|
.Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted),
|
||||||
MatchStateOption.Error => query.Where(s => s.IsBlacklisted),
|
MatchStateOption.Error => query.Where(s => s.IsBlacklisted),
|
||||||
MatchStateOption.DontMatch => query.Where(s => s.DontMatch),
|
MatchStateOption.DontMatch => query.Where(s => s.DontMatch),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(stateOption), stateOption, null)
|
_ => query
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -345,10 +345,13 @@ public class AutoMapperProfiles : Profile
|
|||||||
opt.MapFrom(src => src))
|
opt.MapFrom(src => src))
|
||||||
.ForMember(dest => dest.IsMatched,
|
.ForMember(dest => dest.IsMatched,
|
||||||
opt =>
|
opt =>
|
||||||
opt.MapFrom(src => src.ExternalSeriesMetadata != null && src.ExternalSeriesMetadata.AniListId != 0 && src.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue))
|
opt.MapFrom(src => src.ExternalSeriesMetadata != null && src.ExternalSeriesMetadata.AniListId != 0
|
||||||
|
&& src.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue))
|
||||||
.ForMember(dest => dest.ValidUntilUtc,
|
.ForMember(dest => dest.ValidUntilUtc,
|
||||||
opt =>
|
opt => opt.MapFrom(src =>
|
||||||
opt.MapFrom(src => src.ExternalSeriesMetadata.ValidUntilUtc));
|
src.ExternalSeriesMetadata != null
|
||||||
|
? src.ExternalSeriesMetadata.ValidUntilUtc
|
||||||
|
: DateTime.MinValue));
|
||||||
|
|
||||||
|
|
||||||
CreateMap<MangaFile, FileExtensionExportDto>();
|
CreateMap<MangaFile, FileExtensionExportDto>();
|
||||||
@ -361,10 +364,14 @@ public class AutoMapperProfiles : Profile
|
|||||||
.ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId))
|
.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));
|
.ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type));
|
||||||
|
|
||||||
|
CreateMap<MetadataFieldMapping, MetadataFieldMappingDto>();
|
||||||
|
|
||||||
CreateMap<MetadataSettings, MetadataSettingsDto>()
|
CreateMap<MetadataSettings, MetadataSettingsDto>()
|
||||||
.ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List<string>()))
|
.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>()));
|
.ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List<string>()))
|
||||||
CreateMap<MetadataFieldMapping, MetadataFieldMappingDto>();
|
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -195,6 +195,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
|
||||||
SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata);
|
SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata);
|
||||||
|
if (series == null) return [];
|
||||||
|
|
||||||
var potentialAnilistId = ScrobblingService.ExtractId<int?>(dto.Query, ScrobblingService.AniListWeblinkWebsite);
|
var potentialAnilistId = ScrobblingService.ExtractId<int?>(dto.Query, ScrobblingService.AniListWeblinkWebsite);
|
||||||
var potentialMalId = ScrobblingService.ExtractId<long?>(dto.Query, ScrobblingService.MalWeblinkWebsite);
|
var potentialMalId = ScrobblingService.ExtractId<long?>(dto.Query, ScrobblingService.MalWeblinkWebsite);
|
||||||
@ -512,7 +513,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
madeModification = true;
|
madeModification = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.EnableStartDate && externalMetadata.StartDate.HasValue)
|
if (settings.EnableStartDate && !series.Metadata.ReleaseYearLocked && externalMetadata.StartDate.HasValue)
|
||||||
{
|
{
|
||||||
series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year;
|
series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year;
|
||||||
madeModification = true;
|
madeModification = true;
|
||||||
@ -526,7 +527,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
// Process Genres
|
// Process Genres
|
||||||
if (externalMetadata.Genres != null)
|
if (externalMetadata.Genres != null)
|
||||||
{
|
{
|
||||||
foreach (var genre in externalMetadata.Genres.Where(g => !settings.Blacklist.Contains(g)))
|
foreach (var genre in externalMetadata.Genres)
|
||||||
{
|
{
|
||||||
// Apply field mappings
|
// Apply field mappings
|
||||||
var mappedGenre = ApplyFieldMapping(genre, MetadataFieldType.Genre, settings.FieldMappings);
|
var mappedGenre = ApplyFieldMapping(genre, MetadataFieldType.Genre, settings.FieldMappings);
|
||||||
@ -537,9 +538,12 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Strip blacklisted items from processedGenres
|
// Strip blacklisted items from processedGenres
|
||||||
processedGenres = processedGenres.Distinct().Where(g => !settings.Blacklist.Contains(g)).ToList();
|
processedGenres = processedGenres
|
||||||
|
.Distinct()
|
||||||
|
.Where(g => !settings.Blacklist.Contains(g))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (settings.EnableGenres && processedGenres.Count > 0)
|
if (settings.EnableGenres && !series.Metadata.GenresLocked && processedGenres.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name);
|
_logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name);
|
||||||
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList();
|
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList();
|
||||||
@ -567,13 +571,14 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Strip blacklisted items from processedTags
|
// Strip blacklisted items from processedTags
|
||||||
processedTags = processedTags.Distinct()
|
processedTags = processedTags
|
||||||
|
.Distinct()
|
||||||
.Where(g => !settings.Blacklist.Contains(g))
|
.Where(g => !settings.Blacklist.Contains(g))
|
||||||
.Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g))
|
.Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Set the tags for the series and ensure they are in the DB
|
// Set the tags for the series and ensure they are in the DB
|
||||||
if (settings.EnableTags && processedTags.Count > 0)
|
if (settings.EnableTags && !series.Metadata.TagsLocked && processedTags.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name);
|
_logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name);
|
||||||
var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize)))
|
var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize)))
|
||||||
@ -591,22 +596,36 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
|
|
||||||
#region Age Rating
|
#region Age Rating
|
||||||
|
|
||||||
// Determine Age Rating
|
if (!series.Metadata.AgeRatingLocked)
|
||||||
var ageRating = DetermineAgeRating(processedGenres.Concat(processedTags), settings.AgeRatingMappings);
|
|
||||||
if (!series.Metadata.AgeRatingLocked && series.Metadata.AgeRating <= ageRating)
|
|
||||||
{
|
{
|
||||||
series.Metadata.AgeRating = ageRating;
|
try
|
||||||
_unitOfWork.SeriesRepository.Update(series);
|
{
|
||||||
madeModification = true;
|
// Determine Age Rating
|
||||||
}
|
var totalTags = processedGenres
|
||||||
|
.Concat(processedTags)
|
||||||
|
.Concat(series.Metadata.Genres.Select(g => g.Title))
|
||||||
|
.Concat(series.Metadata.Tags.Select(g => g.Title));
|
||||||
|
|
||||||
|
var ageRating = DetermineAgeRating(totalTags, settings.AgeRatingMappings);
|
||||||
|
if (!series.Metadata.AgeRatingLocked && series.Metadata.AgeRating <= ageRating)
|
||||||
|
{
|
||||||
|
series.Metadata.AgeRating = ageRating;
|
||||||
|
_unitOfWork.SeriesRepository.Update(series);
|
||||||
|
madeModification = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "There was an issue determining Age Rating for Series {SeriesName} ({SeriesId})", series.Name, series.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region People
|
#region People
|
||||||
|
|
||||||
if (settings.EnablePeople)
|
if (settings.EnablePeople)
|
||||||
{
|
{
|
||||||
series.Metadata.People ??= new List<SeriesMetadataPeople>();
|
series.Metadata.People ??= [];
|
||||||
|
|
||||||
// Ensure all people are named correctly
|
// Ensure all people are named correctly
|
||||||
externalMetadata.Staff = externalMetadata.Staff.Select(s =>
|
externalMetadata.Staff = externalMetadata.Staff.Select(s =>
|
||||||
@ -635,7 +654,10 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
Name = w.Name,
|
Name = w.Name,
|
||||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||||
Description = CleanSummary(w.Description),
|
Description = CleanSummary(w.Description),
|
||||||
}).ToList();
|
})
|
||||||
|
.Concat(series.Metadata.People.Where(p => p.Role == PersonRole.Writer).Select(p => _mapper.Map<PersonDto>(p)))
|
||||||
|
.DistinctBy(p => Parser.Normalize(p.Name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
|
||||||
// NOTE: PersonRoles can be a hashset
|
// NOTE: PersonRoles can be a hashset
|
||||||
@ -661,7 +683,10 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
Name = w.Name,
|
Name = w.Name,
|
||||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||||
Description = CleanSummary(w.Description),
|
Description = CleanSummary(w.Description),
|
||||||
}).ToList();
|
})
|
||||||
|
.Concat(series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => _mapper.Map<PersonDto>(p)))
|
||||||
|
.DistinctBy(p => Parser.Normalize(p.Name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (!series.Metadata.CoverArtistLocked && artists.Count > 0 && settings.PersonRoles.Contains(PersonRole.CoverArtist))
|
if (!series.Metadata.CoverArtistLocked && artists.Count > 0 && settings.PersonRoles.Contains(PersonRole.CoverArtist))
|
||||||
{
|
{
|
||||||
@ -684,7 +709,10 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
Name = w.Name,
|
Name = w.Name,
|
||||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
|
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
|
||||||
Description = CleanSummary(w.Description),
|
Description = CleanSummary(w.Description),
|
||||||
}).ToList();
|
})
|
||||||
|
.Concat(series.Metadata.People.Where(p => p.Role == PersonRole.Character).Select(p => _mapper.Map<PersonDto>(p)))
|
||||||
|
.DistinctBy(p => Parser.Normalize(p.Name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
|
||||||
if (!series.Metadata.CharacterLocked && characters.Count > 0)
|
if (!series.Metadata.CharacterLocked && characters.Count > 0)
|
||||||
@ -713,13 +741,27 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Publication Status
|
||||||
|
|
||||||
if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus)
|
if (!series.Metadata.PublicationStatusLocked && settings.EnablePublicationStatus)
|
||||||
{
|
{
|
||||||
var chapters = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes.SelectMany(v => v.Chapters).ToList();
|
try
|
||||||
var wasChanged = DeterminePublicationStatus(series, chapters, externalMetadata);
|
{
|
||||||
_unitOfWork.SeriesRepository.Update(series);
|
var chapters =
|
||||||
madeModification = madeModification || wasChanged;
|
(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;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "There was an issue determining Publication Status for Series {SeriesName} ({SeriesId})", series.Name, series.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Relationships
|
||||||
|
|
||||||
if (settings.EnableRelationships && externalMetadata.Relations != null && defaultAdmin != null)
|
if (settings.EnableRelationships && externalMetadata.Relations != null && defaultAdmin != null)
|
||||||
{
|
{
|
||||||
@ -773,6 +815,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
madeModification = true;
|
madeModification = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
return madeModification;
|
return madeModification;
|
||||||
}
|
}
|
||||||
@ -889,6 +932,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
private static AgeRating DetermineAgeRating(IEnumerable<string> values, Dictionary<string, AgeRating> mappings)
|
private static AgeRating DetermineAgeRating(IEnumerable<string> values, Dictionary<string, AgeRating> mappings)
|
||||||
{
|
{
|
||||||
// Find highest age rating from mappings
|
// Find highest age rating from mappings
|
||||||
|
mappings ??= new Dictionary<string, AgeRating>();
|
||||||
|
|
||||||
return values
|
return values
|
||||||
.Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown)
|
.Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown)
|
||||||
.DefaultIfEmpty(AgeRating.Unknown)
|
.DefaultIfEmpty(AgeRating.Unknown)
|
||||||
@ -913,6 +958,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
};
|
};
|
||||||
series.ExternalSeriesMetadata = externalSeriesMetadata;
|
series.ExternalSeriesMetadata = externalSeriesMetadata;
|
||||||
_unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata);
|
_unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata);
|
||||||
|
|
||||||
return externalSeriesMetadata;
|
return externalSeriesMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -349,7 +349,8 @@ public class SeriesService : ISeriesService
|
|||||||
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
|
var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames);
|
||||||
|
|
||||||
// Use a dictionary for quick lookups
|
// Use a dictionary for quick lookups
|
||||||
var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName).ToDictionary(p => p.NormalizedName, p => p);
|
var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName)
|
||||||
|
.ToDictionary(p => p.NormalizedName, p => p);
|
||||||
|
|
||||||
// List to track people that will be added to the metadata
|
// List to track people that will be added to the metadata
|
||||||
var peopleToAdd = new List<Person>();
|
var peopleToAdd = new List<Person>();
|
||||||
|
@ -450,12 +450,12 @@ public class ScannerService : IScannerService
|
|||||||
// That way logging and UI informing is all in one place with full context
|
// That way logging and UI informing is all in one place with full context
|
||||||
_logger.LogError("[ScannerService] Some of the root folders for the library are empty. " +
|
_logger.LogError("[ScannerService] Some of the root folders for the library are empty. " +
|
||||||
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
|
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
|
||||||
"Scan has be aborted. " +
|
"Scan has been aborted. " +
|
||||||
"Check that your mount is connected or change the library's root folder and rescan");
|
"Check that your mount is connected or change the library's root folder and rescan");
|
||||||
|
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.",
|
await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.",
|
||||||
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
|
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
|
||||||
"Scan has be aborted. " +
|
"Scan has been aborted. " +
|
||||||
"Check that your mount is connected or change the library's root folder and rescan"));
|
"Check that your mount is connected or change the library's root folder and rescan"));
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
@ -114,6 +114,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
|
|
||||||
var nightlyDto = new UpdateNotificationDto
|
var nightlyDto = new UpdateNotificationDto
|
||||||
{
|
{
|
||||||
|
// TODO: I should pass Title to the FE so that Nightly Release can be localized
|
||||||
UpdateTitle = $"Nightly Release {nightly.Version} - {prInfo.Title}",
|
UpdateTitle = $"Nightly Release {nightly.Version} - {prInfo.Title}",
|
||||||
UpdateVersion = nightly.Version,
|
UpdateVersion = nightly.Version,
|
||||||
CurrentVersion = dto.CurrentVersion,
|
CurrentVersion = dto.CurrentVersion,
|
||||||
@ -446,7 +447,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
|||||||
{
|
{
|
||||||
var sections = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
var sections = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
var lines = body.Split('\n');
|
var lines = body.Split('\n');
|
||||||
string currentSection = null;
|
string? currentSection = null;
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
{
|
{
|
||||||
|
@ -37,6 +37,11 @@ export interface ExternalSeriesDetail {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
volumeCount?: number;
|
volumeCount?: number;
|
||||||
chapterCount?: number;
|
chapterCount?: number;
|
||||||
|
/**
|
||||||
|
* These are duplicated with volumeCount based on where it's being invoked.
|
||||||
|
*/
|
||||||
|
volumes?: number;
|
||||||
|
chapters?: number;
|
||||||
staff: Array<SeriesStaff>;
|
staff: Array<SeriesStaff>;
|
||||||
tags: Array<MetadataTagDto>;
|
tags: Array<MetadataTagDto>;
|
||||||
provider: ScrobbleProvider;
|
provider: ScrobbleProvider;
|
||||||
|
@ -63,7 +63,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<app-carousel-reel [items]="webLinks" [title]="t('weblinks-title')">
|
<app-carousel-reel [items]="webLinks" [title]="t('weblinks-title')">
|
||||||
<ng-template #carouselItem let-item>
|
<ng-template #carouselItem let-item>
|
||||||
<a class="me-1" [href]="item | safeHtml" target="_blank" rel="noopener noreferrer" [title]="item">
|
<a class="me-1" [href]="item | safeUrl" target="_blank" rel="noopener noreferrer" [title]="item">
|
||||||
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(item)"
|
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(item)"
|
||||||
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
||||||
</a>
|
</a>
|
||||||
|
@ -22,6 +22,7 @@ import {SeriesFormatComponent} from "../../shared/series-format/series-format.co
|
|||||||
import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
|
import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
|
||||||
import {LanguageNamePipe} from "../../_pipes/language-name.pipe";
|
import {LanguageNamePipe} from "../../_pipes/language-name.pipe";
|
||||||
import {AsyncPipe} from "@angular/common";
|
import {AsyncPipe} from "@angular/common";
|
||||||
|
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-details-tab',
|
selector: 'app-details-tab',
|
||||||
@ -39,7 +40,8 @@ import {AsyncPipe} from "@angular/common";
|
|||||||
SeriesFormatComponent,
|
SeriesFormatComponent,
|
||||||
MangaFormatPipe,
|
MangaFormatPipe,
|
||||||
LanguageNamePipe,
|
LanguageNamePipe,
|
||||||
AsyncPipe
|
AsyncPipe,
|
||||||
|
SafeUrlPipe
|
||||||
],
|
],
|
||||||
templateUrl: './details-tab.component.html',
|
templateUrl: './details-tab.component.html',
|
||||||
styleUrl: './details-tab.component.scss',
|
styleUrl: './details-tab.component.scss',
|
||||||
|
@ -32,9 +32,9 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="d-flex p-1 justify-content-between">
|
<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>
|
<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) {
|
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
|
||||||
<span class="me-1">{{t('volume-count', {num: item.series.volumeCount})}}</span>
|
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
|
||||||
<span class="me-1">{{t('chapter-count', {num: item.series.chapterCount})}}</span>
|
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="me-1">{{t('releasing')}}</span>
|
<span class="me-1">{{t('releasing')}}</span>
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<a class="position-absolute custom-position btn btn-primary-outline" [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">{{t('faq-title')}}</a>
|
<a class="position-absolute custom-position btn btn-primary-outline" [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">{{t('faq-title')}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div>
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
<form [formGroup]="formGroup">
|
<form [formGroup]="formGroup">
|
||||||
|
@ -31,6 +31,7 @@ export class ManageLogsComponent implements OnInit, OnDestroy {
|
|||||||
constructor(private accountService: AccountService) { }
|
constructor(private accountService: AccountService) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// TODO: Come back and implement this one day
|
||||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||||
if (user) {
|
if (user) {
|
||||||
this.hubConnection = new HubConnectionBuilder()
|
this.hubConnection = new HubConnectionBuilder()
|
||||||
|
@ -25,7 +25,7 @@ const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:
|
|||||||
styleUrls: ['./manage-settings.component.scss'],
|
styleUrls: ['./manage-settings.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [ReactiveFormsModule, NgbTooltip, TitleCasePipe, TranslocoModule, NgTemplateOutlet, PageLayoutModePipe, SettingItemComponent, SettingSwitchComponent, SafeHtmlPipe]
|
imports: [ReactiveFormsModule, TitleCasePipe, TranslocoModule, SettingItemComponent, SettingSwitchComponent]
|
||||||
})
|
})
|
||||||
export class ManageSettingsComponent implements OnInit {
|
export class ManageSettingsComponent implements OnInit {
|
||||||
|
|
||||||
|
@ -18,7 +18,6 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
|||||||
|
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||||
import {ConfirmService} from "../../shared/confirm.service";
|
|
||||||
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
|
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
|
||||||
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
||||||
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
|
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
|
||||||
@ -38,7 +37,8 @@ interface AdhocTask {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe,
|
imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe,
|
||||||
TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, SettingButtonComponent, NgxDatatableModule]
|
TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent,
|
||||||
|
SettingButtonComponent, NgxDatatableModule]
|
||||||
})
|
})
|
||||||
export class ManageTasksSettingsComponent implements OnInit {
|
export class ManageTasksSettingsComponent implements OnInit {
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-content {
|
:host ::ng-deep .blog-content {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
@ -27,4 +27,5 @@ export class ChangelogUpdateItemComponent {
|
|||||||
@Input({required:true}) update: UpdateVersionEvent | null = null;
|
@Input({required:true}) update: UpdateVersionEvent | null = null;
|
||||||
@Input() index: number = 0;
|
@Input() index: number = 0;
|
||||||
@Input() showExtras: boolean = true;
|
@Input() showExtras: boolean = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -103,9 +103,7 @@ const blackList = [Action.Edit, Action.Info, Action.IncognitoRead, Action.Read,
|
|||||||
MangaFormatPipe,
|
MangaFormatPipe,
|
||||||
DefaultDatePipe,
|
DefaultDatePipe,
|
||||||
TimeAgoPipe,
|
TimeAgoPipe,
|
||||||
TagBadgeComponent,
|
|
||||||
PublicationStatusPipe,
|
PublicationStatusPipe,
|
||||||
NgbTooltip,
|
|
||||||
BytesPipe,
|
BytesPipe,
|
||||||
ImageComponent,
|
ImageComponent,
|
||||||
NgbCollapse,
|
NgbCollapse,
|
||||||
@ -117,7 +115,6 @@ const blackList = [Action.Edit, Action.Info, Action.IncognitoRead, Action.Read,
|
|||||||
EditListComponent,
|
EditListComponent,
|
||||||
SettingButtonComponent,
|
SettingButtonComponent,
|
||||||
SettingItemComponent,
|
SettingItemComponent,
|
||||||
ReadTimePipe,
|
|
||||||
],
|
],
|
||||||
templateUrl: './edit-series-modal.component.html',
|
templateUrl: './edit-series-modal.component.html',
|
||||||
styleUrls: ['./edit-series-modal.component.scss'],
|
styleUrls: ['./edit-series-modal.component.scss'],
|
||||||
@ -655,6 +652,11 @@ export class EditSeriesModalComponent implements OnInit {
|
|||||||
case Action.Download:
|
case Action.Download:
|
||||||
this.downloadService.download('series', this.series);
|
this.downloadService.download('series', this.series);
|
||||||
break;
|
break;
|
||||||
|
case Action.Match:
|
||||||
|
this.actionService.matchSeries(this.series, _ => {
|
||||||
|
this.modal.close({success: true, series: this.series, coverImageUpdate: false, updateExternal: true});
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,13 @@
|
|||||||
<h2 class="title text-break">
|
<h2 class="title text-break">
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||||
<span>{{person.name}}</span>
|
<span>{{person.name}}</span>
|
||||||
|
|
||||||
|
@if (person.aniListId) {
|
||||||
|
<a class="ms-1" [href]="anilistUrl | safeUrl" target="_blank" rel="noopener noreferrer">
|
||||||
|
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(anilistUrl)"
|
||||||
|
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</h2>
|
</h2>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</app-side-nav-companion-bar>
|
</app-side-nav-companion-bar>
|
||||||
@ -45,6 +52,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -41,6 +41,7 @@ import {ThemeService} from "../_services/theme.service";
|
|||||||
import {DefaultModalOptions} from "../_models/default-modal-options";
|
import {DefaultModalOptions} from "../_models/default-modal-options";
|
||||||
import {ToastrService} from "ngx-toastr";
|
import {ToastrService} from "ngx-toastr";
|
||||||
import {LicenseService} from "../_services/license.service";
|
import {LicenseService} from "../_services/license.service";
|
||||||
|
import {SafeUrlPipe} from "../_pipes/safe-url.pipe";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-person-detail',
|
selector: 'app-person-detail',
|
||||||
@ -49,16 +50,15 @@ import {LicenseService} from "../_services/license.service";
|
|||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
ImageComponent,
|
ImageComponent,
|
||||||
SideNavCompanionBarComponent,
|
SideNavCompanionBarComponent,
|
||||||
NgStyle,
|
|
||||||
ReadMoreComponent,
|
ReadMoreComponent,
|
||||||
TagBadgeComponent,
|
TagBadgeComponent,
|
||||||
PersonRolePipe,
|
PersonRolePipe,
|
||||||
CarouselReelComponent,
|
CarouselReelComponent,
|
||||||
SeriesCardComponent,
|
|
||||||
CardItemComponent,
|
CardItemComponent,
|
||||||
CardActionablesComponent,
|
CardActionablesComponent,
|
||||||
TranslocoDirective,
|
TranslocoDirective,
|
||||||
ChapterCardComponent
|
ChapterCardComponent,
|
||||||
|
SafeUrlPipe
|
||||||
],
|
],
|
||||||
templateUrl: './person-detail.component.html',
|
templateUrl: './person-detail.component.html',
|
||||||
styleUrl: './person-detail.component.scss',
|
styleUrl: './person-detail.component.scss',
|
||||||
@ -93,8 +93,14 @@ export class PersonDetailComponent {
|
|||||||
filter: SeriesFilterV2 | null = null;
|
filter: SeriesFilterV2 | null = null;
|
||||||
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
|
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
|
||||||
chaptersByRole: any = {};
|
chaptersByRole: any = {};
|
||||||
|
anilistUrl: string = '';
|
||||||
private readonly personSubject = new BehaviorSubject<Person | null>(null);
|
private readonly personSubject = new BehaviorSubject<Person | null>(null);
|
||||||
protected readonly person$ = this.personSubject.asObservable();
|
protected readonly person$ = this.personSubject.asObservable().pipe(tap(p => {
|
||||||
|
if (p?.aniListId) {
|
||||||
|
this.anilistUrl = translate('person-detail.anilist-url').replace('{AniListId}', p!.aniListId! + '');
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
get HasCoverImage() {
|
get HasCoverImage() {
|
||||||
return (this.person as Person).coverImage;
|
return (this.person as Person).coverImage;
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
<div class="main-container container-fluid">
|
<div class="main-container container-fluid">
|
||||||
<ng-container *transloco="let t; read:'settings'">
|
<ng-container *transloco="let t; read:'settings'">
|
||||||
<app-side-nav-companion-bar>
|
<app-side-nav-companion-bar>
|
||||||
<h2 title>
|
<h2 class="container-fluid" title>
|
||||||
{{fragment | settingFragment}}
|
{{fragment | settingFragment}}
|
||||||
</h2>
|
</h2>
|
||||||
</app-side-nav-companion-bar>
|
</app-side-nav-companion-bar>
|
||||||
<div class="row col-me-4 pb-3">
|
<div class="container-fluid row col-me-4 pb-3">
|
||||||
|
|
||||||
@if (accountService.currentUser$ | async; as user) {
|
@if (accountService.currentUser$ | async; as user) {
|
||||||
@if (accountService.hasAdminRole(user)) {
|
@if (accountService.hasAdminRole(user)) {
|
||||||
|
@ -763,6 +763,7 @@
|
|||||||
"matched-status-label": "Matched",
|
"matched-status-label": "Matched",
|
||||||
"unmatched-status-label": "Not Matched",
|
"unmatched-status-label": "Not Matched",
|
||||||
"blacklist-status-label": "Needs Manual Match",
|
"blacklist-status-label": "Needs Manual Match",
|
||||||
|
"dont-match-status-label": "{{dont-match-label}}",
|
||||||
"all-status-label": "All",
|
"all-status-label": "All",
|
||||||
"dont-match-label": "Don't Match",
|
"dont-match-label": "Don't Match",
|
||||||
"no-data": "{{common.no-data}}"
|
"no-data": "{{common.no-data}}"
|
||||||
@ -1071,7 +1072,8 @@
|
|||||||
"individual-role-title": "As a {{role}}",
|
"individual-role-title": "As a {{role}}",
|
||||||
"browse-person-title": "All Works of {{name}}",
|
"browse-person-title": "All Works of {{name}}",
|
||||||
"browse-person-by-role-title": "All Works of {{name}} as a {{role}}",
|
"browse-person-by-role-title": "All Works of {{name}} as a {{role}}",
|
||||||
"all-roles": "Roles"
|
"all-roles": "Roles",
|
||||||
|
"anilist-url": "{{edit-person-modal.anilist-tooltip}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"library-settings-modal": {
|
"library-settings-modal": {
|
||||||
@ -2677,7 +2679,7 @@
|
|||||||
"title": "Actions",
|
"title": "Actions",
|
||||||
"copy-settings": "Copy Settings From",
|
"copy-settings": "Copy Settings From",
|
||||||
"match": "Match",
|
"match": "Match",
|
||||||
"match-description": "Match Series with Kavita+ manually"
|
"match-tooltip": "Match Series with Kavita+ manually"
|
||||||
},
|
},
|
||||||
|
|
||||||
"preferences": {
|
"preferences": {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.1",
|
"openapi": "3.0.1",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Kavita",
|
"title": "Kavita",
|
||||||
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.9",
|
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.10",
|
||||||
"license": {
|
"license": {
|
||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user