MAL Interest Stacks (#2932)

This commit is contained in:
Joe Milazzo 2024-05-04 15:23:58 -05:00 committed by GitHub
parent 29eb65c783
commit b23300b1a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 4104 additions and 382 deletions

View File

@ -11,8 +11,8 @@
<PackageReference Include="NSubstitute" Version="5.1.0" /> <PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" />
<PackageReference Include="xunit" Version="2.7.1" /> <PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8"> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>

View File

@ -53,7 +53,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="31.0.3" /> <PackageReference Include="CsvHelper" Version="32.0.1" />
<PackageReference Include="MailKit" Version="4.5.0" /> <PackageReference Include="MailKit" Version="4.5.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@ -66,10 +66,10 @@
<PackageReference Include="Flurl" Version="3.0.7" /> <PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" /> <PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.12" /> <PackageReference Include="Hangfire" Version="1.8.12" />
<PackageReference Include="Hangfire.InMemory" Version="0.8.1" /> <PackageReference Include="Hangfire.InMemory" Version="0.9.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" /> <PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" /> <PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.60" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" /> <PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.12" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.12" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
@ -93,14 +93,14 @@
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" /> <PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.36.0" /> <PackageReference Include="SharpCompress" Version="0.37.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.23.2.88755"> <PackageReference Include="SonarAnalyzer.CSharp" Version="9.24.0.89429">
<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="Swashbuckle.AspNetCore" Version="6.5.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.2" /> <PackageReference Include="System.IO.Abstractions" Version="21.0.2" />
<PackageReference Include="System.Drawing.Common" Version="8.0.4" /> <PackageReference Include="System.Drawing.Common" Version="8.0.4" />
@ -194,6 +194,7 @@
<Content Include="EmailTemplates\**"> <Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Folder Include="Extensions\KavitaPlus\" />
<None Include="I18N\**" /> <None Include="I18N\**" />
</ItemGroup> </ItemGroup>

View File

@ -898,8 +898,6 @@ public class AccountController : BaseApiController
{ {
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (!settings.IsEmailSetup()) return Ok(await _localizationService.Get("en", "email-not-enabled"));
var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email);
if (user == null) if (user == null)
{ {
@ -914,11 +912,7 @@ public class AccountController : BaseApiController
if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed) if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed)
return BadRequest(await _localizationService.Translate(user.Id, "confirm-email")); return BadRequest(await _localizationService.Translate(user.Id, "confirm-email"));
if (!_emailService.IsValidEmail(user.Email))
{
_logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send. Admin must change it in UI", user.Email);
return Ok(await _localizationService.Translate(user.Id, "invalid-email"));
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user); var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var emailLink = await _emailService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email); var emailLink = await _emailService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email);
@ -927,6 +921,13 @@ public class AccountController : BaseApiController
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
_logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
if (!settings.IsEmailSetup()) return Ok(await _localizationService.Get("en", "email-not-enabled"));
if (!_emailService.IsValidEmail(user.Email))
{
_logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send. Admin must change it in UI or from url above", user.Email);
return Ok(await _localizationService.Translate(user.Id, "invalid-email"));
}
var installId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value; var installId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value;
BackgroundJob.Enqueue(() => _emailService.SendForgotPasswordEmail(new PasswordResetEmailDto() BackgroundJob.Enqueue(() => _emailService.SendForgotPasswordEmail(new PasswordResetEmailDto()
{ {

View File

@ -12,8 +12,11 @@ using API.Extensions;
using API.Helpers.Builders; using API.Helpers.Builders;
using API.Services; using API.Services;
using API.Services.Plus; using API.Services.Plus;
using API.SignalR;
using Hangfire;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers; namespace API.Controllers;
@ -28,15 +31,23 @@ public class CollectionController : BaseApiController
private readonly ICollectionTagService _collectionService; private readonly ICollectionTagService _collectionService;
private readonly ILocalizationService _localizationService; private readonly ILocalizationService _localizationService;
private readonly IExternalMetadataService _externalMetadataService; private readonly IExternalMetadataService _externalMetadataService;
private readonly ISmartCollectionSyncService _collectionSyncService;
private readonly ILogger<CollectionController> _logger;
private readonly IEventHub _eventHub;
/// <inheritdoc /> /// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService, public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
ILocalizationService localizationService, IExternalMetadataService externalMetadataService) ILocalizationService localizationService, IExternalMetadataService externalMetadataService,
ISmartCollectionSyncService collectionSyncService, ILogger<CollectionController> logger,
IEventHub eventHub)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_collectionService = collectionService; _collectionService = collectionService;
_localizationService = localizationService; _localizationService = localizationService;
_externalMetadataService = externalMetadataService; _externalMetadataService = externalMetadataService;
_collectionSyncService = collectionSyncService;
_logger = logger;
_eventHub = eventHub;
} }
/// <summary> /// <summary>
@ -49,6 +60,18 @@ public class CollectionController : BaseApiController
return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), !ownedOnly)); return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), !ownedOnly));
} }
/// <summary>
/// Returns a single Collection tag by Id for a given user
/// </summary>
/// <param name="collectionId"></param>
/// <returns></returns>
[HttpGet("single")]
public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetTag(int collectionId)
{
var collections = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), false);
return Ok(collections.FirstOrDefault(c => c.Id == collectionId));
}
/// <summary> /// <summary>
/// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned) /// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned)
/// </summary> /// </summary>
@ -86,6 +109,8 @@ public class CollectionController : BaseApiController
{ {
if (await _collectionService.UpdateTag(updatedTag, User.GetUserId())) if (await _collectionService.UpdateTag(updatedTag, User.GetUserId()))
{ {
await _eventHub.SendMessageAsync(MessageFactory.CollectionUpdated,
MessageFactory.CollectionUpdatedEvent(updatedTag.Id), false);
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully")); return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
} }
} }
@ -129,12 +154,12 @@ public class CollectionController : BaseApiController
/// <summary> /// <summary>
/// Promote/UnPromote multiple collections in one go /// Delete multiple collections in one go
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("delete-multiple")] [HttpPost("delete-multiple")]
public async Task<ActionResult> DeleteMultipleCollections(PromoteCollectionsDto dto) public async Task<ActionResult> DeleteMultipleCollections(DeleteCollectionsDto dto)
{ {
// This needs to take into account owner as I can select other users cards // This needs to take into account owner as I can select other users cards
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
@ -178,7 +203,7 @@ public class CollectionController : BaseApiController
return BadRequest(_localizationService.Translate(User.GetUserId(), "collection-doesnt-exists")); return BadRequest(_localizationService.Translate(User.GetUserId(), "collection-doesnt-exists"));
} }
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList()); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList(), false);
foreach (var s in series) foreach (var s in series)
{ {
if (tag.Items.Contains(s)) continue; if (tag.Items.Contains(s)) continue;
@ -253,4 +278,45 @@ public class CollectionController : BaseApiController
{ {
return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId())); return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId()));
} }
/// <summary>
/// Imports a MAL Stack into Kavita
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("import-stack")]
public async Task<ActionResult> ImportMalStack(MalStackDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
if (user == null) return Unauthorized();
// Validation check to ensure stack doesn't exist already
if (await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, user.Id))
{
return BadRequest(_localizationService.Translate(user.Id, "collection-already-exists"));
}
try
{
// Create new collection
var newCollection = new AppUserCollectionBuilder(dto.Title)
.WithSource(ScrobbleProvider.Mal)
.WithSourceUrl(dto.Url)
.Build();
user.Collections.Add(newCollection);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
// Trigger Stack Refresh for just one stack (not all)
BackgroundJob.Enqueue(() => _collectionSyncService.Sync(newCollection.Id));
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue importing MAL Stack");
}
return BadRequest(_localizationService.Translate(user.Id, "error-import-stack"));
}
} }

View File

@ -36,4 +36,12 @@ public class AppUserCollectionDto
/// For Non-Kavita sourced collections, the url to sync from /// For Non-Kavita sourced collections, the url to sync from
/// </summary> /// </summary>
public string? SourceUrl { get; set; } public string? SourceUrl { get; set; }
/// <summary>
/// Total number of items as of the last sync. Not applicable for Kavita managed collections.
/// </summary>
public int TotalSourceCount { get; set; }
/// <summary>
/// A <br/> separated string of all missing series
/// </summary>
public string? MissingSeriesFromSource { get; set; }
} }

View File

@ -1,8 +1,10 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Collection; namespace API.DTOs.Collection;
public class DeleteCollectionsDto public class DeleteCollectionsDto
{ {
[Required]
public IList<int> CollectionIds { get; set; } public IList<int> CollectionIds { get; set; }
} }

View File

@ -1,5 +1,8 @@
namespace API.DTOs.CollectionTags; using System;
namespace API.DTOs.CollectionTags;
[Obsolete("Use AppUserCollectionDto")]
public class CollectionTagDto public class CollectionTagDto
{ {
public int Id { get; set; } public int Id { get; set; }

View File

@ -1,9 +1,11 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using API.DTOs.Collection;
namespace API.DTOs.CollectionTags; namespace API.DTOs.CollectionTags;
public class UpdateSeriesForTagDto public class UpdateSeriesForTagDto
{ {
public CollectionTagDto Tag { get; init; } = default!; public AppUserCollectionDto Tag { get; init; } = default!;
public IEnumerable<int> SeriesIdsToRemove { get; init; } = default!; public IEnumerable<int> SeriesIdsToRemove { get; init; } = default!;
} }

View File

@ -36,5 +36,6 @@ public class UpdateLibraryDto
/// <summary> /// <summary>
/// A set of Glob patterns that the scanner will exclude processing /// A set of Glob patterns that the scanner will exclude processing
/// </summary> /// </summary>
[Required]
public ICollection<string> ExcludePatterns { get; init; } public ICollection<string> ExcludePatterns { get; init; }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class SmartCollectionFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "MissingSeriesFromSource",
table: "AppUserCollection",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TotalSourceCount",
table: "AppUserCollection",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MissingSeriesFromSource",
table: "AppUserCollection");
migrationBuilder.DropColumn(
name: "TotalSourceCount",
table: "AppUserCollection");
}
}
}

View File

@ -224,6 +224,9 @@ namespace API.Data.Migrations
b.Property<DateTime>("LastSyncUtc") b.Property<DateTime>("LastSyncUtc")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("MissingSeriesFromSource")
.HasColumnType("TEXT");
b.Property<string>("NormalizedTitle") b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -242,6 +245,9 @@ namespace API.Data.Migrations
b.Property<string>("Title") b.Property<string>("Title")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("TotalSourceCount")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");

View File

@ -9,6 +9,7 @@ using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering; using API.Extensions.QueryExtensions.Filtering;
using API.Services.Plus;
using AutoMapper; using AutoMapper;
using AutoMapper.QueryableExtensions; using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -57,6 +58,7 @@ public interface ICollectionTagRepository
Task<IList<AppUserCollection>> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None); Task<IList<AppUserCollection>> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None);
Task UpdateCollectionAgeRating(AppUserCollection tag); Task UpdateCollectionAgeRating(AppUserCollection tag);
Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None); Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None);
Task<IList<AppUserCollection>> GetAllCollectionsForSyncing(DateTime expirationTime);
} }
public class CollectionTagRepository : ICollectionTagRepository public class CollectionTagRepository : ICollectionTagRepository
{ {
@ -207,6 +209,16 @@ public class CollectionTagRepository : ICollectionTagRepository
.ToListAsync(); .ToListAsync();
} }
public async Task<IList<AppUserCollection>> GetAllCollectionsForSyncing(DateTime expirationTime)
{
return await _context.AppUserCollection
.Where(c => c.Source == ScrobbleProvider.Mal)
.Where(c => c.LastSyncUtc <= expirationTime)
.Include(c => c.Items)
.AsSplitQuery()
.ToListAsync();
}
public async Task<AppUserCollection?> GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None) public async Task<AppUserCollection?> GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None)
{ {

View File

@ -73,6 +73,7 @@ public interface ISeriesRepository
void Update(Series series); void Update(Series series);
void Remove(Series series); void Remove(Series series);
void Remove(IEnumerable<Series> series); void Remove(IEnumerable<Series> series);
void Detach(Series series);
Task<bool> DoesSeriesNameExistInLibrary(string name, int libraryId, MangaFormat format); Task<bool> DoesSeriesNameExistInLibrary(string name, int libraryId, MangaFormat format);
/// <summary> /// <summary>
/// Adds user information like progress, ratings, etc /// Adds user information like progress, ratings, etc
@ -96,7 +97,7 @@ public interface ISeriesRepository
Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId); Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId);
Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata); Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
Task<IList<SeriesDto>> GetSeriesDtoByIdsAsync(IEnumerable<int> seriesIds, AppUser user); Task<IList<SeriesDto>> GetSeriesDtoByIdsAsync(IEnumerable<int> seriesIds, AppUser user);
Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds); Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds, bool fullSeries = true);
Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds); Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds);
Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds); Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
/// <summary> /// <summary>
@ -138,6 +139,7 @@ public interface ISeriesRepository
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames, Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
int userId, SeriesIncludes includes = SeriesIncludes.None); int userId, SeriesIncludes includes = SeriesIncludes.None);
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats, int userId);
public Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId, public Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId,
MangaFormat format); MangaFormat format);
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId); Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
@ -204,6 +206,11 @@ public class SeriesRepository : ISeriesRepository
_context.Series.RemoveRange(series); _context.Series.RemoveRange(series);
} }
public void Detach(Series series)
{
_context.Entry(series).State = EntityState.Detached;
}
/// <summary> /// <summary>
/// Returns if a series name and format exists already in a library /// Returns if a series name and format exists already in a library
/// </summary> /// </summary>
@ -531,15 +538,19 @@ public class SeriesRepository : ISeriesRepository
/// Returns Full Series including all external links /// Returns Full Series including all external links
/// </summary> /// </summary>
/// <param name="seriesIds"></param> /// <param name="seriesIds"></param>
/// <param name="fullSeries">Include all the includes or just the Series</param>
/// <returns></returns> /// <returns></returns>
public async Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds) public async Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds, bool fullSeries = true)
{ {
return await _context.Series var query = _context.Series
.Include(s => s.Volumes) .Where(s => seriesIds.Contains(s.Id))
.AsSplitQuery();
if (!fullSeries) return await query.ToListAsync();
return await query.Include(s => s.Volumes)
.Include(s => s.Relations) .Include(s => s.Relations)
.Include(s => s.Metadata) .Include(s => s.Metadata)
.ThenInclude(m => m.CollectionTags)
.Include(s => s.ExternalSeriesMetadata) .Include(s => s.ExternalSeriesMetadata)
@ -549,9 +560,6 @@ public class SeriesRepository : ISeriesRepository
.ThenInclude(e => e.ExternalReviews) .ThenInclude(e => e.ExternalReviews)
.Include(s => s.ExternalSeriesMetadata) .Include(s => s.ExternalSeriesMetadata)
.ThenInclude(e => e.ExternalRecommendations) .ThenInclude(e => e.ExternalRecommendations)
.Where(s => seriesIds.Contains(s.Id))
.AsSplitQuery()
.ToListAsync(); .ToListAsync();
} }
@ -1670,6 +1678,26 @@ public class SeriesRepository : ISeriesRepository
#nullable enable #nullable enable
} }
public async Task<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> formats, int userId)
{
var libraryIds = GetLibraryIdsForUser(userId);
var normalizedSeries = seriesName.ToNormalized();
var normalizedLocalized = localizedName.ToNormalized();
return await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => formats.Contains(s.Format))
.Where(s =>
s.NormalizedName.Equals(normalizedSeries)
|| s.NormalizedName.Equals(normalizedLocalized)
|| s.NormalizedLocalizedName.Equals(normalizedSeries)
|| (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized))
|| (s.OriginalName != null && s.OriginalName.Equals(seriesName))
)
.FirstOrDefaultAsync();
}
public async Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId, public async Task<IList<Series>> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId,
MangaFormat format) MangaFormat format)
{ {

View File

@ -52,7 +52,14 @@ public class AppUserCollection : IEntityDate
/// For Non-Kavita sourced collections, the url to sync from /// For Non-Kavita sourced collections, the url to sync from
/// </summary> /// </summary>
public string? SourceUrl { get; set; } public string? SourceUrl { get; set; }
/// <summary>
/// Total number of items as of the last sync. Not applicable for Kavita managed collections.
/// </summary>
public int TotalSourceCount { get; set; }
/// <summary>
/// A <br/> separated string of all missing series
/// </summary>
public string? MissingSeriesFromSource { get; set; }
// Relationship // Relationship
public AppUser AppUser { get; set; } = null!; public AppUser AppUser { get; set; } = null!;

View File

@ -74,6 +74,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IScrobblingService, ScrobblingService>(); services.AddScoped<IScrobblingService, ScrobblingService>();
services.AddScoped<ILicenseService, LicenseService>(); services.AddScoped<ILicenseService, LicenseService>();
services.AddScoped<IExternalMetadataService, ExternalMetadataService>(); services.AddScoped<IExternalMetadataService, ExternalMetadataService>();
services.AddScoped<ISmartCollectionSyncService, SmartCollectionSyncService>();
services.AddSqLite(); services.AddSqLite();
services.AddSignalR(opt => opt.EnableDetailedErrors = true); services.AddSignalR(opt => opt.EnableDetailedErrors = true);

View File

@ -69,4 +69,10 @@ public class AppUserCollectionBuilder : IEntityBuilder<AppUserCollection>
_collection.CoverImage = cover; _collection.CoverImage = cover;
return this; return this;
} }
public AppUserCollectionBuilder WithSourceUrl(string url)
{
_collection.SourceUrl = url;
return this;
}
} }

View File

@ -9,11 +9,16 @@ public static class LibraryTypeHelper
{ {
public static MediaFormat GetFormat(LibraryType libraryType) public static MediaFormat GetFormat(LibraryType libraryType)
{ {
// TODO: Refactor this to an extension on LibraryType
return libraryType switch return libraryType switch
{ {
LibraryType.Manga => MediaFormat.Manga, LibraryType.Manga => MediaFormat.Manga,
LibraryType.Comic => MediaFormat.Comic, LibraryType.Comic => MediaFormat.Comic,
LibraryType.LightNovel => MediaFormat.LightNovel, LibraryType.LightNovel => MediaFormat.LightNovel,
LibraryType.Book => MediaFormat.LightNovel,
LibraryType.Image => MediaFormat.Manga,
LibraryType.ComicVine => MediaFormat.Comic,
_ => throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null)
}; };
} }
} }

View File

@ -49,6 +49,8 @@
"collection-deleted": "Collection deleted", "collection-deleted": "Collection deleted",
"generic-error": "Something went wrong, please try again", "generic-error": "Something went wrong, please try again",
"collection-doesnt-exist": "Collection does not exist", "collection-doesnt-exist": "Collection does not exist",
"collection-already-exists":"Collection already exists",
"error-import-stack": "There was an issue importing MAL stack",
"device-doesnt-exist": "Device does not exist", "device-doesnt-exist": "Device does not exist",
"generic-device-create": "There was an error when creating the device", "generic-device-create": "There was an error when creating the device",

View File

@ -82,7 +82,7 @@ public class ExternalMetadataService : IExternalMetadataService
Reviews = ArraySegment<UserReviewDto>.Empty Reviews = ArraySegment<UserReviewDto>.Empty
}; };
// Allow 50 requests per 24 hours // Allow 50 requests per 24 hours
private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(12), false); private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false);
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper, ILicenseService licenseService) public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper, ILicenseService licenseService)
{ {

View File

@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Scrobbling;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.SignalR;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.Extensions.Logging;
namespace API.Services.Plus;
#nullable enable
sealed class SeriesCollection
{
public required IList<ExternalMetadataIdsDto> Series { get; set; }
public required string Summary { get; set; }
public required string Title { get; set; }
/// <summary>
/// Total items in the source, not what was matched
/// </summary>
public int TotalItems { get; set; }
}
/// <summary>
/// Responsible to synchronize Collection series from non-Kavita sources
/// </summary>
public interface ISmartCollectionSyncService
{
/// <summary>
/// Synchronize all collections
/// </summary>
/// <returns></returns>
Task Sync();
/// <summary>
/// Synchronize a collection
/// </summary>
/// <param name="collectionId"></param>
/// <returns></returns>
Task Sync(int collectionId);
}
public class SmartCollectionSyncService : ISmartCollectionSyncService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<SmartCollectionSyncService> _logger;
private readonly IEventHub _eventHub;
private readonly ILicenseService _licenseService;
private const int SyncDelta = -2;
// Allow 50 requests per 24 hours
private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false);
public SmartCollectionSyncService(IUnitOfWork unitOfWork, ILogger<SmartCollectionSyncService> logger,
IEventHub eventHub, ILicenseService licenseService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_eventHub = eventHub;
_licenseService = licenseService;
}
/// <summary>
/// For every Sync-eligible collection, synchronize with upstream
/// </summary>
/// <returns></returns>
public async Task Sync()
{
if (!await _licenseService.HasActiveLicense()) return;
var expirationTime = DateTime.UtcNow.AddDays(SyncDelta).Truncate(TimeSpan.TicksPerHour);
var collections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsForSyncing(expirationTime))
.Where(CanSync)
.ToList();
_logger.LogInformation("Found {Count} collections to synchronize", collections.Count);
foreach (var collection in collections)
{
try
{
await SyncCollection(collection);
}
catch (RateLimitException)
{
break;
}
}
_logger.LogInformation("Synchronization complete");
}
public async Task Sync(int collectionId)
{
if (!await _licenseService.HasActiveLicense()) return;
var collection = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId, CollectionIncludes.Series);
if (!CanSync(collection))
{
_logger.LogInformation("Requested to sync {CollectionName} but not applicable to sync", collection!.Title);
return;
}
try
{
await SyncCollection(collection!);
} catch (RateLimitException) {/* Swallow */}
}
private static bool CanSync(AppUserCollection? collection)
{
if (collection is not {Source: ScrobbleProvider.Mal}) return false;
if (string.IsNullOrEmpty(collection.SourceUrl)) return false;
if (collection.LastSyncUtc.Truncate(TimeSpan.TicksPerHour) >= DateTime.UtcNow.AddDays(SyncDelta).Truncate(TimeSpan.TicksPerHour)) return false;
return true;
}
private async Task SyncCollection(AppUserCollection collection)
{
if (!RateLimiter.TryAcquire(string.Empty))
{
// Request not allowed due to rate limit
_logger.LogDebug("Rate Limit hit for Smart Collection Sync");
throw new RateLimitException();
}
var info = await GetStackInfo(GetStackId(collection.SourceUrl!));
if (info == null)
{
_logger.LogInformation("Unable to find collection through Kavita+");
return;
}
// Check each series in the collection against what's in the target
// For everything that's not there, link it up for this user.
_logger.LogInformation("Starting Sync on {CollectionName} with {SeriesCount} Series", info.Title, info.TotalItems);
var missingCount = 0;
var missingSeries = new StringBuilder();
foreach (var seriesInfo in info.Series.OrderBy(s => s.SeriesName))
{
try
{
// Normalize series name and localized name
var normalizedSeriesName = seriesInfo.SeriesName?.ToNormalized();
var normalizedLocalizedSeriesName = seriesInfo.LocalizedSeriesName?.ToNormalized();
// Search for existing series in the collection
var formats = GetMangaFormats(seriesInfo.PlusMediaFormat);
var existingSeries = collection.Items.FirstOrDefault(s =>
(s.Name.ToNormalized() == normalizedSeriesName ||
s.NormalizedName == normalizedSeriesName ||
s.LocalizedName.ToNormalized() == normalizedLocalizedSeriesName ||
s.NormalizedLocalizedName == normalizedLocalizedSeriesName ||
s.NormalizedName == normalizedLocalizedSeriesName ||
s.NormalizedLocalizedName == normalizedSeriesName)
&& formats.Contains(s.Format));
if (existingSeries != null) continue;
// Series not found in the collection, try to find it in the server
var newSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName(seriesInfo.SeriesName,
seriesInfo.LocalizedSeriesName,
formats, collection.AppUserId);
collection.Items ??= new List<Series>();
if (newSeries != null)
{
// Add the new series to the collection
collection.Items.Add(newSeries);
}
else
{
_logger.LogDebug("{Series} not found in the server", seriesInfo.SeriesName);
missingCount++;
missingSeries.Append(
$"<a href='{ScrobblingService.MalWeblinkWebsite}{seriesInfo.MalId}' target='_blank' rel='noopener noreferrer'>{seriesInfo.SeriesName}</a>");
missingSeries.Append("<br/>");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "An exception occured when linking up a series to the collection. Skipping");
missingCount++;
missingSeries.Append(
$"<a href='{ScrobblingService.MalWeblinkWebsite}{seriesInfo.MalId}' target='_blank' rel='noopener noreferrer'>{seriesInfo.SeriesName}</a>");
missingSeries.Append("<br/>");
}
}
// At this point, all series in the info have been checked and added if necessary
// You may want to commit changes to the database if needed
collection.LastSyncUtc = DateTime.UtcNow.Truncate(TimeSpan.TicksPerHour);
collection.TotalSourceCount = info.TotalItems;
collection.Summary = info.Summary;
collection.MissingSeriesFromSource = missingSeries.ToString();
_unitOfWork.CollectionTagRepository.Update(collection);
try
{
await _unitOfWork.CommitAsync();
await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collection);
await _eventHub.SendMessageAsync(MessageFactory.CollectionUpdated,
MessageFactory.CollectionUpdatedEvent(collection.Id), false);
_logger.LogInformation("Finished Syncing Collection {CollectionName} - Missing {MissingCount} series",
collection.Title, missingCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error during saving the collection");
}
}
private static IList<MangaFormat> GetMangaFormats(MediaFormat? mediaFormat)
{
if (mediaFormat == null) return [MangaFormat.Archive];
return mediaFormat switch
{
MediaFormat.Manga => [MangaFormat.Archive, MangaFormat.Image],
MediaFormat.Comic => [MangaFormat.Archive],
MediaFormat.LightNovel => [MangaFormat.Epub, MangaFormat.Pdf],
MediaFormat.Book => [MangaFormat.Epub, MangaFormat.Pdf],
MediaFormat.Unknown => [MangaFormat.Archive],
_ => [MangaFormat.Archive]
};
}
private static long GetStackId(string url)
{
var tokens = url.Split("/");
return long.Parse(tokens[^1], CultureInfo.InvariantCulture);
}
private async Task<SeriesCollection?> GetStackInfo(long stackId)
{
_logger.LogDebug("Fetching Kavita+ for MAL Stack");
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
var seriesForStack = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stack?stackId=" + stackId)
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-license-key", license)
.WithHeader("x-installId", HashUtil.ServerToken())
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
.GetJsonAsync<SeriesCollection>();
return seriesForStack;
}
}

View File

@ -57,6 +57,7 @@ public class TaskScheduler : ITaskScheduler
private readonly IScrobblingService _scrobblingService; private readonly IScrobblingService _scrobblingService;
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly IExternalMetadataService _externalMetadataService; private readonly IExternalMetadataService _externalMetadataService;
private readonly ISmartCollectionSyncService _smartCollectionSyncService;
public static BackgroundJobServer Client => new (); public static BackgroundJobServer Client => new ();
public const string ScanQueue = "scan"; public const string ScanQueue = "scan";
@ -74,6 +75,7 @@ public class TaskScheduler : ITaskScheduler
public const string ProcessProcessedScrobblingEventsId = "process-processed-scrobbling-events"; public const string ProcessProcessedScrobblingEventsId = "process-processed-scrobbling-events";
public const string LicenseCheckId = "license-check"; public const string LicenseCheckId = "license-check";
public const string KavitaPlusDataRefreshId = "kavita+-data-refresh"; public const string KavitaPlusDataRefreshId = "kavita+-data-refresh";
public const string KavitaPlusStackSyncId = "kavita+-stack-sync";
private static readonly ImmutableArray<string> ScanTasks = private static readonly ImmutableArray<string> ScanTasks =
["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"]; ["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"];
@ -91,7 +93,7 @@ public class TaskScheduler : ITaskScheduler
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService, IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService,
IExternalMetadataService externalMetadataService) IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService)
{ {
_cacheService = cacheService; _cacheService = cacheService;
_logger = logger; _logger = logger;
@ -109,6 +111,7 @@ public class TaskScheduler : ITaskScheduler
_scrobblingService = scrobblingService; _scrobblingService = scrobblingService;
_licenseService = licenseService; _licenseService = licenseService;
_externalMetadataService = externalMetadataService; _externalMetadataService = externalMetadataService;
_smartCollectionSyncService = smartCollectionSyncService;
} }
public async Task ScheduleTasks() public async Task ScheduleTasks()
@ -186,6 +189,10 @@ public class TaskScheduler : ITaskScheduler
RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId, RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId,
() => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 4)), () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 4)),
RecurringJobOptions); RecurringJobOptions);
RecurringJob.AddOrUpdate(KavitaPlusStackSyncId,
() => _smartCollectionSyncService.Sync(), Cron.Daily(Rnd.Next(1, 4)),
RecurringJobOptions);
} }
#region StatsTasks #region StatsTasks

View File

@ -32,7 +32,7 @@ public interface IProcessSeries
Task Prime(); Task Prime();
void Reset(); void Reset();
Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, bool forceUpdate = false); Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false);
} }
/// <summary> /// <summary>
@ -99,7 +99,7 @@ public class ProcessSeries : IProcessSeries
_tagManagerService.Reset(); _tagManagerService.Reset();
} }
public async Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, bool forceUpdate = false) public async Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false)
{ {
if (!parsedInfos.Any()) return; if (!parsedInfos.Any()) return;
@ -107,7 +107,7 @@ public class ProcessSeries : IProcessSeries
var scanWatch = Stopwatch.StartNew(); var scanWatch = Stopwatch.StartNew();
var seriesName = parsedInfos[0].Series; var seriesName = parsedInfos[0].Series;
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Updated, seriesName)); MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Updated, seriesName, totalToProcess));
_logger.LogInformation("[ScannerService] Beginning series update on {SeriesName}, Forced: {ForceUpdate}", seriesName, forceUpdate); _logger.LogInformation("[ScannerService] Beginning series update on {SeriesName}, Forced: {ForceUpdate}", seriesName, forceUpdate);
// Check if there is a Series // Check if there is a Series

View File

@ -147,7 +147,7 @@ public class ScannerService : IScannerService
{ {
if (ex.Message.Equals("Sequence contains more than one element.")) if (ex.Message.Equals("Sequence contains more than one element."))
{ {
_logger.LogCritical("[ScannerService] Multiple series map to this folder. Library scan will be used for ScanFolder"); _logger.LogCritical(ex, "[ScannerService] Multiple series map to this folder. Library scan will be used for ScanFolder");
} }
} }
@ -245,7 +245,7 @@ public class ScannerService : IScannerService
var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>(); var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name)); MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name, 1));
_logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name);
var (scanElapsedTime, processedSeries) = await ScanFiles(library, new []{ folderPath }, var (scanElapsedTime, processedSeries) = await ScanFiles(library, new []{ folderPath },
@ -309,15 +309,18 @@ public class ScannerService : IScannerService
await _processSeries.Prime(); await _processSeries.Prime();
} }
var seriesLeftToProcess = toProcess.Count;
foreach (var pSeries in toProcess) foreach (var pSeries in toProcess)
{ {
// Process Series // Process Series
await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, bypassFolderOptimizationChecks); await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks);
seriesLeftToProcess--;
} }
_processSeries.Reset(); _processSeries.Reset();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name)); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name, 0));
// Tell UI that this series is done // Tell UI that this series is done
await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, await _eventHub.SendMessageAsync(MessageFactory.ScanSeries,
MessageFactory.ScanSeriesEvent(library.Id, seriesId, series.Name)); MessageFactory.ScanSeriesEvent(library.Id, seriesId, series.Name));
@ -543,7 +546,8 @@ public class ScannerService : IScannerService
"[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan"); "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan");
} }
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, string.Empty)); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, string.Empty));
await _metadataService.RemoveAbandonedMetadataKeys(); await _metadataService.RemoveAbandonedMetadataKeys();
BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.CacheDirectory)); BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.CacheDirectory));
@ -589,12 +593,14 @@ public class ScannerService : IScannerService
var totalFiles = 0; var totalFiles = 0;
//var tasks = new List<Task>(); //var tasks = new List<Task>();
var seriesLeftToProcess = toProcess.Count;
foreach (var pSeries in toProcess) foreach (var pSeries in toProcess)
{ {
totalFiles += parsedSeries[pSeries].Count; totalFiles += parsedSeries[pSeries].Count;
//tasks.Add(_processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, forceUpdate)); //tasks.Add(_processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, forceUpdate));
// We can't do Task.WhenAll because of concurrency issues. // We can't do Task.WhenAll because of concurrency issues.
await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, forceUpdate); await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, forceUpdate);
seriesLeftToProcess--;
} }
//await Task.WhenAll(tasks); //await Task.WhenAll(tasks);

View File

@ -41,9 +41,9 @@ public static class MessageFactory
/// </summary> /// </summary>
public const string OnlineUsers = "OnlineUsers"; public const string OnlineUsers = "OnlineUsers";
/// <summary> /// <summary>
/// When a series is added to a collection /// When a Collection has been updated
/// </summary> /// </summary>
public const string SeriesAddedToCollection = "SeriesAddedToCollection"; public const string CollectionUpdated = "CollectionUpdated";
/// <summary> /// <summary>
/// Event sent out during backing up the database /// Event sent out during backing up the database
/// </summary> /// </summary>
@ -310,17 +310,17 @@ public static class MessageFactory
}; };
} }
public static SignalRMessage SeriesAddedToCollectionEvent(int tagId, int seriesId)
public static SignalRMessage CollectionUpdatedEvent(int collectionId)
{ {
return new SignalRMessage return new SignalRMessage
{ {
Name = SeriesAddedToCollection, Name = CollectionUpdated,
Progress = ProgressType.None, Progress = ProgressType.None,
EventType = ProgressEventType.Single, EventType = ProgressEventType.Single,
Body = new Body = new
{ {
TagId = tagId, TagId = collectionId,
SeriesId = seriesId
} }
}; };
} }
@ -428,7 +428,7 @@ public static class MessageFactory
/// <param name="eventType"></param> /// <param name="eventType"></param>
/// <param name="seriesName"></param> /// <param name="seriesName"></param>
/// <returns></returns> /// <returns></returns>
public static SignalRMessage LibraryScanProgressEvent(string libraryName, string eventType, string seriesName = "") public static SignalRMessage LibraryScanProgressEvent(string libraryName, string eventType, string seriesName = "", int? totalToProcess = null)
{ {
return new SignalRMessage() return new SignalRMessage()
{ {
@ -437,7 +437,12 @@ public static class MessageFactory
SubTitle = seriesName, SubTitle = seriesName,
EventType = eventType, EventType = eventType,
Progress = ProgressType.Indeterminate, Progress = ProgressType.Indeterminate,
Body = null Body = new
{
SeriesName = seriesName,
LibraryName = libraryName,
LeftToProcess = totalToProcess
}
}; };
} }

View File

@ -139,7 +139,7 @@ public class Startup
{ {
Version = BuildInfo.Version.ToString(), Version = BuildInfo.Version.ToString(),
Title = "Kavita", Title = "Kavita",
Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage.", Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required.",
License = new OpenApiLicense License = new OpenApiLicense
{ {
Name = "GPL-3.0", Name = "GPL-3.0",

View File

@ -14,10 +14,10 @@
<PackageReference Include="Flurl.Http" Version="3.2.4" /> <PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.23.2.88755"> <PackageReference Include="SonarAnalyzer.CSharp" Version="9.24.0.89429">
<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.7.1" /> <PackageReference Include="xunit.assert" Version="2.8.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,18 @@
using System;
namespace Kavita.Common;
/// <summary>
/// When a rate limit is hit
/// </summary>
public class RateLimitException : Exception
{
public RateLimitException()
{ }
public RateLimitException(string message) : base(message)
{ }
public RateLimitException(string message, Exception inner)
: base(message, inner) { }
}

View File

@ -24,11 +24,11 @@
"prefix": "app", "prefix": "app",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular-devkit/build-angular:browser-esbuild",
"options": { "options": {
"outputPath": "dist", "outputPath": "dist",
"index": "src/index.html", "index": "src/index.html",
"browser": "src/main.ts", "main": "src/main.ts",
"polyfills": [ "polyfills": [
"zone.js" "zone.js"
], ],

View File

@ -21,7 +21,7 @@
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.5.2",
"@iharbeck/ngx-virtual-scroller": "^17.0.2", "@iharbeck/ngx-virtual-scroller": "^17.0.2",
"@iplab/ngx-file-upload": "^17.1.0", "@iplab/ngx-file-upload": "^17.1.0",
"@microsoft/signalr": "^7.0.12", "@microsoft/signalr": "^8.0.0",
"@ng-bootstrap/ng-bootstrap": "^16.0.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0",
"@ngneat/transloco": "^6.0.4", "@ngneat/transloco": "^6.0.4",
"@ngneat/transloco-locale": "^5.1.2", "@ngneat/transloco-locale": "^5.1.2",
@ -504,7 +504,6 @@
"version": "17.3.4", "version": "17.3.4",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz",
"integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==", "integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==",
"dev": true,
"dependencies": { "dependencies": {
"@babel/core": "7.23.9", "@babel/core": "7.23.9",
"@jridgewell/sourcemap-codec": "^1.4.14", "@jridgewell/sourcemap-codec": "^1.4.14",
@ -532,7 +531,6 @@
"version": "7.23.9", "version": "7.23.9",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz",
"integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==",
"dev": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5", "@babel/code-frame": "^7.23.5",
@ -561,14 +559,12 @@
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
"dev": true
}, },
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
@ -749,7 +745,6 @@
"version": "7.24.0", "version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
"dev": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5", "@babel/code-frame": "^7.23.5",
@ -778,14 +773,12 @@
"node_modules/@babel/core/node_modules/convert-source-map": { "node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
"dev": true
}, },
"node_modules/@babel/core/node_modules/semver": { "node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
@ -3284,9 +3277,9 @@
} }
}, },
"node_modules/@microsoft/signalr": { "node_modules/@microsoft/signalr": {
"version": "7.0.14", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.14.tgz", "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.0.tgz",
"integrity": "sha512-dnS7gSJF5LxByZwJaj82+F1K755ya7ttPT+JnSeCBef3sL8p8FBkHePXphK8NSuOquIb7vsphXWa28A+L2SPpw==", "integrity": "sha512-K/wS/VmzRWePCGqGh8MU8OWbS1Zvu7DG7LSJS62fBB8rJUXwwj4axQtqrAAwKGUZHQF6CuteuQR9xMsVpM2JNA==",
"dependencies": { "dependencies": {
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"eventsource": "^2.0.2", "eventsource": "^2.0.2",
@ -5629,7 +5622,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": { "dependencies": {
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"picomatch": "^2.0.4" "picomatch": "^2.0.4"
@ -5642,7 +5634,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@ -5914,7 +5905,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
}, },
@ -6226,7 +6216,6 @@
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": { "dependencies": {
"anymatch": "~3.1.2", "anymatch": "~3.1.2",
"braces": "~3.0.2", "braces": "~3.0.2",
@ -6518,8 +6507,7 @@
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
"dev": true
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
@ -7421,7 +7409,6 @@
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"iconv-lite": "^0.6.2" "iconv-lite": "^0.6.2"
@ -7431,7 +7418,6 @@
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
@ -8540,7 +8526,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"optional": true, "optional": true,
"os": [ "os": [
@ -9222,7 +9207,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": { "dependencies": {
"binary-extensions": "^2.0.0" "binary-extensions": "^2.0.0"
}, },
@ -11063,7 +11047,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -12453,7 +12436,6 @@
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": { "dependencies": {
"picomatch": "^2.2.1" "picomatch": "^2.2.1"
}, },
@ -12465,7 +12447,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@ -12476,8 +12457,7 @@
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
"dev": true
}, },
"node_modules/regenerate": { "node_modules/regenerate": {
"version": "1.4.2", "version": "1.4.2",
@ -12945,7 +12925,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true "devOptional": true
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.71.1", "version": "1.71.1",
@ -13064,7 +13044,6 @@
"version": "7.6.0", "version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"dependencies": { "dependencies": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
}, },
@ -13079,7 +13058,6 @@
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": { "dependencies": {
"yallist": "^4.0.0" "yallist": "^4.0.0"
}, },
@ -13090,8 +13068,7 @@
"node_modules/semver/node_modules/yallist": { "node_modules/semver/node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
"dev": true
}, },
"node_modules/send": { "node_modules/send": {
"version": "0.18.0", "version": "0.18.0",
@ -14222,7 +14199,6 @@
"version": "5.4.5", "version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -28,7 +28,7 @@
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.5.2",
"@iharbeck/ngx-virtual-scroller": "^17.0.2", "@iharbeck/ngx-virtual-scroller": "^17.0.2",
"@iplab/ngx-file-upload": "^17.1.0", "@iplab/ngx-file-upload": "^17.1.0",
"@microsoft/signalr": "^7.0.12", "@microsoft/signalr": "^8.0.0",
"@ng-bootstrap/ng-bootstrap": "^16.0.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0",
"@ngneat/transloco": "^6.0.4", "@ngneat/transloco": "^6.0.4",
"@ngneat/transloco-locale": "^5.1.2", "@ngneat/transloco-locale": "^5.1.2",

View File

@ -1,19 +1,6 @@
import {ScrobbleProvider} from "../_services/scrobbling.service"; import {ScrobbleProvider} from "../_services/scrobbling.service";
import {AgeRating} from "./metadata/age-rating"; import {AgeRating} from "./metadata/age-rating";
// Deprecated in v0.8, replaced with UserCollection
// export interface CollectionTag {
// id: number;
// title: string;
// promoted: boolean;
// /**
// * This is used as a placeholder to store the coverImage url. The backend does not use this or send it.
// */
// coverImage: string;
// coverImageLocked: boolean;
// summary: string;
// }
export interface UserCollection { export interface UserCollection {
id: number; id: number;
title: string; title: string;
@ -28,6 +15,7 @@ export interface UserCollection {
owner: string; owner: string;
source: ScrobbleProvider; source: ScrobbleProvider;
sourceUrl: string | null; sourceUrl: string | null;
totalSourceCount: number;
missingSeriesFromSource: string | null;
ageRating: AgeRating; ageRating: AgeRating;
} }

View File

@ -0,0 +1,19 @@
import { inject } from '@angular/core';
import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Pipe({
name: 'safeUrl',
pure: true,
standalone: true
})
export class SafeUrlPipe implements PipeTransform {
private readonly dom: DomSanitizer = inject(DomSanitizer);
constructor() {}
transform(value: string | null | undefined): string | null {
if (value === null || value === undefined) return null;
return this.dom.sanitize(SecurityContext.URL, value);
}
}

View File

@ -64,4 +64,8 @@ export class CollectionTagService {
if (isPromotionAction) return canPromote; if (isPromotionAction) return canPromote;
return true; return true;
} }
importStack(stack: MalStack) {
return this.httpClient.post(this.baseUrl + 'collection/import-stack', stack, TextResonse);
}
} }

View File

@ -17,7 +17,10 @@ export enum EVENTS {
SeriesRemoved = 'SeriesRemoved', SeriesRemoved = 'SeriesRemoved',
ScanLibraryProgress = 'ScanLibraryProgress', ScanLibraryProgress = 'ScanLibraryProgress',
OnlineUsers = 'OnlineUsers', OnlineUsers = 'OnlineUsers',
SeriesAddedToCollection = 'SeriesAddedToCollection', /**
* When a Collection has been updated
*/
CollectionUpdated = 'CollectionUpdated',
/** /**
* A generic error that occurs during operations on the server * A generic error that occurs during operations on the server
*/ */
@ -40,6 +43,10 @@ export enum EVENTS {
* A subtype of NotificationProgress that represents the underlying file being processed during a scan * A subtype of NotificationProgress that represents the underlying file being processed during a scan
*/ */
FileScanProgress = 'FileScanProgress', FileScanProgress = 'FileScanProgress',
/**
* A subtype of NotificationProgress that represents a single series being processed (into the DB)
*/
ScanProgress = 'ScanProgress',
/** /**
* A custom user site theme is added or removed during a scan * A custom user site theme is added or removed during a scan
*/ */
@ -141,7 +148,7 @@ export class MessageHubService {
accessTokenFactory: () => user.token accessTokenFactory: () => user.token
}) })
.withAutomaticReconnect() .withAutomaticReconnect()
//.withStatefulReconnect() // Needs @microsoft/signalr@8 .withStatefulReconnect()
.build(); .build();
this.hubConnection this.hubConnection
@ -214,9 +221,9 @@ export class MessageHubService {
}); });
}); });
this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => { this.hubConnection.on(EVENTS.CollectionUpdated, resp => {
this.messagesSource.next({ this.messagesSource.next({
event: EVENTS.SeriesAddedToCollection, event: EVENTS.CollectionUpdated,
payload: resp.body payload: resp.body
}); });
}); });

View File

@ -10,7 +10,7 @@
<div class="offcanvas-body"> <div class="offcanvas-body">
<ng-container *ngIf="CoverUrl as coverUrl"> <ng-container *ngIf="CoverUrl as coverUrl">
<div style="width: 160px" class="mx-auto mb-3"> <div style="width: 160px" class="mx-auto mb-3">
<app-image *ngIf="coverUrl" height="230px" width="160px" maxHeight="230px" objectFit="contain" [imageUrl]="coverUrl"></app-image> <app-image *ngIf="coverUrl" height="230px" width="160px" [styles]="{'object-fit': 'contain', 'max-height': '230px'}" [imageUrl]="coverUrl"></app-image>
</div> </div>
</ng-container> </ng-container>
@ -55,7 +55,7 @@
<div class="row g-0"> <div class="row g-0">
<div class="col-md-4"> <div class="col-md-4">
<ng-container *ngIf="item.imageUrl && !item.imageUrl.endsWith('default.jpg'); else localPerson"> <ng-container *ngIf="item.imageUrl && !item.imageUrl.endsWith('default.jpg'); else localPerson">
<app-image height="24px" width="24px" objectFit="contain" [imageUrl]="item.imageUrl" classes="person-img"></app-image> <app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}" [imageUrl]="item.imageUrl" classes="person-img"></app-image>
</ng-container> </ng-container>
<ng-template #localPerson> <ng-template #localPerson>
<i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i> <i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i>

View File

@ -5,37 +5,42 @@
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button> <button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div> </div>
<div class="modal-body scrollable-modal"> <div class="modal-body scrollable-modal">
<p *ngIf="!invited" [innerHTML]="t('description') | safeHtml"></p> @if (!invited) {
<p [innerHTML]="t('description') | safeHtml"></p>
}
<form [formGroup]="inviteForm" *ngIf="emailLink === ''"> @if (emailLink === '') {
<div class="row g-0"> <form [formGroup]="inviteForm">
<div class="mb-3" style="width:100%"> <div class="row g-0">
<label for="email" class="form-label">{{t('email')}}</label> <div class="mb-3" style="width:100%">
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched"> <label for="email" class="form-label">{{t('email')}}</label>
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched"> <input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<div *ngIf="email?.errors?.required"> <div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
{{t('required-field')}} <div *ngIf="email?.errors?.required">
{{t('required-field')}}
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="row g-0"> <div class="row g-0">
<div class="col-md-6"> <div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector> <app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
</div>
</div> </div>
<div class="col-md-6"> <div class="row g-0">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector> <div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
</div>
</div> </div>
</div> </form>
}
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
</div>
</div>
</form>
<ng-container *ngIf="emailLink !== ''"> <ng-container *ngIf="emailLink !== ''">
<h4>{{t('setup-user-title')}}</h4> <h4>{{t('setup-user-title')}}</h4>

View File

@ -48,51 +48,53 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="TabID.Series"> @if (tag.source === ScrobbleProvider.Kavita) {
<a ngbNavLink>{{t(TabID.Series)}}</a> <li [ngbNavItem]="TabID.Series">
<ng-template ngbNavContent> <a ngbNavLink>{{t(TabID.Series)}}</a>
@if (!isLoading) { <ng-template ngbNavContent>
<div class="list-group"> @if (!isLoading) {
<form [formGroup]="formGroup"> <div class="list-group">
<div class="row g-0 mb-3"> <form [formGroup]="formGroup">
<div class="col-md-12"> <div class="row g-0 mb-3">
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label> <div class="col-md-12">
<div class="input-group"> <label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" /> <div class="input-group">
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
</div>
</div> </div>
</div> </div>
</form>
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</div> </div>
</form> <ul>
<div class="form-check"> @for (item of series | filter: filterList; let i = $index; track item.id) {
<input id="select-all" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita" <li class="list-group-item">
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected"> <div class="form-check">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label> <input id="series-{{i}}" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
</div> [ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<ul> <label for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
@for (item of series | filter: filterList; let i = $index; track item.id) { </div>
<li class="list-group-item"> </li>
<div class="form-check"> }
<input id="series-{{i}}" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita" </ul>
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)"> @if (pagination && series.length !== 0 && pagination.totalPages > 1) {
<label for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label> <div class="d-flex justify-content-center">
</div> <ngb-pagination
</li> [(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
} }
</ul> </div>
@if (pagination && series.length !== 0 && pagination.totalPages > 1) { }
<div class="d-flex justify-content-center"> </ng-template>
<ngb-pagination </li>
[(page)]="pagination.currentPage" }
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
}
</div>
}
</ng-template>
</li>
<li [ngbNavItem]="TabID.CoverImage"> <li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{t(TabID.CoverImage)}}</a> <a ngbNavLink>{{t(TabID.CoverImage)}}</a>
@ -102,6 +104,43 @@
(resetClicked)="handleReset()"></app-cover-image-chooser> (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template> </ng-template>
</li> </li>
@if (tag.source !== ScrobbleProvider.Kavita) {
<li [ngbNavItem]="TabID.Info">
<a ngbNavLink>{{t(TabID.Info)}}</a>
<ng-template ngbNavContent>
<div class="row g-0 mb-2">
<div class="col-md-6">
<div>{{t('last-sync-title')}}</div>
<div>{{tag.lastSyncUtc | date:'shortDate' | defaultDate}}</div>
</div>
<div class="col-md-6">
<div>{{t('source-url-title')}}</div>
<a [href]="tag.sourceUrl | safeUrl" rel="noopener noreferrer" target="_blank">{{tag.sourceUrl}}</a>
</div>
</div>
<div class="row g-0 mb-2">
<div class="col-md-6">
<div>{{t('total-series-title')}}</div>
<div>{{tag.totalSourceCount | number}}</div>
</div>
<div class="col-md-6">
<div>{{t('missing-series-title')}}</div>
<div>{{tag.totalSourceCount - series.length}}</div>
</div>
</div>
@if (tag.missingSeriesFromSource !== null && (tag.totalSourceCount - series.length) > 0) {
<h6>{{t('missing-series-title')}}</h6>
<div class="row g-0">
<p [innerHTML]="tag.missingSeriesFromSource | safeHtml"></p>
</div>
}
</ng-template>
</li>
}
</ul> </ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div> <div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>

View File

@ -24,26 +24,34 @@ import {LibraryService} from 'src/app/_services/library.service';
import {SeriesService} from 'src/app/_services/series.service'; import {SeriesService} from 'src/app/_services/series.service';
import {UploadService} from 'src/app/_services/upload.service'; import {UploadService} from 'src/app/_services/upload.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CommonModule, NgTemplateOutlet} from "@angular/common"; import {CommonModule, DatePipe, DecimalPipe, NgIf, NgTemplateOutlet} from "@angular/common";
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component"; import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
import {translate, TranslocoDirective} from "@ngneat/transloco"; import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ScrobbleProvider} from "../../../_services/scrobbling.service"; import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {FilterPipe} from "../../../_pipes/filter.pipe"; import {FilterPipe} from "../../../_pipes/filter.pipe";
import {ScrobbleError} from "../../../_models/scrobbling/scrobble-error"; import {ScrobbleError} from "../../../_models/scrobbling/scrobble-error";
import {AccountService} from "../../../_services/account.service"; import {AccountService} from "../../../_services/account.service";
import {DefaultDatePipe} from "../../../_pipes/default-date.pipe";
import {ReadMoreComponent} from "../../../shared/read-more/read-more.component";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {SafeUrlPipe} from "../../../_pipes/safe-url.pipe";
import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
import {TagBadgeComponent} from "../../../shared/tag-badge/tag-badge.component";
enum TabID { enum TabID {
General = 'general-tab', General = 'general-tab',
CoverImage = 'cover-image-tab', CoverImage = 'cover-image-tab',
Series = 'series-tab' Series = 'series-tab',
Info = 'info-tab'
} }
@Component({ @Component({
selector: 'app-edit-collection-tags', selector: 'app-edit-collection-tags',
standalone: true, standalone: true,
imports: [NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination, imports: [NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination,
CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective, NgTemplateOutlet, FilterPipe], CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective, NgTemplateOutlet, FilterPipe, DatePipe, DefaultDatePipe, ReadMoreComponent, SafeHtmlPipe, SafeUrlPipe, MangaFormatPipe, NgIf, SentenceCasePipe, TagBadgeComponent, DecimalPipe],
templateUrl: './edit-collection-tags.component.html', templateUrl: './edit-collection-tags.component.html',
styleUrls: ['./edit-collection-tags.component.scss'], styleUrls: ['./edit-collection-tags.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -2,9 +2,9 @@
<div class="card-item-container card {{selected ? 'selected-highlight' : ''}}"> <div class="card-item-container card {{selected ? 'selected-highlight' : ''}}">
<div class="overlay" (click)="handleClick($event)"> <div class="overlay" (click)="handleClick($event)">
@if (total > 0 || suppressArchiveWarning) { @if (total > 0 || suppressArchiveWarning) {
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageUrl"></app-image> <app-image height="230px" width="158px" [styles]="{'border-radius': '.25rem .25rem 0 0'}" [imageUrl]="imageUrl"></app-image>
} @else if (total === 0 && !suppressArchiveWarning) { } @else if (total === 0 && !suppressArchiveWarning) {
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageService.errorImage"></app-image> <app-image height="230px" width="158px" [styles]="{'border-radius': '.25rem .25rem 0 0'}" [imageUrl]="imageService.errorImage"></app-image>
} }
<div class="progress-banner"> <div class="progress-banner">

View File

@ -1,17 +1,17 @@
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2"> <div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
<div class="pe-2"> <div class="pe-2">
<app-image [imageUrl]="imageUrl" [height]="imageHeight" maxHeight="200px" [width]="imageWidth"></app-image> <app-image [imageUrl]="imageUrl" [height]="imageHeight" [styles]="{'max-height': '200px'}" [width]="imageWidth"></app-image>
</div> </div>
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="g-0"> <div class="g-0">
<h5 class="mb-0"> <h5 class="mb-0">
<ng-content select="[title]"></ng-content> <ng-content select="[title]"></ng-content>
</h5> </h5>
<ng-container *ngIf="summary && summary.length > 0"> @if (summary && summary.length > 0) {
<div class="mt-2 ps-2"> <div class="mt-2 ps-2">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more> <app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</div> </div>
</ng-container> }
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
<div class="card-item-container card clickable"> <div class="card-item-container card clickable">
<div class="overlay" (click)="handleClick()"> <div class="overlay" (click)="handleClick()">
<ng-container> <ng-container>
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="data.coverUrl"></app-image> <app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="230px" width="158px" [imageUrl]="data.coverUrl"></app-image>
</ng-container> </ng-container>

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'list-item'"> <ng-container *transloco="let t; read: 'list-item'">
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2"> <div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
<div class="pe-2"> <div class="pe-2">
<app-image [imageUrl]="imageUrl" [height]="imageHeight" maxHeight="200px" [width]="imageWidth"></app-image> <app-image [imageUrl]="imageUrl" [height]="imageHeight" [styles]="{'max-height': '200px'}" [width]="imageWidth"></app-image>
<div class="not-read-badge" *ngIf="pagesRead === 0 && totalPages > 0"></div> <div class="not-read-badge" *ngIf="pagesRead === 0 && totalPages > 0"></div>
<span class="download"> <span class="download">
<app-download-indicator [download$]="download$"></app-download-indicator> <app-download-indicator [download$]="download$"></app-download-indicator>

View File

@ -1,6 +1,6 @@
<div class="card-item-container card"> <div class="card-item-container card">
<div class="overlay"> <div class="overlay">
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" classes="extreme-blur" [imageUrl]="imageUrl"></app-image> <app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="230px" width="158px" classes="extreme-blur" [imageUrl]="imageUrl"></app-image>
<div class="card-overlay"></div> <div class="card-overlay"></div>
<ng-container *ngIf="entity.title | safeHtml as info"> <ng-container *ngIf="entity.title | safeHtml as info">

View File

@ -11,15 +11,34 @@
</div> </div>
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="collectionTag !== undefined" #scrollingBlock> <div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="collectionTag !== undefined" #scrollingBlock>
<div class="row mb-3" *ngIf="summary.length > 0"> @if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) {
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block"> <div class="row mb-3">
<app-image maxWidth="481px" [imageUrl]="imageService.getCollectionCoverImage(collectionTag.id)"></app-image> <div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<app-image [styles]="{'max-width': '481px'}" [imageUrl]="imageService.getCollectionCoverImage(collectionTag.id)"></app-image>
@if (collectionTag.source !== ScrobbleProvider.Kavita && collectionTag.missingSeriesFromSource !== null
&& series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) {
<div class="under-image">
<app-image [imageUrl]="collectionTag.source | providerImage"
width="16px" height="16px" [styles]="{'vertical-align': 'text-top'}"
[ngbTooltip]="collectionTag.source | providerName" tabindex="0"></app-image>
<span class="ms-2 me-2">{{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}}</span>
<i class="fa-solid fa-question-circle" aria-hidden="true" [ngbTooltip]="t('last-sync', {date: collectionTag.lastSyncUtc | date: 'shortDate' | defaultDate })"></i>
</div>
}
</div>
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
@if (summary.length > 0) {
<div class="mb-2">
<app-read-more [text]="summary" [maxLength]="utilityService.getActiveBreakpoint() < Breakpoint.Tablet ? 250 : 600"></app-read-more>
</div>
}
</div>
<hr>
</div> </div>
<div class="col-md-10 col-xs-8 col-sm-6 mt-2"> }
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</div>
<hr>
</div>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations> <app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout *ngIf="filter" <app-card-detail-layout *ngIf="filter"

View File

@ -41,3 +41,11 @@ h2 {
margin-bottom: 0; margin-bottom: 0;
word-break: break-all; word-break: break-all;
} }
.under-image {
background-color: var(--breadcrumb-bg-color);
color: white;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
text-align: center;
}

View File

@ -1,4 +1,4 @@
import {DOCUMENT, NgIf, NgStyle} from '@angular/common'; import {DatePipe, DOCUMENT, NgIf, NgStyle} from '@angular/common';
import { import {
AfterContentChecked, AfterContentChecked,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -15,14 +15,14 @@ import {
} from '@angular/core'; } from '@angular/core';
import {Title} from '@angular/platform-browser'; import {Title} from '@angular/platform-browser';
import {ActivatedRoute, Router} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import {debounceTime, take} from 'rxjs/operators'; import {debounceTime, take} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component'; import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import {FilterSettings} from 'src/app/metadata-filter/filter-settings'; import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {UserCollection} from 'src/app/_models/collection-tag'; import {UserCollection} from 'src/app/_models/collection-tag';
import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-to-collection-event'; import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-to-collection-event';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
@ -54,6 +54,12 @@ import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2"; import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
import {AccountService} from "../../../_services/account.service"; import {AccountService} from "../../../_services/account.service";
import {User} from "../../../_models/user"; import {User} from "../../../_models/user";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {TranslocoDatePipe} from "@ngneat/transloco-locale";
import {DefaultDatePipe} from "../../../_pipes/default-date.pipe";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
@Component({ @Component({
selector: 'app-collection-detail', selector: 'app-collection-detail',
@ -61,7 +67,7 @@ import {User} from "../../../_models/user";
styleUrls: ['./collection-detail.component.scss'], styleUrls: ['./collection-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, NgStyle, ImageComponent, ReadMoreComponent, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective] imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, NgStyle, ImageComponent, ReadMoreComponent, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip, SafeHtmlPipe, TranslocoDatePipe, DatePipe, DefaultDatePipe, ProviderImagePipe, ProviderNamePipe]
}) })
export class CollectionDetailComponent implements OnInit, AfterContentChecked { export class CollectionDetailComponent implements OnInit, AfterContentChecked {
@ -82,7 +88,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
private readonly actionService = inject(ActionService); private readonly actionService = inject(ActionService);
private readonly messageHub = inject(MessageHubService); private readonly messageHub = inject(MessageHubService);
private readonly filterUtilityService = inject(FilterUtilitiesService); private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly utilityService = inject(UtilityService); protected readonly utilityService = inject(UtilityService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly scrollService = inject(ScrollService); private readonly scrollService = inject(ScrollService);
@ -213,7 +219,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(2000)).subscribe(event => { this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(2000)).subscribe(event => {
if (event.event == EVENTS.SeriesAddedToCollection) { if (event.event == EVENTS.CollectionUpdated) {
const collectionEvent = event.payload as SeriesAddedToCollectionEvent; const collectionEvent = event.payload as SeriesAddedToCollectionEvent;
if (collectionEvent.tagId === this.collectionTag.id) { if (collectionEvent.tagId === this.collectionTag.id) {
this.loadPage(); this.loadPage();
@ -326,4 +332,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
this.loadPage(); this.loadPage();
}); });
} }
protected readonly ScrobbleProvider = ScrobbleProvider;
protected readonly Breakpoint = Breakpoint;
} }

View File

@ -9,9 +9,12 @@
<ul> <ul>
@for(stack of stacks; track stack.url) { @for(stack of stacks; track stack.url) {
<li> <li class="mb-2">
<div><a [href]="stack.url" rel="noreferrer noopener" target="_blank">{{stack.title}}</a></div> <div>
<div>by {{stack.author}} • {{t('series-count', {num: stack.seriesCount})}} • <span><i class="fa-solid fa-layer-group me-1" aria-hidden="true"></i>{{t('restack-count', {num: stack.restackCount})}}</span></div> <a [href]="stack.url" rel="noreferrer noopener" target="_blank">{{stack.title}}</a>
<button class="btn btn-primary float-end" [disabled]="collectionMap && collectionMap.hasOwnProperty(stack.url)" (click)="importStack(stack)">Track</button>
</div>
<div>by {{stack.author}} • {{t('series-count', {num: stack.seriesCount | number})}} • <span><i class="fa-solid fa-layer-group me-1" aria-hidden="true"></i>{{t('restack-count', {num: stack.restackCount | number})}}</span></div>
</li> </li>
} }
</ul> </ul>
@ -31,6 +34,9 @@
<!-- <div class="col-auto">--> <!-- <div class="col-auto">-->
<!-- <button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>--> <!-- <button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>-->
<!-- </div>--> <!-- </div>-->
<div class="col-auto">
<button type="button" class="btn btn-secondary" (click)="ngbModal.dismiss()">{{t('close')}}</button>
</div>
</div> </div>
</ng-container> </ng-container>

View File

@ -1,10 +1,15 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {TranslocoDirective} from "@ngneat/transloco"; import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ReactiveFormsModule} from "@angular/forms"; import {ReactiveFormsModule} from "@angular/forms";
import {Select2Module} from "ng-select2-component"; import {Select2Module} from "ng-select2-component";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {CollectionTagService} from "../../../_services/collection-tag.service"; import {CollectionTagService} from "../../../_services/collection-tag.service";
import {MalStack} from "../../../_models/collection/mal-stack"; import {MalStack} from "../../../_models/collection/mal-stack";
import {UserCollection} from "../../../_models/collection-tag";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {forkJoin} from "rxjs";
import {ToastrService} from "ngx-toastr";
import {DecimalPipe} from "@angular/common";
@Component({ @Component({
selector: 'app-import-mal-collection-modal', selector: 'app-import-mal-collection-modal',
@ -12,7 +17,8 @@ import {MalStack} from "../../../_models/collection/mal-stack";
imports: [ imports: [
TranslocoDirective, TranslocoDirective,
ReactiveFormsModule, ReactiveFormsModule,
Select2Module Select2Module,
DecimalPipe
], ],
templateUrl: './import-mal-collection-modal.component.html', templateUrl: './import-mal-collection-modal.component.html',
styleUrl: './import-mal-collection-modal.component.scss', styleUrl: './import-mal-collection-modal.component.scss',
@ -21,19 +27,41 @@ import {MalStack} from "../../../_models/collection/mal-stack";
export class ImportMalCollectionModalComponent { export class ImportMalCollectionModalComponent {
protected readonly ngbModal = inject(NgbActiveModal); protected readonly ngbModal = inject(NgbActiveModal);
protected readonly collectionService = inject(CollectionTagService); private readonly collectionService = inject(CollectionTagService);
protected readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
stacks: Array<MalStack> = []; stacks: Array<MalStack> = [];
isLoading = true; isLoading = true;
collectionMap: {[key: string]: UserCollection | MalStack} = {};
constructor() { constructor() {
this.collectionService.getMalStacks().subscribe(stacks => {
this.stacks = stacks;
this.isLoading = false;
this.cdRef.markForCheck();
})
forkJoin({
allCollections: this.collectionService.allCollections(true),
malStacks: this.collectionService.getMalStacks()
}).subscribe(res => {
// Create a map on sourceUrl from collections so that if there are non-null sourceUrl (and source is MAL) then we can disable buttons
const collects = res.allCollections.filter(c => c.source === ScrobbleProvider.Mal && c.sourceUrl);
for(let col of collects) {
if (col.sourceUrl === null) continue;
this.collectionMap[col.sourceUrl] = col;
}
this.stacks = res.malStacks;
this.isLoading = false;
this.cdRef.markForCheck();
});
}
importStack(stack: MalStack) {
this.collectionService.importStack(stack).subscribe(() => {
this.collectionMap[stack.url] = stack;
this.cdRef.markForCheck();
this.toastr.success(translate('toasts.stack-imported'));
})
} }

View File

@ -23,71 +23,81 @@
</li> </li>
</ng-container> </ng-container>
</ng-container> </ng-container>
<ng-container *ngIf="debugMode"> @if (debugMode) {
<li class="list-group-item dark-menu-item"> <ng-container>
<div class="h6 mb-1">Title goes here</div> <li class="list-group-item dark-menu-item">
<div class="accent-text mb-1">Subtitle goes here</div> <div class="h6 mb-1">Title goes here</div>
<div class="progress-container row g-0 align-items-center"> <div class="accent-text mb-1">Subtitle goes here</div>
<div class="progress" style="height: 5px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</li>
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
</li>
<li class="list-group-item dark-menu-item">
<div>
<div class="h6 mb-1">Scanning Books</div>
<div class="accent-text mb-1">E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub</div>
<div class="progress-container row g-0 align-items-center"> <div class="progress-container row g-0 align-items-center">
<div class="col-2">{{prettyPrintProgress(0.1)}}%</div> <div class="progress" style="height: 5px;">
<div class="col-10 progress" style="height: 5px;"> <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': 0.1 * 100 + '%'}" [attr.aria-valuenow]="0.1 * 100" aria-valuemin="0" aria-valuemax="100"></div>
</div> </div>
</div> </div>
</li>
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
</li>
<li class="list-group-item dark-menu-item">
<div>
<div class="h6 mb-1">Scanning Books</div>
<div class="accent-text mb-1">E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub</div>
<div class="progress-container row g-0 align-items-center">
<div class="col-2">{{prettyPrintProgress(0.1)}}%</div>
<div class="col-10 progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': 0.1 * 100 + '%'}" [attr.aria-valuenow]="0.1 * 100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div> </div>
</li> </li>
<li class="list-group-item dark-menu-item error"> <li class="list-group-item dark-menu-item error">
<div> <div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div> <div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div>
<div class="accent-text mb-1">Click for more information</div> <div class="accent-text mb-1">Click for more information</div>
</div> </div>
<button type="button" class="btn-close float-end" aria-label="close" ></button> <button type="button" class="btn-close float-end" aria-label="close" ></button>
</li> </li>
<li class="list-group-item dark-menu-item info"> <li class="list-group-item dark-menu-item info">
<div> <div>
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>Scan didn't run becasuse nothing to do</div> <div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>Scan didn't run becasuse nothing to do</div>
<div class="accent-text mb-1">Click for more information</div> <div class="accent-text mb-1">Click for more information</div>
</div> </div>
<button type="button" class="btn-close float-end" aria-label="close" ></button> <button type="button" class="btn-close float-end" aria-label="close" ></button>
</li> </li>
<li class="list-group-item dark-menu-item"> <li class="list-group-item dark-menu-item">
<div class="d-inline-flex"> <div class="d-inline-flex">
<span class="download"> <span class="download">
<app-circular-loader [currentValue]="25" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader> <app-circular-loader [currentValue]="25" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader>
<span class="visually-hidden" role="status"> <span class="visually-hidden" role="status">
10% downloaded 10% downloaded
</span> </span>
</span> </span>
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span> <span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
</div> </div>
<div class="accent-text">PDFs</div> <div class="accent-text">PDFs</div>
</li> </li>
</ng-container> </ng-container>
}
<!-- Progress Events--> <!-- Progress Events-->
<ng-container *ngIf="progressEvents$ | async as progressUpdates"> <ng-container *ngIf="progressEvents$ | async as progressUpdates">
<ng-container *ngFor="let message of progressUpdates"> <ng-container *ngFor="let message of progressUpdates">
<li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent"> <li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent">
<div class="h6 mb-1">{{message.title}}</div> <div class="h6 mb-1">{{message.title}}</div>
<div class="accent-text mb-1" *ngIf="message.subTitle !== ''" [title]="message.subTitle">{{message.subTitle}}</div> @if (message.subTitle !== '') {
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
}
@if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) {
<div class="accent-text mb-1" [title]="t('left-to-process', {leftToProcess: message.body.leftToProcess})">{{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}</div>
}
<div class="progress-container row g-0 align-items-center"> <div class="progress-container row g-0 align-items-center">
<div class="progress" style="height: 5px;" *ngIf="message.progress === 'indeterminate'"> @if(message.progress === 'indeterminate') {
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress" style="height: 5px;">
</div> <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
}
</div> </div>
</li> </li>
<ng-template #progressEvent> <ng-template #progressEvent>
@ -165,12 +175,17 @@
</ng-container> </ng-container>
<!-- Online Users --> <!-- Online Users -->
<ng-container *ngIf="messageHub.onlineUsers$ | async as onlineUsers"> @if (messageHub.onlineUsers$ | async; as onlineUsers) {
<li class="list-group-item dark-menu-item" *ngIf="onlineUsers.length > 1"> @if (onlineUsers.length > 1) {
<div>{{t('users-online-count', {num: onlineUsers.length})}}</div> <li class="list-group-item dark-menu-item">
</li> <div>{{t('users-online-count', {num: onlineUsers.length})}}</div>
<li class="list-group-item dark-menu-item" *ngIf="debugMode">{{t('active-events-title')}} {{activeEvents}}</li> </li>
</ng-container> }
@if (debugMode) {
<li class="list-group-item dark-menu-item">{{t('active-events-title')}} {{activeEvents}}</li>
}
}
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads"> <ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
<li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && activeDownloads.length === 0">{{t('no-data')}}</li> <li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && activeDownloads.length === 0">{{t('no-data')}}</li>

View File

@ -15,10 +15,10 @@
} }
::ng-deep .nav-events { ::ng-deep .nav-events {
.popover-body { .popover-body {
min-width: 250px; min-width: 300px;
max-width: 250px; max-width: 300px;
padding: 0px; padding: 0px;
box-shadow: 0px 0px 12px rgb(0 0 0 / 75%); box-shadow: 0px 0px 12px rgb(0 0 0 / 75%);
max-height: calc(100vh - 60px); max-height: calc(100vh - 60px);
@ -42,7 +42,7 @@
width: 100%; width: 100%;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow:hidden; overflow:hidden;
white-space:nowrap; white-space:nowrap;
} }
.btn:focus, .btn:hover { .btn:focus, .btn:hover {
@ -76,7 +76,7 @@
.update-available { .update-available {
cursor: pointer; cursor: pointer;
i.fa { i.fa {
color: var(--primary-color) !important; color: var(--primary-color) !important;
} }
@ -119,4 +119,4 @@
font-size: 11px; font-size: 11px;
position: absolute; position: absolute;
} }
} }

View File

@ -39,7 +39,7 @@
<div class="row mb-2"> <div class="row mb-2">
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block"> <div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<app-image maxWidth="300px" maxHeight="400px" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)"></app-image> <app-image [styles]="{'max-height': '400px', 'max-width': '300px'}" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)"></app-image>
</div> </div>
<div class="col-md-10 col-xs-8 col-sm-6 mt-2"> <div class="col-md-10 col-xs-8 col-sm-6 mt-2">
<div class="row g-0 mb-3"> <div class="row g-0 mb-3">

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'reading-list-item'"> <ng-container *transloco="let t; read: 'reading-list-item'">
<div class="d-flex flex-row g-0 mb-2 reading-list-item"> <div class="d-flex flex-row g-0 mb-2 reading-list-item">
<div class="pe-2"> <div class="pe-2">
<app-image width="106px" maxHeight="125px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image> <app-image width="106px" [styles]="{'max-height': '125px'}" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
@if (item.pagesRead === 0 && item.pagesTotal > 0) { @if (item.pagesRead === 0 && item.pagesTotal > 0) {
<div class="not-read-badge" ></div> <div class="not-read-badge" ></div>
} }

View File

@ -58,7 +58,7 @@
<div class="to-read-counter" *ngIf="unreadCount > 0 && unreadCount !== totalCount"> <div class="to-read-counter" *ngIf="unreadCount > 0 && unreadCount !== totalCount">
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge> <app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
</div> </div>
<app-image height="100%" maxHeight="400px" objectFit="contain" background="none" [imageUrl]="seriesImage"></app-image> <app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image>
<ng-container *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial"> <ng-container *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial">
<div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read"> <div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read">
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar> <ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>

View File

@ -1,18 +1,19 @@
import { import {
AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
ElementRef, ElementRef,
inject, inject,
Input, Input,
OnChanges, OnChanges,
Renderer2, Renderer2,
RendererStyleFlags2,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { CoverUpdateEvent } from 'src/app/_models/events/cover-update-event'; import {CoverUpdateEvent} from 'src/app/_models/events/cover-update-event';
import { ImageService } from 'src/app/_services/image.service'; import {ImageService} from 'src/app/_services/image.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CommonModule, NgOptimizedImage} from "@angular/common"; import {CommonModule, NgOptimizedImage} from "@angular/common";
import {LazyLoadImageModule, StateChange} from "ng-lazyload-image"; import {LazyLoadImageModule, StateChange} from "ng-lazyload-image";
@ -48,26 +49,6 @@ export class ImageComponent implements OnChanges {
* Height of the image. If not defined, will not be applied * Height of the image. If not defined, will not be applied
*/ */
@Input() height: string = ''; @Input() height: string = '';
/**
* Max Width of the image. If not defined, will not be applied
*/
@Input() maxWidth: string = '';
/**
* Max Height of the image. If not defined, will not be applied
*/
@Input() maxHeight: string = '';
/**
* Border Radius of the image. If not defined, will not be applied
*/
@Input() borderRadius: string = '';
/**
* Object fit of the image. If not defined, will not be applied
*/
@Input() objectFit: string = '';
/**
* Background of the image. If not defined, will not be applied
*/
@Input() background: string = '';
/** /**
* If the image component should respond to cover updates * If the image component should respond to cover updates
*/ */
@ -79,7 +60,7 @@ export class ImageComponent implements OnChanges {
/** /**
* A collection of styles to apply. This is useful if the parent component doesn't want to use no view encapsulation * A collection of styles to apply. This is useful if the parent component doesn't want to use no view encapsulation
*/ */
@Input() styles: string = ''; @Input() styles: {[key: string]: string} = {};
@Input() errorImage: string = this.imageService.errorImage; @Input() errorImage: string = this.imageService.errorImage;
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>; @ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;
@ -110,41 +91,25 @@ export class ImageComponent implements OnChanges {
} }
ngOnChanges(): void { ngOnChanges(): void {
if (this.width != '') { if (this.width !== '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'width', this.width); this.renderer.setStyle(this.imgElem.nativeElement, 'width', this.width);
} }
if (this.height != '') { if (this.height !== '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'height', this.height); this.renderer.setStyle(this.imgElem.nativeElement, 'height', this.height);
} }
if (this.maxWidth != '') { const styleKeys = Object.keys(this.styles);
this.renderer.setStyle(this.imgElem.nativeElement, 'max-width', this.maxWidth); if (styleKeys.length !== 0) {
} styleKeys.forEach(key => {
this.renderer.setStyle(this.imgElem.nativeElement, key, this.styles[key], RendererStyleFlags2.Important);
if (this.maxHeight != '') { });
this.renderer.setStyle(this.imgElem.nativeElement, 'max-height', this.maxHeight);
}
if (this.borderRadius != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'border-radius', this.borderRadius);
}
if (this.objectFit != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'object-fit', this.objectFit);
}
if (this.background != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'background', this.background);
}
if (this.styles != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'styles', this.styles);
} }
if (this.classes != '') { if (this.classes != '') {
this.renderer.addClass(this.imgElem.nativeElement, this.classes); this.renderer.addClass(this.imgElem.nativeElement, this.classes);
} }
this.cdRef.markForCheck();
} }

View File

@ -1,7 +1,7 @@
<div class="tagbadge cursor clickable" *ngIf="person !== undefined"> <div class="tagbadge cursor clickable" *ngIf="person !== undefined">
<div class="d-flex"> <div class="d-flex">
<ng-container *ngIf="isStaff && staff.imageUrl && !staff.imageUrl.endsWith('default.jpg'); else localPerson"> <ng-container *ngIf="isStaff && staff.imageUrl && !staff.imageUrl.endsWith('default.jpg'); else localPerson">
<app-image height="24px" width="24px" objectFit="contain" [imageUrl]="staff.imageUrl"></app-image> <app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}"[imageUrl]="staff.imageUrl"></app-image>
</ng-container> </ng-container>
<ng-template #localPerson> <ng-template #localPerson>
<i class="fa fa-user-circle align-self-center me-2" aria-hidden="true"></i> <i class="fa fa-user-circle align-self-center me-2" aria-hidden="true"></i>

View File

@ -65,7 +65,7 @@ export class SideNavComponent implements OnInit {
homeActions = [ homeActions = [
{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.openCustomize.bind(this)}, {action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.openCustomize.bind(this)},
{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}, {action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)},
//{action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}, // This requires the Collection Rework (https://github.com/Kareadita/Kavita/issues/2810) {action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}, // This requires the Collection Rework (https://github.com/Kareadita/Kavita/issues/2810)
]; ];
filterQuery: string = ''; filterQuery: string = '';

View File

@ -1,17 +1,27 @@
<ng-container *ngIf="data$ | async as data"> @if (data$ | async; as data) {
<div class="card" style="width: 18rem;"> <div class="card" style="width: 18rem;">
<div class="card-header text-center"> <div class="card-header text-center">
{{title}} {{title}}
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0" *ngIf="description && description.length > 0"></i> @if (description && description.length > 0) {
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
}
</div> </div>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<li class="list-group-item" [ngClass]="{'underline': handleClick !== undefined}" *ngFor="let item of data" (click)="doClick(item)"> @for(item of data; track item) {
<ng-container *ngIf="image && image(item) as url"> <li class="list-group-item" [ngClass]="{'underline': handleClick !== undefined}" (click)="doClick(item)">
<app-image *ngIf="url && url.length > 0" width="32px" maxHeight="32px" class="img-top me-1" [imageUrl]="url"></app-image> @if (image && image(item); as url) {
</ng-container> @if (url && url.length > 0) {
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value | compactNumber}} {{label}}</span> <app-image width="32px" [styles]="{'max-height': '32px'}" class="img-top me-1" [imageUrl]="url"></app-image>
</li> }
}
{{item.name}}
@if (item.value >= 0) {
<span class="float-end">{{item.value | compactNumber}} {{label}}</span>
}
</li>
}
</ul> </ul>
</div> </div>
</ng-container> }
<ng-template #tooltip></ng-template>
<ng-template #tooltip>{{description}}</ng-template>

View File

@ -4,7 +4,7 @@ import { PieDataItem } from '../../_models/pie-data-item';
import { CompactNumberPipe } from '../../../_pipes/compact-number.pipe'; import { CompactNumberPipe } from '../../../_pipes/compact-number.pipe';
import { ImageComponent } from '../../../shared/image/image.component'; import { ImageComponent } from '../../../shared/image/image.component';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgFor, NgClass, AsyncPipe } from '@angular/common'; import { NgClass, AsyncPipe } from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
@Component({ @Component({
@ -13,7 +13,7 @@ import {TranslocoDirective} from "@ngneat/transloco";
styleUrls: ['./stat-list.component.scss'], styleUrls: ['./stat-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [NgIf, NgbTooltip, NgFor, NgClass, ImageComponent, AsyncPipe, CompactNumberPipe, TranslocoDirective] imports: [NgbTooltip, NgClass, ImageComponent, AsyncPipe, CompactNumberPipe, TranslocoDirective]
}) })
export class StatListComponent { export class StatListComponent {

View File

@ -949,7 +949,7 @@
"general-tab": "General", "general-tab": "General",
"metadata-tab": "Metadata", "metadata-tab": "Metadata",
"cover-tab": "Cover", "cover-tab": "Cover",
"info-tab": "Info", "info-tab": "{{edit-series-modal.info-tab}}",
"progress-tab": "Progress", "progress-tab": "Progress",
"no-summary": "No Summary available.", "no-summary": "No Summary available.",
"writers-title": "{{series-metadata-detail.writers-title}}", "writers-title": "{{series-metadata-detail.writers-title}}",
@ -1167,7 +1167,7 @@
"encode-as-description-part-2": "Can I Use WebP?", "encode-as-description-part-2": "Can I Use WebP?",
"encode-as-description-part-3": "Can I Use AVIF?", "encode-as-description-part-3": "Can I Use AVIF?",
"encode-as-warning": "You cannot convert back to PNG once you've gone to WebP/AVIF. You would need to refresh covers on your libraries to regenerate all covers. Bookmarks and favicons cannot be converted.", "encode-as-warning": "You cannot convert back to PNG once you've gone to WebP/AVIF. You would need to refresh covers on your libraries to regenerate all covers. Bookmarks and favicons cannot be converted.",
"media-warning": "You must trigger the media conversion task in Tasks Tab.,", "media-warning": "You must trigger the media conversion task in Tasks Tab.",
"encode-as-label": "Save Media As", "encode-as-label": "Save Media As",
"encode-as-tooltip": "All media Kavita manages (covers, bookmarks, favicons) will be encoded as this type.", "encode-as-tooltip": "All media Kavita manages (covers, bookmarks, favicons) will be encoded as this type.",
"bookmark-dir-label": "Bookmarks Directory", "bookmark-dir-label": "Bookmarks Directory",
@ -1373,6 +1373,7 @@
"cancel": "{{common.cancel}}", "cancel": "{{common.cancel}}",
"general-tab": "General", "general-tab": "General",
"cover-image-tab": "Cover Image", "cover-image-tab": "Cover Image",
"info-tab": "{{edit-series-modal.info-tab}}",
"series-tab": "Series", "series-tab": "Series",
"name-label": "Name", "name-label": "Name",
"name-validation": "Name must be unique", "name-validation": "Name must be unique",
@ -1381,7 +1382,11 @@
"summary-label": "Summary", "summary-label": "Summary",
"deselect-all": "{{common.deselect-all}}", "deselect-all": "{{common.deselect-all}}",
"select-all": "{{common.select-all}}", "select-all": "{{common.select-all}}",
"filter-label": "{{common.filter}}" "filter-label": "{{common.filter}}",
"last-sync-title": "Last Sync:",
"source-url-title": "Source Url:",
"total-series-title": "Total Series:",
"missing-series-title": "Missing Series:"
}, },
"library-detail": { "library-detail": {
@ -1423,7 +1428,9 @@
"no-data": "There are no items. Try adding a series.", "no-data": "There are no items. Try adding a series.",
"no-data-filtered": "No items match your current filter.", "no-data-filtered": "No items match your current filter.",
"title-alt": "Kavita - {{collectionName}} Collection", "title-alt": "Kavita - {{collectionName}} Collection",
"series-header": "Series" "series-header": "Series",
"sync-progress": "Series Collected: {{title}}",
"last-sync": "Last Sync: {{date}}"
}, },
"all-collections": { "all-collections": {
@ -1490,7 +1497,8 @@
"close": "{{common.close}}", "close": "{{common.close}}",
"users-online-count": "{{num}} Users online", "users-online-count": "{{num}} Users online",
"active-events-title": "Active Events:", "active-events-title": "Active Events:",
"no-data": "Not much going on here" "no-data": "Not much going on here",
"left-to-process": "Left to Process: {{leftToProcess}}"
}, },
"shortcuts-modal": { "shortcuts-modal": {
@ -2176,7 +2184,8 @@
"collections-unpromoted": "Collections un-promoted", "collections-unpromoted": "Collections un-promoted",
"confirm-delete-collections": "Are you sure you want to delete multiple collections?", "confirm-delete-collections": "Are you sure you want to delete multiple collections?",
"collections-deleted": "Collections deleted", "collections-deleted": "Collections deleted",
"pdf-book-mode-screen-size": "Screen too small for Book mode" "pdf-book-mode-screen-size": "Screen too small for Book mode",
"stack-imported": "Stack Imported"
}, },

View File

@ -2,12 +2,12 @@
"openapi": "3.0.1", "openapi": "3.0.1",
"info": { "info": {
"title": "Kavita", "title": "Kavita",
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage.", "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required.",
"license": { "license": {
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.8.0.9" "version": "0.8.1.0"
}, },
"servers": [ "servers": [
{ {
@ -1378,6 +1378,56 @@
} }
} }
}, },
"/api/Collection/single": {
"get": {
"tags": [
"Collection"
],
"summary": "Returns a single Collection tag by Id for a given user",
"parameters": [
{
"name": "collectionId",
"in": "query",
"description": "",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AppUserCollectionDto"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AppUserCollectionDto"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AppUserCollectionDto"
}
}
}
}
}
}
}
},
"/api/Collection/all-series": { "/api/Collection/all-series": {
"get": { "get": {
"tags": [ "tags": [
@ -1548,23 +1598,23 @@
"tags": [ "tags": [
"Collection" "Collection"
], ],
"summary": "Promote/UnPromote multiple collections in one go", "summary": "Delete multiple collections in one go",
"requestBody": { "requestBody": {
"description": "", "description": "",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/PromoteCollectionsDto" "$ref": "#/components/schemas/DeleteCollectionsDto"
} }
}, },
"text/json": { "text/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/PromoteCollectionsDto" "$ref": "#/components/schemas/DeleteCollectionsDto"
} }
}, },
"application/*+json": { "application/*+json": {
"schema": { "schema": {
"$ref": "#/components/schemas/PromoteCollectionsDto" "$ref": "#/components/schemas/DeleteCollectionsDto"
} }
} }
} }
@ -1681,6 +1731,39 @@
} }
} }
}, },
"/api/Collection/import-stack": {
"post": {
"tags": [
"Collection"
],
"summary": "Imports a MAL Stack into Kavita",
"requestBody": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MalStackDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MalStackDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/MalStackDto"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Device/create": { "/api/Device/create": {
"post": { "post": {
"tags": [ "tags": [
@ -13296,6 +13379,16 @@
"description": "For Non-Kavita sourced collections, the url to sync from", "description": "For Non-Kavita sourced collections, the url to sync from",
"nullable": true "nullable": true
}, },
"totalSourceCount": {
"type": "integer",
"description": "Total number of items as of the last sync. Not applicable for Kavita managed collections.",
"format": "int32"
},
"missingSeriesFromSource": {
"type": "string",
"description": "A <br /> separated string of all missing series",
"nullable": true
},
"appUser": { "appUser": {
"$ref": "#/components/schemas/AppUser" "$ref": "#/components/schemas/AppUser"
}, },
@ -13380,6 +13473,16 @@
"type": "string", "type": "string",
"description": "For Non-Kavita sourced collections, the url to sync from", "description": "For Non-Kavita sourced collections, the url to sync from",
"nullable": true "nullable": true
},
"totalSourceCount": {
"type": "integer",
"description": "Total number of items as of the last sync. Not applicable for Kavita managed collections.",
"format": "int32"
},
"missingSeriesFromSource": {
"type": "string",
"description": "A <br /> separated string of all missing series",
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -15240,35 +15343,6 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"CollectionTagDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"title": {
"type": "string",
"nullable": true
},
"summary": {
"type": "string",
"nullable": true
},
"promoted": {
"type": "boolean"
},
"coverImage": {
"type": "string",
"description": "The cover image string. This is used on Frontend to show or hide the Cover Image",
"nullable": true
},
"coverImageLocked": {
"type": "boolean"
}
},
"additionalProperties": false
},
"ConfirmEmailDto": { "ConfirmEmailDto": {
"required": [ "required": [
"email", "email",
@ -15538,6 +15612,22 @@
"additionalProperties": false, "additionalProperties": false,
"description": "For requesting an encoded filter to be decoded" "description": "For requesting an encoded filter to be decoded"
}, },
"DeleteCollectionsDto": {
"required": [
"collectionIds"
],
"type": "object",
"properties": {
"collectionIds": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
}
},
"additionalProperties": false
},
"DeleteSeriesDto": { "DeleteSeriesDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -20347,6 +20437,7 @@
"UpdateLibraryDto": { "UpdateLibraryDto": {
"required": [ "required": [
"allowScrobbling", "allowScrobbling",
"excludePatterns",
"fileGroupTypes", "fileGroupTypes",
"folders", "folders",
"folderWatching", "folderWatching",
@ -20428,8 +20519,7 @@
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "A set of Glob patterns that the scanner will exclude processing", "description": "A set of Glob patterns that the scanner will exclude processing"
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -20843,7 +20933,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"tag": { "tag": {
"$ref": "#/components/schemas/CollectionTagDto" "$ref": "#/components/schemas/AppUserCollectionDto"
}, },
"seriesIdsToRemove": { "seriesIdsToRemove": {
"type": "array", "type": "array",