Kavita+ Overhaul & New Changelog (#3507)

This commit is contained in:
Joe Milazzo 2025-01-20 08:14:57 -06:00 committed by GitHub
parent d880c1690c
commit a5707617f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
249 changed files with 14775 additions and 2300 deletions

View file

@ -15,6 +15,7 @@ using API.Errors;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using AutoMapper;
using Hangfire;

View file

@ -1,5 +1,8 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.Data.ManualMigrations;
using API.DTOs;
using API.DTOs.Progress;
using API.Entities;
using Microsoft.AspNetCore.Authorization;
@ -13,10 +16,12 @@ namespace API.Controllers;
public class AdminController : BaseApiController
{
private readonly UserManager<AppUser> _userManager;
private readonly IUnitOfWork _unitOfWork;
public AdminController(UserManager<AppUser> userManager)
public AdminController(UserManager<AppUser> userManager, IUnitOfWork unitOfWork)
{
_userManager = userManager;
_unitOfWork = unitOfWork;
}
/// <summary>

View file

@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Email;
using API.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
[Authorize(Policy = "RequireAdminRole")]
public class EmailController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
public EmailController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
[HttpGet("all")]
public async Task<ActionResult<IList<EmailHistoryDto>>> GetEmails()
{
return Ok(await _unitOfWork.EmailHistoryRepository.GetEmailDtos(UserParams.Default));
}
}

View file

@ -2,11 +2,12 @@
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.License;
using API.DTOs.KavitaPlus.License;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using Hangfire;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -41,7 +42,7 @@ public class LicenseController(
}
/// <summary>
/// Has any license
/// Has any license registered with the instance. Does not check Kavita+ API
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
@ -53,6 +54,19 @@ public class LicenseController(
(await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value));
}
/// <summary>
/// Asks Kavita+ for the latest license info
/// </summary>
/// <param name="forceCheck">Force checking the API and skip the 8 hour cache</param>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
public async Task<ActionResult<LicenseInfoDto?>> GetLicenseInfo(bool forceCheck = false)
{
return Ok(await licenseService.GetLicenseInfo(forceCheck));
}
[Authorize("RequireAdminRole")]
[HttpDelete]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
@ -67,6 +81,7 @@ public class LicenseController(
return Ok();
}
[Authorize("RequireAdminRole")]
[HttpPost("reset")]
public async Task<ActionResult> ResetLicense(UpdateLicenseDto dto)

View file

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.KavitaPlus.Manage;
using API.Services.Plus;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// All things centered around Managing the Kavita instance, that isn't aligned with an entity
/// </summary>
[Authorize("RequireAdminRole")]
public class ManageController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILicenseService _licenseService;
public ManageController(IUnitOfWork unitOfWork, ILicenseService licenseService)
{
_unitOfWork = unitOfWork;
_licenseService = licenseService;
}
/// <summary>
/// Returns a list of all Series that is Kavita+ applicable to metadata match and the status of it
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpPost("series-metadata")]
public async Task<ActionResult<IList<ManageMatchSeriesDto>>> SeriesMetadata(ManageMatchFilterDto filter)
{
if (!await _licenseService.HasActiveLicense()) return Ok(Array.Empty<SeriesDto>());
return Ok(await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeries(filter));
}
}

View file

@ -32,12 +32,15 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
/// Fetches genres from the instance
/// </summary>
/// <param name="libraryIds">String separated libraryIds or null for all genres</param>
/// <param name="context">Context from which this API was invoked</param>
/// <returns></returns>
[HttpGet("genres")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds", "context"])]
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds, QueryContext context = QueryContext.None)
{
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(int.Parse)
.ToList();
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context));
}
@ -189,12 +192,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("force-refresh")]
public async Task<ActionResult> ForceRefresh(int seriesId)
{
await metadataService.ForceKavitaPlusRefresh(seriesId);
return Ok();
}
// [HttpPost("force-refresh")]
// public async Task<ActionResult> ForceRefresh(int seriesId)
// {
// await metadataService.ForceKavitaPlusRefresh(seriesId);
// return Ok();
// }
/// <summary>
/// Fetches the details needed from Kavita+ for Series Detail page

View file

@ -764,6 +764,12 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
/// <summary>
/// OPDS Search endpoint
/// </summary>
/// <param name="apiKey"></param>
/// <param name="query"></param>
/// <returns></returns>
[HttpGet("{apiKey}/series")]
[Produces("application/xml")]
public async Task<IActionResult> SearchSeries(string apiKey, [FromQuery] string query)
@ -781,20 +787,21 @@ public class OpdsController : BaseApiController
query = query.Replace(@"%", string.Empty);
// Get libraries user has access to
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
if (!libraries.Any()) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted"));
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted"));
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var series = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, libraries.Select(l => l.Id).ToArray(), query);
var searchResults = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin,
libraries.Select(l => l.Id).ToArray(), query, includeChapterAndFiles: false);
var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey, prefix);
SetFeedId(feed, "search-series");
foreach (var seriesDto in series.Series)
foreach (var seriesDto in searchResults.Series)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey, prefix, baseUrl));
}
foreach (var collection in series.Collections)
foreach (var collection in searchResults.Collections)
{
feed.Entries.Add(new FeedEntry()
{
@ -813,7 +820,7 @@ public class OpdsController : BaseApiController
});
}
foreach (var readingListDto in series.ReadingLists)
foreach (var readingListDto in searchResults.ReadingLists)
{
feed.Entries.Add(new FeedEntry()
{
@ -827,6 +834,7 @@ public class OpdsController : BaseApiController
});
}
// TODO: Search should allow Chapters/Files and more
return CreateXmlResult(SerializeXml(feed));
}

View file

@ -73,7 +73,7 @@ public class PersonController : BaseApiController
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize("AdminRequired")]
[Authorize("RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult<PersonDto>> UpdatePerson(UpdatePersonDto dto)
{
@ -135,7 +135,11 @@ public class PersonController : BaseApiController
var personImage = await _coverDbService.DownloadPersonImageAsync(person, settings.EncodeMediaAs);
if (string.IsNullOrEmpty(personImage)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist"));
if (string.IsNullOrEmpty(personImage))
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist"));
}
person.CoverImage = personImage;
_imageService.UpdateColorScape(person);

View file

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Account;
using API.DTOs.KavitaPlus.Account;
using API.DTOs.Scrobbling;
using API.Entities.Scrobble;
using API.Extensions;
@ -73,9 +74,9 @@ public class ScrobblingController : BaseApiController
/// Update the current user's AniList token
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
/// <returns>True if the token was new or not</returns>
[HttpPost("update-anilist-token")]
public async Task<ActionResult> UpdateAniListToken(AniListUpdateDto dto)
public async Task<ActionResult<bool>> UpdateAniListToken(AniListUpdateDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
@ -85,31 +86,39 @@ public class ScrobblingController : BaseApiController
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
if (isNewToken)
{
BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(user.Id));
}
return Ok();
return Ok(isNewToken);
}
/// <summary>
/// Update the current user's MAL token (Client ID) and Username
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
/// <returns>True if the token was new or not</returns>
[HttpPost("update-mal-token")]
public async Task<ActionResult> UpdateMalToken(MalUserInfoDto dto)
public async Task<ActionResult<bool>> UpdateMalToken(MalUserInfoDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
var isNewToken = string.IsNullOrEmpty(user.MalAccessToken);
user.MalAccessToken = dto.AccessToken;
user.MalUserName = dto.Username;
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
return Ok(isNewToken);
}
/// <summary>
/// When a user request to generate scrobble events from history. Should only be ran once per user.
/// </summary>
/// <returns></returns>
[HttpPost("generate-scrobble-events")]
public ActionResult GenerateScrobbleEvents()
{
BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(User.GetUserId()));
return Ok();
}

View file

@ -9,6 +9,7 @@ using API.DTOs.Dashboard;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata;
using API.DTOs.Metadata.Matching;
using API.DTOs.Recommendation;
using API.DTOs.SeriesDetail;
using API.Entities;
@ -616,4 +617,42 @@ public class SeriesController : BaseApiController
return Ok(await _seriesService.GetEstimatedChapterCreationDate(seriesId, userId));
}
/// <summary>
/// Sends a request to Kavita+ API for all potential matches, sorted by relevance
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("match")]
public async Task<ActionResult<IList<ExternalSeriesMatchDto>>> MatchSeries(MatchSeriesDto dto)
{
return Ok(await _externalMetadataService.MatchSeries(dto));
}
/// <summary>
/// This will perform the fix match
/// </summary>
/// <param name="dto"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("update-match")]
public async Task<ActionResult> UpdateSeriesMatch(ExternalSeriesDetailDto dto, [FromQuery] int seriesId)
{
await _externalMetadataService.FixSeriesMatch(seriesId, dto);
return Ok();
}
/// <summary>
/// When true, will not perform a match and will prevent Kavita from attempting to match/scrobble against this series
/// </summary>
/// <param name="seriesId"></param>
/// <param name="dontMatch"></param>
/// <returns></returns>
[HttpPost("dont-match")]
public async Task<ActionResult> UpdateDontMatch([FromQuery] int seriesId, [FromQuery] bool dontMatch)
{
await _externalMetadataService.UpdateSeriesDontMatch(seriesId, dontMatch);
return Ok();
}
}

View file

@ -213,15 +213,16 @@ public class ServerController : BaseApiController
/// <summary>
/// Pull the Changelog for Kavita from Github and display
/// </summary>
/// <param name="count">How many releases from the latest to return</param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("changelog")]
public async Task<ActionResult<IEnumerable<UpdateNotificationDto>>> GetChangelog()
public async Task<ActionResult<IEnumerable<UpdateNotificationDto>>> GetChangelog(int count = 0)
{
// Strange bug where [Authorize] doesn't work
if (User.GetUserId() == 0) return Unauthorized();
return Ok(await _versionUpdaterService.GetAllReleases());
return Ok(await _versionUpdaterService.GetAllReleases(count));
}
/// <summary>

View file

@ -389,7 +389,6 @@ public class SettingsController : BaseApiController
{
if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value)
{
//if (updateSettingsDto.TotalBackup)
setting.Value = updateSettingsDto.TaskBackup;
_unitOfWork.SettingsRepository.Update(setting);

View file

@ -222,18 +222,4 @@ public class StatsController : BaseApiController
return Ok(_statService.GetWordsReadCountByYear(userId));
}
/// <summary>
/// Returns for Kavita+ the number of Series that have been processed, errored, and not processed
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("kavitaplus-metadata-breakdown")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetKavitaPlusMetadataBreakdown()
{
if (!await _licenseService.HasActiveLicense())
return BadRequest("This data is not available for non-Kavita+ servers");
return Ok(await _statService.GetKavitaPlusMetadataBreakdown());
}
}

View file

@ -5,8 +5,10 @@ using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.KavitaPlus.Account;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
@ -23,14 +25,16 @@ public class UsersController : BaseApiController
private readonly IMapper _mapper;
private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService;
private readonly ILicenseService _licenseService;
public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub,
ILocalizationService localizationService)
ILocalizationService localizationService, ILicenseService licenseService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_eventHub = eventHub;
_localizationService = localizationService;
_licenseService = licenseService;
}
[Authorize(Policy = "RequireAdminRole")]
@ -173,4 +177,18 @@ public class UsersController : BaseApiController
{
return Ok((await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(u => u.UserName));
}
/// <summary>
/// Returns all users with tokens registered and their token information. Does not send the tokens.
/// </summary>
/// <remarks>Kavita+ only</remarks>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("tokens")]
public async Task<ActionResult<IEnumerable<UserTokenInfo>>> GetUserTokens()
{
if (!await _licenseService.HasActiveLicense()) return BadRequest(_localizationService.Translate(User.GetUserId(), "kavitaplus-restricted"));
return Ok((await _unitOfWork.UserRepository.GetUserTokenInfo()));
}
}