Colorscape Love (#3326)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
parent
b44f89d1e8
commit
a847468a6c
42 changed files with 1009 additions and 429 deletions
|
|
@ -106,6 +106,7 @@
|
|||
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using API.Data;
|
|||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MimeTypes;
|
||||
|
|
@ -26,17 +27,19 @@ public class ImageController : BaseApiController
|
|||
private readonly IImageService _imageService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IReadingListService _readingListService;
|
||||
private readonly ICoverDbService _coverDbService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService,
|
||||
IImageService imageService, ILocalizationService localizationService,
|
||||
IReadingListService readingListService)
|
||||
IReadingListService readingListService, ICoverDbService coverDbService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_directoryService = directoryService;
|
||||
_imageService = imageService;
|
||||
_localizationService = localizationService;
|
||||
_readingListService = readingListService;
|
||||
_coverDbService = coverDbService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -230,7 +233,7 @@ public class ImageController : BaseApiController
|
|||
try
|
||||
{
|
||||
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory,
|
||||
await _imageService.DownloadFaviconAsync(url, encodeFormat));
|
||||
await _coverDbService.DownloadFaviconAsync(url, encodeFormat));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
|
@ -270,7 +273,7 @@ public class ImageController : BaseApiController
|
|||
try
|
||||
{
|
||||
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory,
|
||||
await _imageService.DownloadPublisherImageAsync(publisherName, encodeFormat));
|
||||
await _coverDbService.DownloadPublisherImageAsync(publisherName, encodeFormat));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ using API.Entities.Enums;
|
|||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nager.ArticleNumber;
|
||||
|
|
@ -18,12 +20,19 @@ public class PersonController : BaseApiController
|
|||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ICoverDbService _coverDbService;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper)
|
||||
public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper,
|
||||
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_localizationService = localizationService;
|
||||
_mapper = mapper;
|
||||
_coverDbService = coverDbService;
|
||||
_imageService = imageService;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -65,8 +74,17 @@ public class PersonController : BaseApiController
|
|||
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id);
|
||||
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
|
||||
|
||||
dto.Description ??= string.Empty;
|
||||
person.Description = dto.Description;
|
||||
if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required"));
|
||||
|
||||
|
||||
// Validate the name is unique
|
||||
if (dto.Name != person.Name && !(await _unitOfWork.PersonRepository.IsNameUnique(dto.Name)))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique"));
|
||||
}
|
||||
|
||||
person.Name = dto.Name?.Trim();
|
||||
person.Description = dto.Description ?? string.Empty;
|
||||
person.CoverImageLocked = dto.CoverImageLocked;
|
||||
|
||||
if (dto.MalId is > 0)
|
||||
|
|
@ -96,6 +114,26 @@ public class PersonController : BaseApiController
|
|||
return Ok(_mapper.Map<PersonDto>(person));
|
||||
}
|
||||
|
||||
[HttpPost("fetch-cover")]
|
||||
public async Task<ActionResult<string>> DownloadCoverImage([FromQuery] int personId)
|
||||
{
|
||||
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonById(personId);
|
||||
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
|
||||
|
||||
var personImage = await _coverDbService.DownloadPersonImageAsync(person, settings.EncodeMediaAs);
|
||||
|
||||
if (string.IsNullOrEmpty(personImage)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist"));
|
||||
person.CoverImage = personImage;
|
||||
_imageService.UpdateColorScape(person);
|
||||
_unitOfWork.PersonRepository.Update(person);
|
||||
await _unitOfWork.CommitAsync();
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(person.Id, "person"), false);
|
||||
|
||||
|
||||
return Ok(personImage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the top 20 series that the "person" is known for. This will use Average Rating when applicable (Kavita+ field), else it's a random sort
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ public class ReadingListController : BaseApiController
|
|||
return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated"));
|
||||
}
|
||||
|
||||
return BadRequest("Couldn't delete item(s)");
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-item-delete"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
16
API/DTOs/CoverDb/CoverDbAuthor.cs
Normal file
16
API/DTOs/CoverDb/CoverDbAuthor.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
using System.Collections.Generic;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace API.DTOs.CoverDb;
|
||||
|
||||
public class CoverDbAuthor
|
||||
{
|
||||
[YamlMember(Alias = "name", ApplyNamingConventions = false)]
|
||||
public string Name { get; set; }
|
||||
[YamlMember(Alias = "aliases", ApplyNamingConventions = false)]
|
||||
public List<string> Aliases { get; set; } = new List<string>();
|
||||
[YamlMember(Alias = "ids", ApplyNamingConventions = false)]
|
||||
public CoverDbPersonIds Ids { get; set; }
|
||||
[YamlMember(Alias = "image_path", ApplyNamingConventions = false)]
|
||||
public string ImagePath { get; set; }
|
||||
}
|
||||
10
API/DTOs/CoverDb/CoverDbPeople.cs
Normal file
10
API/DTOs/CoverDb/CoverDbPeople.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using System.Collections.Generic;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace API.DTOs.CoverDb;
|
||||
|
||||
public class CoverDbPeople
|
||||
{
|
||||
[YamlMember(Alias = "people", ApplyNamingConventions = false)]
|
||||
public List<CoverDbAuthor> People { get; set; } = new List<CoverDbAuthor>();
|
||||
}
|
||||
20
API/DTOs/CoverDb/CoverDbPersonIds.cs
Normal file
20
API/DTOs/CoverDb/CoverDbPersonIds.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace API.DTOs.CoverDb;
|
||||
#nullable enable
|
||||
|
||||
public class CoverDbPersonIds
|
||||
{
|
||||
[YamlMember(Alias = "hardcover_id", ApplyNamingConventions = false)]
|
||||
public string? HardcoverId { get; set; } = null;
|
||||
[YamlMember(Alias = "amazon_id", ApplyNamingConventions = false)]
|
||||
public string? AmazonId { get; set; } = null;
|
||||
[YamlMember(Alias = "metron_id", ApplyNamingConventions = false)]
|
||||
public string? MetronId { get; set; } = null;
|
||||
[YamlMember(Alias = "comicvine_id", ApplyNamingConventions = false)]
|
||||
public string? ComicVineId { get; set; } = null;
|
||||
[YamlMember(Alias = "anilist_id", ApplyNamingConventions = false)]
|
||||
public string? AnilistId { get; set; } = null;
|
||||
[YamlMember(Alias = "mal_id", ApplyNamingConventions = false)]
|
||||
public string? MALId { get; set; } = null;
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ public class UpdatePersonDto
|
|||
public int Id { get; init; }
|
||||
[Required]
|
||||
public bool CoverImageLocked { get; set; }
|
||||
[Required]
|
||||
public string Name {get; set;}
|
||||
public string? Description { get; set; }
|
||||
|
||||
public int? AniListId { get; set; }
|
||||
|
|
|
|||
|
|
@ -43,4 +43,6 @@ public class ReadingListItemDto
|
|||
/// The chapter summary
|
||||
/// </summary>
|
||||
public string? Summary { get; set; }
|
||||
|
||||
public bool IsSpecial { get; set; }
|
||||
}
|
||||
|
|
|
|||
67
API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs
Normal file
67
API/Data/ManualMigrations/MigrateDuplicateDarkTheme.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Data.ManualMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// v0.8.0 ensured that MangaFile Path is normalized. This will normalize existing data to avoid churn.
|
||||
/// </summary>
|
||||
public static class MigrateDuplicateDarkTheme
|
||||
{
|
||||
public static async Task Migrate(DataContext dataContext, ILogger<Program> logger)
|
||||
{
|
||||
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateDuplicateDarkTheme"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogCritical(
|
||||
"Running MigrateDuplicateDarkTheme migration - Please be patient, this may take some time. This is not an error");
|
||||
|
||||
var darkThemes = await dataContext.SiteTheme.Where(t => t.Name == "Dark").ToListAsync();
|
||||
|
||||
if (darkThemes.Count > 1)
|
||||
{
|
||||
var correctDarkTheme = darkThemes.First(d => !string.IsNullOrEmpty(d.Description));
|
||||
|
||||
// Get users
|
||||
var users = await dataContext.AppUser
|
||||
.Include(u => u.UserPreferences)
|
||||
.ThenInclude(p => p.Theme)
|
||||
.Where(u => u.UserPreferences.Theme.Name == "Dark")
|
||||
.ToListAsync();
|
||||
|
||||
// Find any users that have a duplicate Dark theme as default and switch to the correct one
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (string.IsNullOrEmpty(user.UserPreferences.Theme.Description))
|
||||
{
|
||||
user.UserPreferences.Theme = correctDarkTheme;
|
||||
}
|
||||
}
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
// Now remove the bad themes
|
||||
dataContext.SiteTheme.RemoveRange(darkThemes.Where(d => string.IsNullOrEmpty(d.Description)));
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "MigrateDuplicateDarkTheme",
|
||||
ProductVersion = BuildInfo.Version.ToString(),
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
logger.LogCritical(
|
||||
"Running MigrateDuplicateDarkTheme migration - Completed. This is not an error");
|
||||
}
|
||||
}
|
||||
85
API/Data/Repositories/CoverDbRepository.cs
Normal file
85
API/Data/Repositories/CoverDbRepository.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using API.DTOs.CoverDb;
|
||||
using API.Entities;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// This is a manual repository, not a DB repo
|
||||
/// </summary>
|
||||
public class CoverDbRepository
|
||||
{
|
||||
private readonly List<CoverDbAuthor> _authors;
|
||||
|
||||
public CoverDbRepository(string filePath)
|
||||
{
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
// Read and deserialize YAML file
|
||||
var yamlContent = File.ReadAllText(filePath);
|
||||
var peopleData = deserializer.Deserialize<CoverDbPeople>(yamlContent);
|
||||
_authors = peopleData.People;
|
||||
}
|
||||
|
||||
public CoverDbAuthor? FindAuthorByNameOrAlias(string name)
|
||||
{
|
||||
return _authors.Find(author =>
|
||||
author.Name.Equals(name, StringComparison.OrdinalIgnoreCase) ||
|
||||
author.Aliases.Contains(name, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public CoverDbAuthor? FindBestAuthorMatch(Person person)
|
||||
{
|
||||
var aniListId = person.AniListId > 0 ? $"{person.AniListId}" : string.Empty;
|
||||
var highestScore = 0;
|
||||
CoverDbAuthor? bestMatch = null;
|
||||
|
||||
foreach (var author in _authors)
|
||||
{
|
||||
var score = 0;
|
||||
|
||||
// Check metadata IDs and add points if they match
|
||||
if (!string.IsNullOrEmpty(author.Ids.AmazonId) && author.Ids.AmazonId == person.Asin)
|
||||
{
|
||||
score += 10;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(author.Ids.AnilistId) && author.Ids.AnilistId == aniListId)
|
||||
{
|
||||
score += 10;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(author.Ids.HardcoverId) && author.Ids.HardcoverId == person.HardcoverId)
|
||||
{
|
||||
score += 10;
|
||||
}
|
||||
|
||||
// Check for exact name match
|
||||
if (author.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 7;
|
||||
}
|
||||
|
||||
// Check for alias match
|
||||
if (author.Aliases.Contains(person.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Update the best match if current score is higher
|
||||
if (score <= highestScore) continue;
|
||||
|
||||
highestScore = score;
|
||||
bestMatch = author;
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ public interface IPersonRepository
|
|||
Task<Person?> GetPersonById(int personId);
|
||||
Task<PersonDto?> GetPersonDtoByName(string name, int userId);
|
||||
Task<Person> GetPersonByName(string name);
|
||||
Task<bool> IsNameUnique(string name);
|
||||
|
||||
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
|
||||
Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role);
|
||||
|
|
@ -211,6 +212,11 @@ public class PersonRepository : IPersonRepository
|
|||
return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized());
|
||||
}
|
||||
|
||||
public async Task<bool> IsNameUnique(string name)
|
||||
{
|
||||
return !(await _context.Person.AnyAsync(p => p.Name == name));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId)
|
||||
{
|
||||
return await _context.Person
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ public class ReadingListRepository : IReadingListRepository
|
|||
ChapterTitleName = chapter.TitleName,
|
||||
FileSize = chapter.Files.Sum(f => f.Bytes),
|
||||
chapter.Summary,
|
||||
chapter.IsSpecial
|
||||
|
||||
})
|
||||
.Join(_context.Volume, s => s.ReadingListItem.VolumeId, volume => volume.Id, (data, volume) => new
|
||||
|
|
@ -259,6 +260,7 @@ public class ReadingListRepository : IReadingListRepository
|
|||
data.ChapterTitleName,
|
||||
data.FileSize,
|
||||
data.Summary,
|
||||
data.IsSpecial,
|
||||
VolumeId = volume.Id,
|
||||
VolumeNumber = volume.Name,
|
||||
})
|
||||
|
|
@ -277,6 +279,7 @@ public class ReadingListRepository : IReadingListRepository
|
|||
data.ChapterTitleName,
|
||||
data.FileSize,
|
||||
data.Summary,
|
||||
data.IsSpecial,
|
||||
LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(),
|
||||
LibraryType = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Type).Single()
|
||||
})
|
||||
|
|
@ -299,7 +302,8 @@ public class ReadingListRepository : IReadingListRepository
|
|||
ChapterTitleName = data.ChapterTitleName,
|
||||
LibraryName = data.LibraryName,
|
||||
FileSize = data.FileSize,
|
||||
Summary = data.Summary
|
||||
Summary = data.Summary,
|
||||
IsSpecial = data.IsSpecial
|
||||
})
|
||||
.Where(o => userLibraries.Contains(o.LibraryId))
|
||||
.OrderBy(rli => rli.Order)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ public static class ApplicationServiceExtensions
|
|||
services.AddScoped<IEventHub, EventHub>();
|
||||
services.AddScoped<IPresenceTracker, PresenceTracker>();
|
||||
services.AddScoped<IImageService, ImageService>();
|
||||
services.AddScoped<ICoverDbService, CoverDbService>();
|
||||
|
||||
services.AddScoped<ILocalizationService, LocalizationService>();
|
||||
|
||||
|
|
|
|||
26
API/Extensions/DoubleExtensions.cs
Normal file
26
API/Extensions/DoubleExtensions.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using System;
|
||||
|
||||
namespace API.Extensions;
|
||||
|
||||
public static class DoubleExtensions
|
||||
{
|
||||
private const float Tolerance = 0.001f;
|
||||
|
||||
/// <summary>
|
||||
/// Used to compare 2 floats together
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <returns></returns>
|
||||
public static bool Is(this double a, double? b)
|
||||
{
|
||||
if (!b.HasValue) return false;
|
||||
return Math.Abs((float) (a - b)) < Tolerance;
|
||||
}
|
||||
|
||||
public static bool IsNot(this double a, double? b)
|
||||
{
|
||||
if (!b.HasValue) return false;
|
||||
return Math.Abs((float) (a - b)) > Tolerance;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Globalization;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace API.Extensions;
|
||||
|
|
@ -10,6 +11,23 @@ public static class StringExtensions
|
|||
RegexOptions.ExplicitCapture | RegexOptions.Compiled,
|
||||
Services.Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||
|
||||
public static string Sanitize(this string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return string.Empty;
|
||||
|
||||
// Remove all newline and control characters
|
||||
var sanitized = input
|
||||
.Replace(Environment.NewLine, "")
|
||||
.Replace("\n", "")
|
||||
.Replace("\r", "");
|
||||
|
||||
// Optionally remove other potentially unwanted characters
|
||||
sanitized = Regex.Replace(sanitized, @"[^\u0020-\u007E]", string.Empty); // Removes non-printable ASCII
|
||||
|
||||
return sanitized.Trim(); // Trim any leading/trailing whitespace
|
||||
}
|
||||
|
||||
public static string SentenceCase(this string value)
|
||||
{
|
||||
return SentenceCaseRegex.Replace(value.ToLower(), s => s.Value.ToUpper());
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@
|
|||
"error-import-stack": "There was an issue importing MAL stack",
|
||||
|
||||
"person-doesnt-exist": "Person does not exist",
|
||||
"person-name-required": "Person name is required and must not be null",
|
||||
"person-name-unique": "Person name must be unique",
|
||||
"person-image-doesnt-exist": "Person does not exist in CoversDB",
|
||||
|
||||
"device-doesnt-exist": "Device does not exist",
|
||||
"generic-device-create": "There was an error when creating the device",
|
||||
|
|
@ -61,7 +64,7 @@
|
|||
"greater-0": "{0} must be greater than 0",
|
||||
"send-to-kavita-email": "Send to device cannot be used without Email setup",
|
||||
"send-to-unallowed":"You cannot send to a device that isn't yours",
|
||||
"send-to-size-limit": "The file(s) you are trying to send are too large for your emailer",
|
||||
"send-to-size-limit": "The file(s) you are trying to send are too large for your email provider",
|
||||
"send-to-device-status": "Transferring files to your device",
|
||||
"generic-send-to": "There was an error sending the file(s) to the device",
|
||||
"series-doesnt-exist": "Series does not exist",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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;
|
||||
|
|
@ -66,17 +67,15 @@ public interface IImageService
|
|||
/// <returns>File of written encoded image</returns>
|
||||
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
|
||||
Task<bool> IsImage(string filePath);
|
||||
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
||||
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
||||
void UpdateColorScape(IHasCoverImage entity);
|
||||
}
|
||||
|
||||
public class ImageService : IImageService
|
||||
{
|
||||
public const string Name = "BookmarkService";
|
||||
public const string Name = "ImageService";
|
||||
private readonly ILogger<ImageService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IEasyCachingProviderFactory _cacheFactory;
|
||||
|
||||
public const string ChapterCoverImageRegex = @"v\d+_c\d+";
|
||||
public const string SeriesCoverImageRegex = @"series\d+";
|
||||
public const string CollectionTagCoverImageRegex = @"tag\d+";
|
||||
|
|
@ -100,26 +99,10 @@ public class ImageService : IImageService
|
|||
public const int LibraryThumbnailWidth = 32;
|
||||
|
||||
|
||||
private static readonly string[] ValidIconRelations = {
|
||||
"icon",
|
||||
"apple-touch-icon",
|
||||
"apple-touch-icon-precomposed",
|
||||
"apple-touch-icon icon-precomposed" // ComicVine has it combined
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon)
|
||||
/// </summary>
|
||||
private static readonly IDictionary<string, string> FaviconUrlMapper = new Dictionary<string, string>
|
||||
{
|
||||
["https://app.plex.tv"] = "https://plex.tv"
|
||||
};
|
||||
|
||||
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService, IEasyCachingProviderFactory cacheFactory)
|
||||
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService)
|
||||
{
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
_cacheFactory = cacheFactory;
|
||||
}
|
||||
|
||||
public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1)
|
||||
|
|
@ -335,151 +318,8 @@ public class ImageService : IImageService
|
|||
return false;
|
||||
}
|
||||
|
||||
public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
|
||||
{
|
||||
// Parse the URL to get the domain (including subdomain)
|
||||
var uri = new Uri(url);
|
||||
var domain = uri.Host.Replace(Environment.NewLine, string.Empty);
|
||||
var baseUrl = uri.Scheme + "://" + uri.Host;
|
||||
|
||||
|
||||
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon);
|
||||
var res = await provider.GetAsync<string>(baseUrl);
|
||||
if (res.HasValue)
|
||||
{
|
||||
_logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", baseUrl);
|
||||
throw new KavitaException($"Kavita has already tried to fetch from {baseUrl} and failed. Skipping duplicate check");
|
||||
}
|
||||
|
||||
await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10));
|
||||
if (FaviconUrlMapper.TryGetValue(baseUrl, out var value))
|
||||
{
|
||||
url = value;
|
||||
}
|
||||
|
||||
var correctSizeLink = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var htmlContent = url.GetStringAsync().Result;
|
||||
var htmlDocument = new HtmlDocument();
|
||||
htmlDocument.LoadHtml(htmlContent);
|
||||
var pngLinks = htmlDocument.DocumentNode.Descendants("link")
|
||||
.Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty)))
|
||||
.Select(link => link.GetAttributeValue("href", string.Empty))
|
||||
.Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(correctSizeLink))
|
||||
{
|
||||
correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl);
|
||||
}
|
||||
if (string.IsNullOrEmpty(correctSizeLink))
|
||||
{
|
||||
throw new KavitaException($"Could not grab favicon from {baseUrl}");
|
||||
}
|
||||
|
||||
var finalUrl = correctSizeLink;
|
||||
|
||||
// If starts with //, it's coming usually from an offsite cdn
|
||||
if (correctSizeLink.StartsWith("//"))
|
||||
{
|
||||
finalUrl = "https:" + correctSizeLink;
|
||||
}
|
||||
else if (!correctSizeLink.StartsWith(uri.Scheme))
|
||||
{
|
||||
finalUrl = Url.Combine(baseUrl, correctSizeLink);
|
||||
}
|
||||
|
||||
_logger.LogTrace("Fetching favicon from {Url}", finalUrl);
|
||||
// Download the favicon.ico file using Flurl
|
||||
var faviconStream = await finalUrl
|
||||
.AllowHttpStatus("2xx,304")
|
||||
.GetStreamAsync();
|
||||
|
||||
// Create the destination file path
|
||||
using var image = Image.PngloadStream(faviconStream);
|
||||
var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain);
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading favicon for {Domain}", domain);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat)
|
||||
{
|
||||
try
|
||||
{
|
||||
var publisherLink = await FallbackToKavitaReaderPublisher(publisherName);
|
||||
if (string.IsNullOrEmpty(publisherLink))
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
var finalUrl = publisherLink;
|
||||
|
||||
_logger.LogTrace("Fetching publisher image from {Url}", finalUrl);
|
||||
// Download the favicon.ico file using Flurl
|
||||
var publisherStream = await finalUrl
|
||||
.AllowHttpStatus("2xx,304")
|
||||
.GetStreamAsync();
|
||||
|
||||
// Create the destination file path
|
||||
using var image = Image.PngloadStream(publisherStream);
|
||||
var filename = GetPublisherFormat(publisherName, encodeFormat);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName);
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath)
|
||||
{
|
||||
using var image = Image.NewFromFile(imagePath);
|
||||
|
|
@ -740,63 +580,7 @@ public class ImageService : IImageService
|
|||
};
|
||||
}
|
||||
|
||||
private static async Task<string> FallbackToKavitaReaderFavicon(string baseUrl)
|
||||
{
|
||||
var correctSizeLink = string.Empty;
|
||||
var allOverrides = await "https://www.kavitareader.com/assets/favicons/urls.txt".GetStringAsync();
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
{
|
||||
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.FirstOrDefault(url =>
|
||||
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
|
||||
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
|
||||
));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
throw new KavitaException($"Could not grab favicon from {baseUrl}");
|
||||
}
|
||||
|
||||
correctSizeLink = "https://www.kavitareader.com/assets/favicons/" + externalFile;
|
||||
}
|
||||
|
||||
return correctSizeLink;
|
||||
}
|
||||
|
||||
private static async Task<string> FallbackToKavitaReaderPublisher(string publisherName)
|
||||
{
|
||||
var externalLink = string.Empty;
|
||||
var allOverrides = await "https://www.kavitareader.com/assets/publishers/publishers.txt".GetStringAsync();
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
{
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.Select(publisherLine =>
|
||||
{
|
||||
var tokens = publisherLine.Split("|");
|
||||
if (tokens.Length != 2) return null;
|
||||
var aliases = tokens[0];
|
||||
// Multiple publisher aliases are separated by #
|
||||
if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim())))
|
||||
{
|
||||
return tokens[1];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.FirstOrDefault(url => !string.IsNullOrEmpty(url));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
externalLink = "https://www.kavitareader.com/assets/publishers/" + externalFile;
|
||||
}
|
||||
|
||||
return externalLink;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth)
|
||||
|
|
|
|||
|
|
@ -107,15 +107,30 @@ public class ReadingListService : IReadingListService
|
|||
|
||||
if (title != string.Empty) return title;
|
||||
|
||||
// item.ChapterNumber is Range
|
||||
if (item.ChapterNumber == Parser.DefaultChapter &&
|
||||
!string.IsNullOrEmpty(item.ChapterTitleName))
|
||||
{
|
||||
title = item.ChapterTitleName;
|
||||
}
|
||||
else if (item.IsSpecial &&
|
||||
(!string.IsNullOrEmpty(item.ChapterTitleName) || !string.IsNullOrEmpty(chapterNum)))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.ChapterTitleName))
|
||||
{
|
||||
title = item.ChapterTitleName;
|
||||
}
|
||||
else
|
||||
{
|
||||
title = chapterNum;
|
||||
}
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
title = ReaderService.FormatChapterName(item.LibraryType, true, true) + chapterNum;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -910,9 +910,14 @@ public class SeriesService : ISeriesService
|
|||
}
|
||||
|
||||
// Calculate the forecast for when the next chapter is expected
|
||||
var nextChapterExpected = chapters.Any()
|
||||
? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference)
|
||||
: (DateTime?)null;
|
||||
// var nextChapterExpected = chapters.Count > 0
|
||||
// ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference)
|
||||
// : (DateTime?)null;
|
||||
var lastChapterDate = chapters.Max(c => c.CreatedUtc);
|
||||
var estimatedDate = lastChapterDate.AddDays(forecastedTimeDifference);
|
||||
var nextChapterExpected = estimatedDate.Day > DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month)
|
||||
? new DateTime(estimatedDate.Year, estimatedDate.Month, DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month))
|
||||
: estimatedDate;
|
||||
|
||||
// For number and volume number, we need the highest chapter, not the latest created
|
||||
var lastChapter = chapters.MaxBy(c => c.MaxNumber)!;
|
||||
|
|
@ -936,6 +941,7 @@ public class SeriesService : ISeriesService
|
|||
{
|
||||
LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber),
|
||||
LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber),
|
||||
LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber),
|
||||
LibraryType.Book => await _localizationService.Translate(userId, "book-num", result.ChapterNumber),
|
||||
LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", result.ChapterNumber),
|
||||
_ => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber)
|
||||
|
|
|
|||
372
API/Services/Tasks/Metadata/CoverDbService.cs
Normal file
372
API/Services/Tasks/Metadata/CoverDbService.cs
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using EasyCaching.Core;
|
||||
using Flurl;
|
||||
using Flurl.Http;
|
||||
using HtmlAgilityPack;
|
||||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetVips;
|
||||
|
||||
namespace API.Services.Tasks.Metadata;
|
||||
|
||||
public interface ICoverDbService
|
||||
{
|
||||
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
||||
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
||||
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat);
|
||||
}
|
||||
|
||||
|
||||
public class CoverDbService : ICoverDbService
|
||||
{
|
||||
private readonly ILogger<CoverDbService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IEasyCachingProviderFactory _cacheFactory;
|
||||
private readonly IHostEnvironment _env;
|
||||
|
||||
private const string NewHost = "https://www.kavitareader.com/CoversDB/";
|
||||
|
||||
private static readonly string[] ValidIconRelations = {
|
||||
"icon",
|
||||
"apple-touch-icon",
|
||||
"apple-touch-icon-precomposed",
|
||||
"apple-touch-icon icon-precomposed" // ComicVine has it combined
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon)
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> FaviconUrlMapper = new()
|
||||
{
|
||||
["https://app.plex.tv"] = "https://plex.tv"
|
||||
};
|
||||
|
||||
public CoverDbService(ILogger<CoverDbService> logger, IDirectoryService directoryService,
|
||||
IEasyCachingProviderFactory cacheFactory, IHostEnvironment env)
|
||||
{
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
_cacheFactory = cacheFactory;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
|
||||
{
|
||||
// Parse the URL to get the domain (including subdomain)
|
||||
var uri = new Uri(url);
|
||||
var domain = uri.Host.Replace(Environment.NewLine, string.Empty);
|
||||
var baseUrl = uri.Scheme + "://" + uri.Host;
|
||||
|
||||
|
||||
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon);
|
||||
var res = await provider.GetAsync<string>(baseUrl);
|
||||
if (res.HasValue)
|
||||
{
|
||||
var sanitizedBaseUrl = baseUrl.Sanitize();
|
||||
_logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", sanitizedBaseUrl);
|
||||
throw new KavitaException($"Kavita has already tried to fetch from {sanitizedBaseUrl} and failed. Skipping duplicate check");
|
||||
}
|
||||
|
||||
await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10));
|
||||
if (FaviconUrlMapper.TryGetValue(baseUrl, out var value))
|
||||
{
|
||||
url = value;
|
||||
}
|
||||
|
||||
var correctSizeLink = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var htmlContent = url.GetStringAsync().Result;
|
||||
var htmlDocument = new HtmlDocument();
|
||||
htmlDocument.LoadHtml(htmlContent);
|
||||
|
||||
var pngLinks = htmlDocument.DocumentNode.Descendants("link")
|
||||
.Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty)))
|
||||
.Select(link => link.GetAttributeValue("href", string.Empty))
|
||||
.Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(correctSizeLink))
|
||||
{
|
||||
correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl);
|
||||
}
|
||||
if (string.IsNullOrEmpty(correctSizeLink))
|
||||
{
|
||||
throw new KavitaException($"Could not grab favicon from {baseUrl}");
|
||||
}
|
||||
|
||||
var finalUrl = correctSizeLink;
|
||||
|
||||
// If starts with //, it's coming usually from an offsite cdn
|
||||
if (correctSizeLink.StartsWith("//"))
|
||||
{
|
||||
finalUrl = "https:" + correctSizeLink;
|
||||
}
|
||||
else if (!correctSizeLink.StartsWith(uri.Scheme))
|
||||
{
|
||||
finalUrl = Url.Combine(baseUrl, correctSizeLink);
|
||||
}
|
||||
|
||||
_logger.LogTrace("Fetching favicon from {Url}", finalUrl);
|
||||
// Download the favicon.ico file using Flurl
|
||||
var faviconStream = await finalUrl
|
||||
.AllowHttpStatus("2xx,304")
|
||||
.GetStreamAsync();
|
||||
|
||||
// Create the destination file path
|
||||
using var image = Image.PngloadStream(faviconStream);
|
||||
var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain);
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading favicon for {Domain}", domain);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat)
|
||||
{
|
||||
try
|
||||
{
|
||||
var publisherLink = await FallbackToKavitaReaderPublisher(publisherName);
|
||||
if (string.IsNullOrEmpty(publisherLink))
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
_logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize());
|
||||
// Download the publisher file using Flurl
|
||||
var publisherStream = await publisherLink
|
||||
.AllowHttpStatus("2xx,304")
|
||||
.GetStreamAsync();
|
||||
|
||||
// Create the destination file path
|
||||
using var image = Image.NewFromStream(publisherStream);
|
||||
var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize());
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName.Sanitize());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to download the Person image from CoverDB while matching against metadata within the Person
|
||||
/// </summary>
|
||||
/// <param name="person"></param>
|
||||
/// <param name="encodeFormat"></param>
|
||||
/// <returns>Person image (in correct directory) or null if not found/error</returns>
|
||||
public async Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat)
|
||||
{
|
||||
try
|
||||
{
|
||||
var personImageLink = await GetCoverPersonImagePath(person);
|
||||
if (string.IsNullOrEmpty(personImageLink))
|
||||
{
|
||||
throw new KavitaException($"Could not grab person image for {person.Name}");
|
||||
}
|
||||
|
||||
// Create the destination file path
|
||||
var filename = ImageService.GetPersonFormat(person.Id) + encodeFormat.GetExtension();
|
||||
var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename);
|
||||
|
||||
// Ensure if file exists, we delete to overwrite
|
||||
|
||||
|
||||
_logger.LogTrace("Fetching publisher image from {Url}", personImageLink.Sanitize());
|
||||
// Download the publisher file using Flurl
|
||||
var personStream = await personImageLink
|
||||
.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);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Person image for {PersonName} downloaded and saved successfully", person.Name);
|
||||
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading image for {PersonName}", person.Name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string> GetCoverPersonImagePath(Person person)
|
||||
{
|
||||
var tempFile = Path.Join(_directoryService.TempDirectory, "people.yml");
|
||||
|
||||
// Check if the file already exists and skip download in Development environment
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
if (_env.IsDevelopment())
|
||||
{
|
||||
_logger.LogInformation("Using existing people.yml file in Development environment");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remove file if not in Development and file is older than 7 days
|
||||
if (File.GetLastWriteTime(tempFile) < DateTime.Now.AddDays(-7))
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download the file if it doesn't exist or was deleted due to age
|
||||
if (!File.Exists(tempFile))
|
||||
{
|
||||
var masterPeopleFile = await $"{NewHost}people/people.yml"
|
||||
.DownloadFileAsync(_directoryService.TempDirectory);
|
||||
|
||||
if (!File.Exists(tempFile) || string.IsNullOrEmpty(masterPeopleFile))
|
||||
{
|
||||
_logger.LogError("Could not download people.yml from Github");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var coverDbRepository = new CoverDbRepository(tempFile);
|
||||
|
||||
var coverAuthor = coverDbRepository.FindBestAuthorMatch(person);
|
||||
if (coverAuthor == null || string.IsNullOrEmpty(coverAuthor.ImagePath))
|
||||
{
|
||||
throw new KavitaException($"Could not grab person image for {person.Name}");
|
||||
}
|
||||
|
||||
return $"{NewHost}{coverAuthor.ImagePath}";
|
||||
}
|
||||
|
||||
private static async Task<string> FallbackToKavitaReaderFavicon(string baseUrl)
|
||||
{
|
||||
var correctSizeLink = string.Empty;
|
||||
// TODO: Pull this down and store it in temp/ to save on requests
|
||||
var allOverrides = await $"{NewHost}favicons/urls.txt"
|
||||
.GetStringAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
{
|
||||
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.FirstOrDefault(url =>
|
||||
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
|
||||
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
|
||||
));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
throw new KavitaException($"Could not grab favicon from {baseUrl.Sanitize()}");
|
||||
}
|
||||
|
||||
correctSizeLink = $"{NewHost}favicons/" + externalFile;
|
||||
}
|
||||
|
||||
return correctSizeLink;
|
||||
}
|
||||
|
||||
private static async Task<string> FallbackToKavitaReaderPublisher(string publisherName)
|
||||
{
|
||||
var externalLink = string.Empty;
|
||||
// TODO: Pull this down and store it in temp/ to save on requests
|
||||
var allOverrides = await $"{NewHost}publishers/publishers.txt".GetStringAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
{
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.Select(publisherLine =>
|
||||
{
|
||||
var tokens = publisherLine.Split("|");
|
||||
if (tokens.Length != 2) return null;
|
||||
var aliases = tokens[0];
|
||||
// Multiple publisher aliases are separated by #
|
||||
if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim())))
|
||||
{
|
||||
return tokens[1];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.FirstOrDefault(url => !string.IsNullOrEmpty(url));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
externalLink = $"{NewHost}publishers/" + externalFile;
|
||||
}
|
||||
|
||||
return externalLink;
|
||||
}
|
||||
}
|
||||
|
|
@ -274,6 +274,7 @@ public class Startup
|
|||
// v0.8.4
|
||||
await MigrateLowestSeriesFolderPath2.Migrate(dataContext, unitOfWork, logger);
|
||||
await ManualMigrateRemovePeople.Migrate(dataContext, logger);
|
||||
await MigrateDuplicateDarkTheme.Migrate(dataContext, logger);
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue