mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-04 22:25:36 -04:00
Background Prefetching for Kavita+ (#2707)
This commit is contained in:
parent
f616b99585
commit
5dc5029a75
79
API.Tests/Helpers/RateLimiterTests.cs
Normal file
79
API.Tests/Helpers/RateLimiterTests.cs
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using API.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace API.Tests.Helpers;
|
||||||
|
|
||||||
|
public class RateLimiterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AcquireTokens_Successful()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var limiter = new RateLimiter(3, TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
Assert.True(limiter.TryAcquire("test_key"));
|
||||||
|
Assert.True(limiter.TryAcquire("test_key"));
|
||||||
|
Assert.True(limiter.TryAcquire("test_key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcquireTokens_ExceedLimit()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var limiter = new RateLimiter(2, TimeSpan.FromSeconds(10), false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
limiter.TryAcquire("test_key");
|
||||||
|
limiter.TryAcquire("test_key");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(limiter.TryAcquire("test_key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcquireTokens_Refill()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var limiter = new RateLimiter(2, TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
limiter.TryAcquire("test_key");
|
||||||
|
limiter.TryAcquire("test_key");
|
||||||
|
|
||||||
|
// Wait for refill
|
||||||
|
System.Threading.Thread.Sleep(1100);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(limiter.TryAcquire("test_key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcquireTokens_Refill_WithOff()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var limiter = new RateLimiter(2, TimeSpan.FromSeconds(10), false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
limiter.TryAcquire("test_key");
|
||||||
|
limiter.TryAcquire("test_key");
|
||||||
|
|
||||||
|
// Wait for refill
|
||||||
|
System.Threading.Thread.Sleep(2100);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(limiter.TryAcquire("test_key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcquireTokens_MultipleKeys()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var limiter = new RateLimiter(2, TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
Assert.True(limiter.TryAcquire("key1"));
|
||||||
|
Assert.True(limiter.TryAcquire("key2"));
|
||||||
|
}
|
||||||
|
}
|
@ -1392,4 +1392,96 @@ public class SeriesServiceTests : AbstractDbTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region DeleteMultipleSeries
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteMultipleSeries_ShouldDeleteSeries()
|
||||||
|
{
|
||||||
|
await ResetDb();
|
||||||
|
var lib1 = new LibraryBuilder("Test LIb")
|
||||||
|
.WithSeries(new SeriesBuilder("Test Series")
|
||||||
|
.WithMetadata(new SeriesMetadata()
|
||||||
|
{
|
||||||
|
AgeRating = AgeRating.Everyone
|
||||||
|
})
|
||||||
|
.WithVolume(new VolumeBuilder("0")
|
||||||
|
.WithChapter(new ChapterBuilder("1").WithFile(
|
||||||
|
new MangaFileBuilder($"{DataDirectory}1.zip", MangaFormat.Archive)
|
||||||
|
.WithPages(1)
|
||||||
|
.Build()
|
||||||
|
).Build())
|
||||||
|
.Build())
|
||||||
|
.Build())
|
||||||
|
.WithSeries(new SeriesBuilder("Test Series Prequels").Build())
|
||||||
|
.WithSeries(new SeriesBuilder("Test Series Sequels").Build())
|
||||||
|
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||||
|
.Build();
|
||||||
|
_context.Library.Add(lib1);
|
||||||
|
|
||||||
|
var lib2 = new LibraryBuilder("Test LIb 2", LibraryType.Book)
|
||||||
|
.WithSeries(new SeriesBuilder("Test Series 2").Build())
|
||||||
|
.WithSeries(new SeriesBuilder("Test Series Prequels 2").Build())
|
||||||
|
.WithSeries(new SeriesBuilder("Test Series Prequels 2").Build())// TODO: Is this a bug
|
||||||
|
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||||
|
.Build();
|
||||||
|
_context.Library.Add(lib2);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1,
|
||||||
|
SeriesIncludes.Related | SeriesIncludes.ExternalRatings);
|
||||||
|
// Add relations
|
||||||
|
var addRelationDto = CreateRelationsDto(series1);
|
||||||
|
addRelationDto.Adaptations.Add(4); // cross library link
|
||||||
|
await _seriesService.UpdateRelatedSeries(addRelationDto);
|
||||||
|
|
||||||
|
|
||||||
|
// Setup External Metadata stuff
|
||||||
|
series1.ExternalSeriesMetadata ??= new ExternalSeriesMetadata();
|
||||||
|
series1.ExternalSeriesMetadata.ExternalRatings = new List<ExternalRating>()
|
||||||
|
{
|
||||||
|
new ExternalRating()
|
||||||
|
{
|
||||||
|
SeriesId = 1,
|
||||||
|
Provider = ScrobbleProvider.Mal,
|
||||||
|
AverageScore = 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
series1.ExternalSeriesMetadata.ExternalRecommendations = new List<ExternalRecommendation>()
|
||||||
|
{
|
||||||
|
new ExternalRecommendation()
|
||||||
|
{
|
||||||
|
SeriesId = 2,
|
||||||
|
Name = "Series 2",
|
||||||
|
Url = "",
|
||||||
|
CoverUrl = ""
|
||||||
|
},
|
||||||
|
new ExternalRecommendation()
|
||||||
|
{
|
||||||
|
SeriesId = 0, // Causes a FK constraint
|
||||||
|
Name = "Series 2",
|
||||||
|
Url = "",
|
||||||
|
CoverUrl = ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
series1.ExternalSeriesMetadata.ExternalReviews = new List<ExternalReview>()
|
||||||
|
{
|
||||||
|
new ExternalReview()
|
||||||
|
{
|
||||||
|
Body = "",
|
||||||
|
Provider = ScrobbleProvider.Mal,
|
||||||
|
BodyJustText = ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Ensure we can delete the series
|
||||||
|
Assert.True(await _seriesService.DeleteMultipleSeries(new[] {1, 2}));
|
||||||
|
Assert.Null(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1));
|
||||||
|
Assert.Null(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
@ -103,7 +103,7 @@ public class DeviceController : BaseApiController
|
|||||||
if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds"));
|
if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds"));
|
||||||
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
|
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
|
||||||
|
|
||||||
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetup();
|
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
|
||||||
if (!isEmailSetup)
|
if (!isEmailSetup)
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
|
||||||
|
|
||||||
@ -142,7 +142,7 @@ public class DeviceController : BaseApiController
|
|||||||
if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId"));
|
if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId"));
|
||||||
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
|
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
|
||||||
|
|
||||||
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetup();
|
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
|
||||||
if (!isEmailSetup)
|
if (!isEmailSetup)
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
|
||||||
|
|
||||||
|
@ -122,6 +122,11 @@ public class SeriesController : BaseApiController
|
|||||||
return Ok(series);
|
return Ok(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a series from Kavita
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="seriesId"></param>
|
||||||
|
/// <returns>If the series was deleted or not</returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
[Authorize(Policy = "RequireAdminRole")]
|
||||||
[HttpDelete("{seriesId}")]
|
[HttpDelete("{seriesId}")]
|
||||||
public async Task<ActionResult<bool>> DeleteSeries(int seriesId)
|
public async Task<ActionResult<bool>> DeleteSeries(int seriesId)
|
||||||
@ -139,7 +144,7 @@ public class SeriesController : BaseApiController
|
|||||||
var username = User.GetUsername();
|
var username = User.GetUsername();
|
||||||
_logger.LogInformation("Series {@SeriesId} is being deleted by {UserName}", dto.SeriesIds, username);
|
_logger.LogInformation("Series {@SeriesId} is being deleted by {UserName}", dto.SeriesIds, username);
|
||||||
|
|
||||||
if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok();
|
if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok(true);
|
||||||
|
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-series-delete"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-series-delete"));
|
||||||
}
|
}
|
||||||
|
@ -95,9 +95,18 @@ public class ServerSettingDto
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public bool IsEmailSetup()
|
public bool IsEmailSetup()
|
||||||
{
|
{
|
||||||
//return false;
|
|
||||||
return !string.IsNullOrEmpty(SmtpConfig.Host)
|
return !string.IsNullOrEmpty(SmtpConfig.Host)
|
||||||
&& !string.IsNullOrEmpty(SmtpConfig.UserName)
|
&& !string.IsNullOrEmpty(SmtpConfig.UserName)
|
||||||
&& !string.IsNullOrEmpty(HostName);
|
&& !string.IsNullOrEmpty(HostName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Are at least some basics filled in, but not hostname as not required for Send to Device
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
public bool IsEmailSetupForSendToDevice()
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(SmtpConfig.Host)
|
||||||
|
&& !string.IsNullOrEmpty(SmtpConfig.UserName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -143,6 +143,12 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
builder.Entity<AppUserSideNavStream>()
|
builder.Entity<AppUserSideNavStream>()
|
||||||
.HasIndex(e => e.Visible)
|
.HasIndex(e => e.Visible)
|
||||||
.IsUnique(false);
|
.IsUnique(false);
|
||||||
|
|
||||||
|
builder.Entity<ExternalSeriesMetadata>()
|
||||||
|
.HasOne(em => em.Series)
|
||||||
|
.WithOne(s => s.ExternalSeriesMetadata)
|
||||||
|
.HasForeignKey<ExternalSeriesMetadata>(em => em.SeriesId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
}
|
}
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
2871
API/Data/Migrations/20240209224347_DBTweaks.Designer.cs
generated
Normal file
2871
API/Data/Migrations/20240209224347_DBTweaks.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20240209224347_DBTweaks.cs
Normal file
29
API/Data/Migrations/20240209224347_DBTweaks.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class DBTweaks : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ExternalRecommendation_Series_SeriesId",
|
||||||
|
table: "ExternalRecommendation");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ExternalRecommendation_Series_SeriesId",
|
||||||
|
table: "ExternalRecommendation",
|
||||||
|
column: "SeriesId",
|
||||||
|
principalTable: "Series",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2398,15 +2398,6 @@ namespace API.Data.Migrations
|
|||||||
b.Navigation("Chapter");
|
b.Navigation("Chapter");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("API.Entities.Series", "Series")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("SeriesId");
|
|
||||||
|
|
||||||
b.Navigation("Series");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
|
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.Series", "Series")
|
b.HasOne("API.Entities.Series", "Series")
|
||||||
|
@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Identity;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace API.Data.Repositories;
|
namespace API.Data.Repositories;
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
public interface IExternalSeriesMetadataRepository
|
public interface IExternalSeriesMetadataRepository
|
||||||
{
|
{
|
||||||
@ -28,6 +29,7 @@ public interface IExternalSeriesMetadataRepository
|
|||||||
void Remove(IEnumerable<ExternalReview>? reviews);
|
void Remove(IEnumerable<ExternalReview>? reviews);
|
||||||
void Remove(IEnumerable<ExternalRating>? ratings);
|
void Remove(IEnumerable<ExternalRating>? ratings);
|
||||||
void Remove(IEnumerable<ExternalRecommendation>? recommendations);
|
void Remove(IEnumerable<ExternalRecommendation>? recommendations);
|
||||||
|
void Remove(ExternalSeriesMetadata metadata);
|
||||||
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
|
Task<ExternalSeriesMetadata?> GetExternalSeriesMetadata(int seriesId);
|
||||||
Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId);
|
Task<bool> ExternalSeriesMetadataNeedsRefresh(int seriesId);
|
||||||
Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId);
|
Task<SeriesDetailPlusDto> GetSeriesDetailPlusDto(int seriesId);
|
||||||
@ -70,18 +72,24 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
|
|||||||
_context.ExternalReview.RemoveRange(reviews);
|
_context.ExternalReview.RemoveRange(reviews);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Remove(IEnumerable<ExternalRating> ratings)
|
public void Remove(IEnumerable<ExternalRating>? ratings)
|
||||||
{
|
{
|
||||||
if (ratings == null) return;
|
if (ratings == null) return;
|
||||||
_context.ExternalRating.RemoveRange(ratings);
|
_context.ExternalRating.RemoveRange(ratings);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Remove(IEnumerable<ExternalRecommendation> recommendations)
|
public void Remove(IEnumerable<ExternalRecommendation>? recommendations)
|
||||||
{
|
{
|
||||||
if (recommendations == null) return;
|
if (recommendations == null) return;
|
||||||
_context.ExternalRecommendation.RemoveRange(recommendations);
|
_context.ExternalRecommendation.RemoveRange(recommendations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Remove(ExternalSeriesMetadata? metadata)
|
||||||
|
{
|
||||||
|
if (metadata == null) return;
|
||||||
|
_context.ExternalSeriesMetadata.Remove(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the ExternalSeriesMetadata entity for the given Series including all linked tables
|
/// Returns the ExternalSeriesMetadata entity for the given Series including all linked tables
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -56,6 +56,7 @@ public interface ILibraryRepository
|
|||||||
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
||||||
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
|
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
|
||||||
|
|
||||||
|
Task<IDictionary<int, LibraryType>> GetLibraryTypesBySeriesIdsAsync(IList<int> seriesIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LibraryRepository : ILibraryRepository
|
public class LibraryRepository : ILibraryRepository
|
||||||
@ -352,4 +353,16 @@ public class LibraryRepository : ILibraryRepository
|
|||||||
.Select(s => s.Library.AllowScrobbling)
|
.Select(s => s.Library.AllowScrobbling)
|
||||||
.SingleOrDefaultAsync();
|
.SingleOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IDictionary<int, LibraryType>> GetLibraryTypesBySeriesIdsAsync(IList<int> seriesIds)
|
||||||
|
{
|
||||||
|
return await _context.Series
|
||||||
|
.Where(series => seriesIds.Contains(series.Id))
|
||||||
|
.Select(series => new
|
||||||
|
{
|
||||||
|
series.Id,
|
||||||
|
series.Library.Type
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(entity => entity.Id, entity => entity.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,6 +49,7 @@ public enum SeriesIncludes
|
|||||||
ExternalReviews = 64,
|
ExternalReviews = 64,
|
||||||
ExternalRatings = 128,
|
ExternalRatings = 128,
|
||||||
ExternalRecommendations = 256,
|
ExternalRecommendations = 256,
|
||||||
|
ExternalMetadata = 512
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -551,7 +552,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns Volumes, Metadata, and Collection Tags
|
/// Returns Full Series including all external links
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="seriesIds"></param>
|
/// <param name="seriesIds"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
@ -559,9 +560,20 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
{
|
{
|
||||||
return await _context.Series
|
return await _context.Series
|
||||||
.Include(s => s.Volumes)
|
.Include(s => s.Volumes)
|
||||||
|
.Include(s => s.Relations)
|
||||||
.Include(s => s.Metadata)
|
.Include(s => s.Metadata)
|
||||||
.ThenInclude(m => m.CollectionTags)
|
.ThenInclude(m => m.CollectionTags)
|
||||||
.Include(s => s.Relations)
|
|
||||||
|
|
||||||
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
|
|
||||||
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
|
.ThenInclude(e => e.ExternalRatings)
|
||||||
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
|
.ThenInclude(e => e.ExternalReviews)
|
||||||
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
|
.ThenInclude(e => e.ExternalRecommendations)
|
||||||
|
|
||||||
.Where(s => seriesIds.Contains(s.Id))
|
.Where(s => seriesIds.Contains(s.Id))
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using API.Services.Plus;
|
using API.Services.Plus;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
|
||||||
namespace API.Entities.Metadata;
|
namespace API.Entities.Metadata;
|
||||||
|
|
||||||
|
[Index(nameof(SeriesId), IsUnique = false)]
|
||||||
public class ExternalRecommendation
|
public class ExternalRecommendation
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
@ -19,7 +22,7 @@ public class ExternalRecommendation
|
|||||||
/// When null, represents an external series. When set, it is a Series
|
/// When null, represents an external series. When set, it is a Series
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? SeriesId { get; set; }
|
public int? SeriesId { get; set; }
|
||||||
public virtual Series Series { get; set; }
|
//public virtual Series? Series { get; set; }
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
|
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
|
||||||
|
@ -80,6 +80,12 @@ public static class IncludesExtensions
|
|||||||
.ThenInclude(s => s.ExternalRatings);
|
.ThenInclude(s => s.ExternalRatings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeFlags.HasFlag(SeriesIncludes.ExternalMetadata))
|
||||||
|
{
|
||||||
|
query = query
|
||||||
|
.Include(s => s.ExternalSeriesMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
if (includeFlags.HasFlag(SeriesIncludes.ExternalRecommendations))
|
if (includeFlags.HasFlag(SeriesIncludes.ExternalRecommendations))
|
||||||
{
|
{
|
||||||
query = query
|
query = query
|
||||||
|
59
API/Helpers/RateLimiter.cs
Normal file
59
API/Helpers/RateLimiter.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace API.Helpers;
|
||||||
|
|
||||||
|
public class RateLimiter(int maxRequests, TimeSpan duration, bool refillBetween = true)
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, (int Tokens, DateTime LastRefill)> _tokenBuckets = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public bool TryAcquire(string key)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_tokenBuckets.TryGetValue(key, out var bucket))
|
||||||
|
{
|
||||||
|
bucket = (Tokens: maxRequests, LastRefill: DateTime.UtcNow);
|
||||||
|
_tokenBuckets[key] = bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefillTokens(key);
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
|
||||||
|
if (_tokenBuckets[key].Tokens > 0)
|
||||||
|
{
|
||||||
|
_tokenBuckets[key] = (Tokens: _tokenBuckets[key].Tokens - 1, LastRefill: _tokenBuckets[key].LastRefill);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefillTokens(string key)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var timeSinceLastRefill = now - _tokenBuckets[key].LastRefill;
|
||||||
|
var tokensToAdd = (int) (timeSinceLastRefill.TotalSeconds / duration.TotalSeconds);
|
||||||
|
|
||||||
|
// Refill the bucket if the elapsed time is greater than or equal to the duration
|
||||||
|
if (timeSinceLastRefill >= duration)
|
||||||
|
{
|
||||||
|
_tokenBuckets[key] = (Tokens: maxRequests, LastRefill: now);
|
||||||
|
Console.WriteLine($"Tokens Refilled to Max: {maxRequests}");
|
||||||
|
}
|
||||||
|
else if (tokensToAdd > 0 && refillBetween)
|
||||||
|
{
|
||||||
|
_tokenBuckets[key] = (Tokens: Math.Min(maxRequests, _tokenBuckets[key].Tokens + tokensToAdd), LastRefill: now);
|
||||||
|
Console.WriteLine($"Tokens Refilled: {_tokenBuckets[key].Tokens}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -126,6 +126,7 @@ public class DeviceService : IDeviceService
|
|||||||
device.UpdateLastUsed();
|
device.UpdateLastUsed();
|
||||||
_unitOfWork.DeviceRepository.Update(device);
|
_unitOfWork.DeviceRepository.Update(device);
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
var success = await _emailService.SendFilesToEmail(new SendToDto()
|
var success = await _emailService.SendFilesToEmail(new SendToDto()
|
||||||
{
|
{
|
||||||
DestinationEmail = device.EmailAddress!,
|
DestinationEmail = device.EmailAddress!,
|
||||||
|
@ -224,7 +224,7 @@ public class EmailService : IEmailService
|
|||||||
public async Task<bool> SendFilesToEmail(SendToDto data)
|
public async Task<bool> SendFilesToEmail(SendToDto data)
|
||||||
{
|
{
|
||||||
var serverSetting = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
var serverSetting = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||||
if (!serverSetting.IsEmailSetup()) return false;
|
if (!serverSetting.IsEmailSetupForSendToDevice()) return false;
|
||||||
|
|
||||||
var emailOptions = new EmailOptionsDto()
|
var emailOptions = new EmailOptionsDto()
|
||||||
{
|
{
|
||||||
|
@ -13,6 +13,7 @@ using API.Entities;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
|
using API.Helpers;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
@ -76,6 +77,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
Ratings = ArraySegment<RatingDto>.Empty,
|
Ratings = ArraySegment<RatingDto>.Empty,
|
||||||
Reviews = ArraySegment<UserReviewDto>.Empty
|
Reviews = ArraySegment<UserReviewDto>.Empty
|
||||||
};
|
};
|
||||||
|
// Allow 50 requests per 24 hours
|
||||||
|
private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(12), false);
|
||||||
|
|
||||||
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper, ILicenseService licenseService)
|
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper, ILicenseService licenseService)
|
||||||
{
|
{
|
||||||
@ -85,7 +88,6 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
_licenseService = licenseService;
|
_licenseService = licenseService;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
||||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||||
}
|
}
|
||||||
@ -114,17 +116,17 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeriesIdsWithoutMetadata(25);
|
var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeriesIdsWithoutMetadata(25);
|
||||||
if (ids.Count == 0) return;
|
if (ids.Count == 0) return;
|
||||||
|
|
||||||
_logger.LogInformation("Started Refreshing {Count} series data from Kavita+", ids.Count);
|
_logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count);
|
||||||
var count = 0;
|
var count = 0;
|
||||||
|
var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids);
|
||||||
foreach (var seriesId in ids)
|
foreach (var seriesId in ids)
|
||||||
{
|
{
|
||||||
// TODO: Rewrite this so it's streamlined and not multiple DB calls
|
var libraryType = libTypes[seriesId];
|
||||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId);
|
await GetNewSeriesData(seriesId, libraryType);
|
||||||
await GetSeriesDetailPlus(seriesId, libraryType);
|
|
||||||
await Task.Delay(1500);
|
await Task.Delay(1500);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
_logger.LogInformation("Finished Refreshing {Count} series data from Kavita+", count);
|
_logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -145,13 +147,30 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
[DisableConcurrentExecution(60 * 60 * 60)]
|
/// <summary>
|
||||||
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
/// Fetches data from Kavita+
|
||||||
public Task GetNewSeriesData(int seriesId, LibraryType libraryType)
|
/// </summary>
|
||||||
|
/// <param name="seriesId"></param>
|
||||||
|
/// <param name="libraryType"></param>
|
||||||
|
public async Task GetNewSeriesData(int seriesId, LibraryType libraryType)
|
||||||
{
|
{
|
||||||
// TODO: Implement this task
|
if (!IsPlusEligible(libraryType)) return;
|
||||||
if (!IsPlusEligible(libraryType)) return Task.CompletedTask;
|
|
||||||
return Task.CompletedTask;
|
// Generate key based on seriesId and libraryType or any unique identifier for the request
|
||||||
|
// Check if the request is allowed based on the rate limit
|
||||||
|
if (!RateLimiter.TryAcquire(string.Empty))
|
||||||
|
{
|
||||||
|
// Request not allowed due to rate limit
|
||||||
|
_logger.LogDebug("Rate Limit hit for Kavita+ prefetch");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId);
|
||||||
|
// Prefetch SeriesDetail data
|
||||||
|
await GetSeriesDetailPlus(seriesId, libraryType);
|
||||||
|
|
||||||
|
// TODO: Fetch Series Metadata
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -427,6 +427,9 @@ public class SeriesService : ISeriesService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(seriesIds);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(seriesIds);
|
||||||
|
|
||||||
|
_unitOfWork.SeriesRepository.Remove(series);
|
||||||
|
|
||||||
var libraryIds = series.Select(s => s.LibraryId);
|
var libraryIds = series.Select(s => s.LibraryId);
|
||||||
var libraries = await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(libraryIds);
|
var libraries = await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(libraryIds);
|
||||||
foreach (var library in libraries)
|
foreach (var library in libraries)
|
||||||
@ -434,11 +437,8 @@ public class SeriesService : ISeriesService
|
|||||||
library.UpdateLastModified();
|
library.UpdateLastModified();
|
||||||
_unitOfWork.LibraryRepository.Update(library);
|
_unitOfWork.LibraryRepository.Update(library);
|
||||||
}
|
}
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
_unitOfWork.SeriesRepository.Remove(series);
|
|
||||||
|
|
||||||
|
|
||||||
if (!_unitOfWork.HasChanges() || !await _unitOfWork.CommitAsync()) return true;
|
|
||||||
|
|
||||||
foreach (var s in series)
|
foreach (var s in series)
|
||||||
{
|
{
|
||||||
@ -449,14 +449,13 @@ public class SeriesService : ISeriesService
|
|||||||
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
|
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
|
||||||
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||||
_taskScheduler.CleanupChapters(allChapterIds.ToArray());
|
_taskScheduler.CleanupChapters(allChapterIds.ToArray());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "There was an issue when trying to delete multiple series");
|
_logger.LogError(ex, "There was an issue when trying to delete multiple series");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -182,10 +182,10 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(),
|
RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(),
|
||||||
Cron.Daily, RecurringJobOptions);
|
Cron.Daily, RecurringJobOptions);
|
||||||
|
|
||||||
// Backfilling/Freshening Reviews/Rating/Recommendations (TODO: This will come in v0.8.x)
|
// Backfilling/Freshening Reviews/Rating/Recommendations
|
||||||
// RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId,
|
RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId,
|
||||||
// () => _externalMetadataService.FetchExternalDataTask(), Cron.Hourly(Rnd.Next(0, 59)),
|
() => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 4)),
|
||||||
// RecurringJobOptions);
|
RecurringJobOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
#region StatsTasks
|
#region StatsTasks
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Company>kavitareader.com</Company>
|
<Company>kavitareader.com</Company>
|
||||||
<Product>Kavita</Product>
|
<Product>Kavita</Product>
|
||||||
<AssemblyVersion>0.8.0.1</AssemblyVersion>
|
<AssemblyVersion>0.7.14.1</AssemblyVersion>
|
||||||
<NeutralLanguage>en</NeutralLanguage>
|
<NeutralLanguage>en</NeutralLanguage>
|
||||||
<TieredPGO>true</TieredPGO>
|
<TieredPGO>true</TieredPGO>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@ -19,5 +19,6 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="xunit.assert" Version="2.6.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
@ -552,11 +552,9 @@ export class ActionService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all chapters and the volumes as Read. All volumes and chapters must belong to a series
|
* Deletes all series
|
||||||
* @param seriesId Series Id
|
* @param seriesIds - List of series
|
||||||
* @param volumes Volumes, should have id, chapters and pagesRead populated
|
* @param callback - Optional callback once complete
|
||||||
* @param chapters? Chapters, should have id
|
|
||||||
* @param callback Optional callback to perform actions after API completes
|
|
||||||
*/
|
*/
|
||||||
async deleteMultipleSeries(seriesIds: Array<Series>, callback?: BooleanActionCallback) {
|
async deleteMultipleSeries(seriesIds: Array<Series>, callback?: BooleanActionCallback) {
|
||||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-series', {count: seriesIds.length}))) {
|
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-series', {count: seriesIds.length}))) {
|
||||||
@ -565,11 +563,15 @@ export class ActionService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.seriesService.deleteMultipleSeries(seriesIds.map(s => s.id)).pipe(take(1)).subscribe(() => {
|
this.seriesService.deleteMultipleSeries(seriesIds.map(s => s.id)).pipe(take(1)).subscribe(res => {
|
||||||
|
if (res) {
|
||||||
this.toastr.success(translate('toasts.series-deleted'));
|
this.toastr.success(translate('toasts.series-deleted'));
|
||||||
|
} else {
|
||||||
|
this.toastr.error(translate('errors.generic'));
|
||||||
|
}
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback(true);
|
callback(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -584,7 +586,12 @@ export class ActionService implements OnDestroy {
|
|||||||
|
|
||||||
this.seriesService.delete(series.id).subscribe((res: boolean) => {
|
this.seriesService.delete(series.id).subscribe((res: boolean) => {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
|
if (res) {
|
||||||
this.toastr.success(translate('toasts.series-deleted'));
|
this.toastr.success(translate('toasts.series-deleted'));
|
||||||
|
} else {
|
||||||
|
this.toastr.error(translate('errors.generic'));
|
||||||
|
}
|
||||||
|
|
||||||
callback(res);
|
callback(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -80,11 +80,11 @@ export class SeriesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(seriesId: number) {
|
delete(seriesId: number) {
|
||||||
return this.httpClient.delete<boolean>(this.baseUrl + 'series/' + seriesId);
|
return this.httpClient.delete<string>(this.baseUrl + 'series/' + seriesId, TextResonse).pipe(map(s => s === "true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteMultipleSeries(seriesIds: Array<number>) {
|
deleteMultipleSeries(seriesIds: Array<number>) {
|
||||||
return this.httpClient.post<boolean>(this.baseUrl + 'series/delete-multiple', {seriesIds});
|
return this.httpClient.post<string>(this.baseUrl + 'series/delete-multiple', {seriesIds}, TextResonse).pipe(map(s => s === "true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRating(seriesId: number, userRating: number) {
|
updateRating(seriesId: number, userRating: number) {
|
||||||
|
@ -15,8 +15,6 @@ import {NgForOf, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
|||||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||||
import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
|
import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
|
||||||
import {filter} from "rxjs/operators";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manage-email-settings',
|
selector: 'app-manage-email-settings',
|
||||||
|
@ -8,8 +8,8 @@
|
|||||||
<i *ngIf="iconClasses !== ''" class="{{iconClasses}} title-icon ms-1" aria-hidden="true"></i>
|
<i *ngIf="iconClasses !== ''" class="{{iconClasses}} title-icon ms-1" aria-hidden="true"></i>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="float-end" *ngIf="swiper">
|
<div class="float-end" *ngIf="swiper">
|
||||||
<button class="btn btn-icon" [disabled]="swiper.isBeginning" (click)="prevPage()"><i class="fa fa-angle-left" aria-hidden="true"></i><span class="visually-hidden">{{t('prev-items')}}</span></button>
|
<button class="btn btn-icon carousel-btn" [disabled]="swiper.isBeginning" (click)="prevPage()"><i class="fa fa-angle-left" aria-hidden="true"></i><span class="visually-hidden">{{t('prev-items')}}</span></button>
|
||||||
<button class="btn btn-icon" [disabled]="swiper.isEnd" (click)="nextPage()"><i class="fa fa-angle-right" aria-hidden="true"></i><span class="visually-hidden">{{t('next-items')}}</span></button>
|
<button class="btn btn-icon carousel-btn" [disabled]="swiper.isEnd" (click)="nextPage()"><i class="fa fa-angle-right" aria-hidden="true"></i><span class="visually-hidden">{{t('next-items')}}</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (items.length > 0) {
|
@if (items.length > 0) {
|
||||||
|
@ -21,6 +21,10 @@
|
|||||||
.non-selectable {
|
.non-selectable {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.carousel-btn > i {
|
||||||
|
color: var(--carousel-btn-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,11 +3,15 @@
|
|||||||
padding-right: 0px;
|
padding-right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
color: var(--badge-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
.sm-popover {
|
.sm-popover {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
|
|
||||||
> .popover-body {
|
> .popover-body {
|
||||||
padding-top: 0px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -15,7 +19,7 @@
|
|||||||
width: 214px;
|
width: 214px;
|
||||||
|
|
||||||
> .popover-body {
|
> .popover-body {
|
||||||
padding-top: 0px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,7 +27,7 @@
|
|||||||
width: 320px;
|
width: 320px;
|
||||||
|
|
||||||
> .popover-body {
|
> .popover-body {
|
||||||
padding-top: 0px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
tap,
|
tap,
|
||||||
finalize,
|
finalize,
|
||||||
of,
|
of,
|
||||||
filter, Subject,
|
filter,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { download, Download } from '../_models/download';
|
import { download, Download } from '../_models/download';
|
||||||
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
|
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
|
||||||
@ -71,6 +71,10 @@ export class DownloadService {
|
|||||||
* Size in bytes in which to inform the user for confirmation before download starts. Defaults to 100 MB.
|
* Size in bytes in which to inform the user for confirmation before download starts. Defaults to 100 MB.
|
||||||
*/
|
*/
|
||||||
public SIZE_WARNING = 104_857_600;
|
public SIZE_WARNING = 104_857_600;
|
||||||
|
/**
|
||||||
|
* Sie in bytes in which to inform the user that anything above may fail on iOS due to device limits. (200MB)
|
||||||
|
*/
|
||||||
|
private IOS_SIZE_WARNING = 209_715_200;
|
||||||
|
|
||||||
private downloadsSource: BehaviorSubject<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
|
private downloadsSource: BehaviorSubject<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
|
||||||
/**
|
/**
|
||||||
@ -290,41 +294,18 @@ export class DownloadService {
|
|||||||
|
|
||||||
private downloadChapter(chapter: Chapter) {
|
private downloadChapter(chapter: Chapter) {
|
||||||
return this.downloadEntity(chapter);
|
return this.downloadEntity(chapter);
|
||||||
|
|
||||||
// const downloadType = 'chapter';
|
|
||||||
// const subtitle = this.downloadSubtitle(downloadType, chapter);
|
|
||||||
// return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
|
||||||
// {observe: 'events', responseType: 'blob', reportProgress: true}
|
|
||||||
// ).pipe(
|
|
||||||
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
|
||||||
// download((blob, filename) => {
|
|
||||||
// this.save(blob, decodeURIComponent(filename));
|
|
||||||
// }),
|
|
||||||
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)),
|
|
||||||
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private downloadVolume(volume: Volume) {
|
private downloadVolume(volume: Volume) {
|
||||||
return this.downloadEntity(volume);
|
return this.downloadEntity(volume);
|
||||||
// const downloadType = 'volume';
|
|
||||||
// const subtitle = this.downloadSubtitle(downloadType, volume);
|
|
||||||
// return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
|
|
||||||
// {observe: 'events', responseType: 'blob', reportProgress: true}
|
|
||||||
// ).pipe(
|
|
||||||
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
|
||||||
// download((blob, filename) => {
|
|
||||||
// this.save(blob, decodeURIComponent(filename));
|
|
||||||
// }),
|
|
||||||
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)),
|
|
||||||
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async confirmSize(size: number, entityType: DownloadEntityType) {
|
private async confirmSize(size: number, entityType: DownloadEntityType) {
|
||||||
|
const showIosWarning = size > this.IOS_SIZE_WARNING && /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||||
return (size < this.SIZE_WARNING ||
|
return (size < this.SIZE_WARNING ||
|
||||||
await this.confirmService.confirm(translate('toasts.confirm-download-size',
|
await this.confirmService.confirm(translate('toasts.confirm-download-size',
|
||||||
{entityType: translate('entity-type.' + entityType), size: bytesPipe.transform(size)})));
|
{entityType: translate('entity-type.' + entityType), size: bytesPipe.transform(size)})
|
||||||
|
+ (!showIosWarning ? '' : '<br/><br/>' + translate('toasts.confirm-download-size-ios'))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private downloadBookmarks(bookmarks: PageBookmark[]) {
|
private downloadBookmarks(bookmarks: PageBookmark[]) {
|
||||||
|
@ -100,7 +100,7 @@
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--side-nav-color);
|
color: var(--side-nav-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
|
@ -2060,6 +2060,7 @@
|
|||||||
"confirm-library-delete": "Are you sure you want to delete the {{name}} library? You cannot undo this action.",
|
"confirm-library-delete": "Are you sure you want to delete the {{name}} library? You cannot undo this action.",
|
||||||
"confirm-library-type-change": "Changing library type will trigger a new scan with different parsing rules and may lead to series being re-created and hence you may loose progress and bookmarks. You should backup before you do this. Are you sure you want to continue?",
|
"confirm-library-type-change": "Changing library type will trigger a new scan with different parsing rules and may lead to series being re-created and hence you may loose progress and bookmarks. You should backup before you do this. Are you sure you want to continue?",
|
||||||
"confirm-download-size": "The {{entityType}} is {{size}}. Are you sure you want to continue?",
|
"confirm-download-size": "The {{entityType}} is {{size}}. Are you sure you want to continue?",
|
||||||
|
"confirm-download-size-ios": "iOS has issues downloading files that are larger than 200MB, this download might not complete.",
|
||||||
"list-doesnt-exist": "This list doesn't exist",
|
"list-doesnt-exist": "This list doesn't exist",
|
||||||
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
|
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
|
||||||
"smart-filter-deleted": "Smart Filter Deleted",
|
"smart-filter-deleted": "Smart Filter Deleted",
|
||||||
|
@ -110,7 +110,7 @@
|
|||||||
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
|
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
|
||||||
--side-nav-hover-text-color: white;
|
--side-nav-hover-text-color: white;
|
||||||
--side-nav-hover-bg-color: black;
|
--side-nav-hover-bg-color: black;
|
||||||
--side-nav-color: white;
|
--side-nav-text-color: white;
|
||||||
--side-nav-border-radius: 5px;
|
--side-nav-border-radius: 5px;
|
||||||
--side-nav-border: none;
|
--side-nav-border: none;
|
||||||
--side-nav-border-closed: none;
|
--side-nav-border-closed: none;
|
||||||
@ -228,6 +228,7 @@
|
|||||||
--carousel-header-text-color: var(--body-text-color);
|
--carousel-header-text-color: var(--body-text-color);
|
||||||
--carousel-header-text-decoration: none;
|
--carousel-header-text-decoration: none;
|
||||||
--carousel-hover-header-text-decoration: none;
|
--carousel-hover-header-text-decoration: none;
|
||||||
|
--carousel-btn-color: var(--body-text-color);
|
||||||
|
|
||||||
/** Drawer */
|
/** Drawer */
|
||||||
--drawer-bg-color: #292929;
|
--drawer-bg-color: #292929;
|
||||||
@ -259,4 +260,7 @@
|
|||||||
/** Rating Star Color **/
|
/** Rating Star Color **/
|
||||||
--rating-star-color: var(--primary-color);
|
--rating-star-color: var(--primary-color);
|
||||||
|
|
||||||
}
|
/** Badge **/
|
||||||
|
--badge-text-color: var(--bs-badge-color);
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -72,7 +72,7 @@
|
|||||||
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
|
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
|
||||||
--side-nav-hover-text-color: white;
|
--side-nav-hover-text-color: white;
|
||||||
--side-nav-hover-bg-color: black;
|
--side-nav-hover-bg-color: black;
|
||||||
--side-nav-color: black;
|
--side-nav-text-color: black;
|
||||||
--side-nav-border-radius: 5px;
|
--side-nav-border-radius: 5px;
|
||||||
--side-nav-border: none;
|
--side-nav-border: none;
|
||||||
--side-nav-border-closed: none;
|
--side-nav-border-closed: none;
|
||||||
|
@ -100,7 +100,7 @@
|
|||||||
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
|
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
|
||||||
--side-nav-hover-text-color: white;
|
--side-nav-hover-text-color: white;
|
||||||
--side-nav-hover-bg-color: black;
|
--side-nav-hover-bg-color: black;
|
||||||
--side-nav-color: white;
|
--side-nav-text-color: white;
|
||||||
--side-nav-border-radius: 5px;
|
--side-nav-border-radius: 5px;
|
||||||
--side-nav-border: none;
|
--side-nav-border: none;
|
||||||
--side-nav-border-closed: none;
|
--side-nav-border-closed: none;
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"version": "0.8.0.0"
|
"version": "0.7.14.1"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -8001,10 +8001,12 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Series"
|
"Series"
|
||||||
],
|
],
|
||||||
|
"summary": "Deletes a series from Kavita",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "seriesId",
|
"name": "seriesId",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
|
"description": "",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@ -14983,9 +14985,6 @@
|
|||||||
"format": "int32",
|
"format": "int32",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
"series": {
|
|
||||||
"$ref": "#/components/schemas/Series"
|
|
||||||
},
|
|
||||||
"externalSeriesMetadatas": {
|
"externalSeriesMetadatas": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user