mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
MAL Interest Stacks (#2932)
This commit is contained in:
parent
29eb65c783
commit
b23300b1a4
@ -11,8 +11,8 @@
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" 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.runner.visualstudio" Version="2.5.8">
|
||||
<PackageReference Include="xunit" Version="2.8.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
@ -53,7 +53,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="31.0.3" />
|
||||
<PackageReference Include="CsvHelper" Version="32.0.1" />
|
||||
<PackageReference Include="MailKit" Version="4.5.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@ -66,10 +66,10 @@
|
||||
<PackageReference Include="Flurl" Version="3.0.7" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<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.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="Hangfire.AspNetCore" Version="1.8.12" />
|
||||
<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.File" Version="5.0.0" />
|
||||
<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="SonarAnalyzer.CSharp" Version="9.23.2.88755">
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.24.0.89429">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<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.IO.Abstractions" Version="21.0.2" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.4" />
|
||||
@ -194,6 +194,7 @@
|
||||
<Content Include="EmailTemplates\**">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Folder Include="Extensions\KavitaPlus\" />
|
||||
<None Include="I18N\**" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -898,8 +898,6 @@ public class AccountController : BaseApiController
|
||||
{
|
||||
|
||||
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);
|
||||
if (user == null)
|
||||
{
|
||||
@ -914,11 +912,7 @@ public class AccountController : BaseApiController
|
||||
if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed)
|
||||
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 emailLink = await _emailService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email);
|
||||
@ -927,6 +921,13 @@ public class AccountController : BaseApiController
|
||||
await _unitOfWork.CommitAsync();
|
||||
_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;
|
||||
BackgroundJob.Enqueue(() => _emailService.SendForgotPasswordEmail(new PasswordResetEmailDto()
|
||||
{
|
||||
|
@ -12,8 +12,11 @@ using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
@ -28,15 +31,23 @@ public class CollectionController : BaseApiController
|
||||
private readonly ICollectionTagService _collectionService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IExternalMetadataService _externalMetadataService;
|
||||
private readonly ISmartCollectionSyncService _collectionSyncService;
|
||||
private readonly ILogger<CollectionController> _logger;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
/// <inheritdoc />
|
||||
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
|
||||
ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
|
||||
ILocalizationService localizationService, IExternalMetadataService externalMetadataService,
|
||||
ISmartCollectionSyncService collectionSyncService, ILogger<CollectionController> logger,
|
||||
IEventHub eventHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_collectionService = collectionService;
|
||||
_localizationService = localizationService;
|
||||
_externalMetadataService = externalMetadataService;
|
||||
_collectionSyncService = collectionSyncService;
|
||||
_logger = logger;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -49,6 +60,18 @@ public class CollectionController : BaseApiController
|
||||
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>
|
||||
/// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned)
|
||||
/// </summary>
|
||||
@ -86,6 +109,8 @@ public class CollectionController : BaseApiController
|
||||
{
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@ -129,12 +154,12 @@ public class CollectionController : BaseApiController
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Promote/UnPromote multiple collections in one go
|
||||
/// Delete multiple collections in one go
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[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
|
||||
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"));
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList());
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList(), false);
|
||||
foreach (var s in series)
|
||||
{
|
||||
if (tag.Items.Contains(s)) continue;
|
||||
@ -253,4 +278,45 @@ public class CollectionController : BaseApiController
|
||||
{
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
@ -36,4 +36,12 @@ public class AppUserCollectionDto
|
||||
/// For Non-Kavita sourced collections, the url to sync from
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace API.DTOs.Collection;
|
||||
|
||||
public class DeleteCollectionsDto
|
||||
{
|
||||
[Required]
|
||||
public IList<int> CollectionIds { get; set; }
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
namespace API.DTOs.CollectionTags;
|
||||
using System;
|
||||
|
||||
namespace API.DTOs.CollectionTags;
|
||||
|
||||
[Obsolete("Use AppUserCollectionDto")]
|
||||
public class CollectionTagDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
@ -1,9 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Collection;
|
||||
|
||||
namespace API.DTOs.CollectionTags;
|
||||
|
||||
public class UpdateSeriesForTagDto
|
||||
{
|
||||
public CollectionTagDto Tag { get; init; } = default!;
|
||||
public AppUserCollectionDto Tag { get; init; } = default!;
|
||||
public IEnumerable<int> SeriesIdsToRemove { get; init; } = default!;
|
||||
}
|
||||
|
@ -36,5 +36,6 @@ public class UpdateLibraryDto
|
||||
/// <summary>
|
||||
/// A set of Glob patterns that the scanner will exclude processing
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ICollection<string> ExcludePatterns { get; init; }
|
||||
}
|
||||
|
3025
API/Data/Migrations/20240503120147_SmartCollectionFields.Designer.cs
generated
Normal file
3025
API/Data/Migrations/20240503120147_SmartCollectionFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
API/Data/Migrations/20240503120147_SmartCollectionFields.cs
Normal file
39
API/Data/Migrations/20240503120147_SmartCollectionFields.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
@ -224,6 +224,9 @@ namespace API.Data.Migrations
|
||||
b.Property<DateTime>("LastSyncUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("MissingSeriesFromSource")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedTitle")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -242,6 +245,9 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TotalSourceCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
@ -9,6 +9,7 @@ using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Extensions.QueryExtensions.Filtering;
|
||||
using API.Services.Plus;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -57,6 +58,7 @@ public interface ICollectionTagRepository
|
||||
Task<IList<AppUserCollection>> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None);
|
||||
Task UpdateCollectionAgeRating(AppUserCollection tag);
|
||||
Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None);
|
||||
Task<IList<AppUserCollection>> GetAllCollectionsForSyncing(DateTime expirationTime);
|
||||
}
|
||||
public class CollectionTagRepository : ICollectionTagRepository
|
||||
{
|
||||
@ -207,6 +209,16 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||
.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)
|
||||
{
|
||||
|
@ -73,6 +73,7 @@ public interface ISeriesRepository
|
||||
void Update(Series series);
|
||||
void Remove(Series series);
|
||||
void Remove(IEnumerable<Series> series);
|
||||
void Detach(Series series);
|
||||
Task<bool> DoesSeriesNameExistInLibrary(string name, int libraryId, MangaFormat format);
|
||||
/// <summary>
|
||||
/// Adds user information like progress, ratings, etc
|
||||
@ -96,7 +97,7 @@ public interface ISeriesRepository
|
||||
Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId);
|
||||
Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
|
||||
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<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
|
||||
/// <summary>
|
||||
@ -138,6 +139,7 @@ public interface ISeriesRepository
|
||||
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
|
||||
int userId, SeriesIncludes includes = SeriesIncludes.None);
|
||||
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,
|
||||
MangaFormat format);
|
||||
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
|
||||
@ -204,6 +206,11 @@ public class SeriesRepository : ISeriesRepository
|
||||
_context.Series.RemoveRange(series);
|
||||
}
|
||||
|
||||
public void Detach(Series series)
|
||||
{
|
||||
_context.Entry(series).State = EntityState.Detached;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns if a series name and format exists already in a library
|
||||
/// </summary>
|
||||
@ -531,15 +538,19 @@ public class SeriesRepository : ISeriesRepository
|
||||
/// Returns Full Series including all external links
|
||||
/// </summary>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <param name="fullSeries">Include all the includes or just the Series</param>
|
||||
/// <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
|
||||
.Include(s => s.Volumes)
|
||||
var query = _context.Series
|
||||
.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.Metadata)
|
||||
.ThenInclude(m => m.CollectionTags)
|
||||
|
||||
|
||||
.Include(s => s.ExternalSeriesMetadata)
|
||||
|
||||
@ -549,9 +560,6 @@ public class SeriesRepository : ISeriesRepository
|
||||
.ThenInclude(e => e.ExternalReviews)
|
||||
.Include(s => s.ExternalSeriesMetadata)
|
||||
.ThenInclude(e => e.ExternalRecommendations)
|
||||
|
||||
.Where(s => seriesIds.Contains(s.Id))
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@ -1670,6 +1678,26 @@ public class SeriesRepository : ISeriesRepository
|
||||
#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,
|
||||
MangaFormat format)
|
||||
{
|
||||
|
@ -52,7 +52,14 @@ public class AppUserCollection : IEntityDate
|
||||
/// For Non-Kavita sourced collections, the url to sync from
|
||||
/// </summary>
|
||||
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
|
||||
public AppUser AppUser { get; set; } = null!;
|
||||
|
@ -74,6 +74,7 @@ public static class ApplicationServiceExtensions
|
||||
services.AddScoped<IScrobblingService, ScrobblingService>();
|
||||
services.AddScoped<ILicenseService, LicenseService>();
|
||||
services.AddScoped<IExternalMetadataService, ExternalMetadataService>();
|
||||
services.AddScoped<ISmartCollectionSyncService, SmartCollectionSyncService>();
|
||||
|
||||
services.AddSqLite();
|
||||
services.AddSignalR(opt => opt.EnableDetailedErrors = true);
|
||||
|
@ -69,4 +69,10 @@ public class AppUserCollectionBuilder : IEntityBuilder<AppUserCollection>
|
||||
_collection.CoverImage = cover;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AppUserCollectionBuilder WithSourceUrl(string url)
|
||||
{
|
||||
_collection.SourceUrl = url;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
@ -9,11 +9,16 @@ public static class LibraryTypeHelper
|
||||
{
|
||||
public static MediaFormat GetFormat(LibraryType libraryType)
|
||||
{
|
||||
// TODO: Refactor this to an extension on LibraryType
|
||||
return libraryType switch
|
||||
{
|
||||
LibraryType.Manga => MediaFormat.Manga,
|
||||
LibraryType.Comic => MediaFormat.Comic,
|
||||
LibraryType.LightNovel => MediaFormat.LightNovel,
|
||||
LibraryType.Book => MediaFormat.LightNovel,
|
||||
LibraryType.Image => MediaFormat.Manga,
|
||||
LibraryType.ComicVine => MediaFormat.Comic,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -49,6 +49,8 @@
|
||||
"collection-deleted": "Collection deleted",
|
||||
"generic-error": "Something went wrong, please try again",
|
||||
"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",
|
||||
"generic-device-create": "There was an error when creating the device",
|
||||
|
@ -82,7 +82,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
Reviews = ArraySegment<UserReviewDto>.Empty
|
||||
};
|
||||
// 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)
|
||||
{
|
||||
|
266
API/Services/Plus/SmartCollectionSyncService.cs
Normal file
266
API/Services/Plus/SmartCollectionSyncService.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -57,6 +57,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
private readonly IScrobblingService _scrobblingService;
|
||||
private readonly ILicenseService _licenseService;
|
||||
private readonly IExternalMetadataService _externalMetadataService;
|
||||
private readonly ISmartCollectionSyncService _smartCollectionSyncService;
|
||||
|
||||
public static BackgroundJobServer Client => new ();
|
||||
public const string ScanQueue = "scan";
|
||||
@ -74,6 +75,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
public const string ProcessProcessedScrobblingEventsId = "process-processed-scrobbling-events";
|
||||
public const string LicenseCheckId = "license-check";
|
||||
public const string KavitaPlusDataRefreshId = "kavita+-data-refresh";
|
||||
public const string KavitaPlusStackSyncId = "kavita+-stack-sync";
|
||||
|
||||
private static readonly ImmutableArray<string> ScanTasks =
|
||||
["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"];
|
||||
@ -91,7 +93,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
|
||||
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
|
||||
IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService,
|
||||
IExternalMetadataService externalMetadataService)
|
||||
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_logger = logger;
|
||||
@ -109,6 +111,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
_scrobblingService = scrobblingService;
|
||||
_licenseService = licenseService;
|
||||
_externalMetadataService = externalMetadataService;
|
||||
_smartCollectionSyncService = smartCollectionSyncService;
|
||||
}
|
||||
|
||||
public async Task ScheduleTasks()
|
||||
@ -186,6 +189,10 @@ public class TaskScheduler : ITaskScheduler
|
||||
RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId,
|
||||
() => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 4)),
|
||||
RecurringJobOptions);
|
||||
|
||||
RecurringJob.AddOrUpdate(KavitaPlusStackSyncId,
|
||||
() => _smartCollectionSyncService.Sync(), Cron.Daily(Rnd.Next(1, 4)),
|
||||
RecurringJobOptions);
|
||||
}
|
||||
|
||||
#region StatsTasks
|
||||
|
@ -32,7 +32,7 @@ public interface IProcessSeries
|
||||
Task Prime();
|
||||
|
||||
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>
|
||||
@ -99,7 +99,7 @@ public class ProcessSeries : IProcessSeries
|
||||
_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;
|
||||
|
||||
@ -107,7 +107,7 @@ public class ProcessSeries : IProcessSeries
|
||||
var scanWatch = Stopwatch.StartNew();
|
||||
var seriesName = parsedInfos[0].Series;
|
||||
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);
|
||||
|
||||
// Check if there is a Series
|
||||
|
@ -147,7 +147,7 @@ public class ScannerService : IScannerService
|
||||
{
|
||||
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>>();
|
||||
|
||||
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);
|
||||
var (scanElapsedTime, processedSeries) = await ScanFiles(library, new []{ folderPath },
|
||||
@ -309,15 +309,18 @@ public class ScannerService : IScannerService
|
||||
await _processSeries.Prime();
|
||||
}
|
||||
|
||||
var seriesLeftToProcess = toProcess.Count;
|
||||
foreach (var pSeries in toProcess)
|
||||
{
|
||||
// Process Series
|
||||
await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, bypassFolderOptimizationChecks);
|
||||
await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks);
|
||||
seriesLeftToProcess--;
|
||||
}
|
||||
|
||||
_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
|
||||
await _eventHub.SendMessageAsync(MessageFactory.ScanSeries,
|
||||
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");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.CacheDirectory));
|
||||
@ -589,12 +593,14 @@ public class ScannerService : IScannerService
|
||||
|
||||
var totalFiles = 0;
|
||||
//var tasks = new List<Task>();
|
||||
var seriesLeftToProcess = toProcess.Count;
|
||||
foreach (var pSeries in toProcess)
|
||||
{
|
||||
totalFiles += parsedSeries[pSeries].Count;
|
||||
//tasks.Add(_processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, forceUpdate));
|
||||
// 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);
|
||||
|
@ -41,9 +41,9 @@ public static class MessageFactory
|
||||
/// </summary>
|
||||
public const string OnlineUsers = "OnlineUsers";
|
||||
/// <summary>
|
||||
/// When a series is added to a collection
|
||||
/// When a Collection has been updated
|
||||
/// </summary>
|
||||
public const string SeriesAddedToCollection = "SeriesAddedToCollection";
|
||||
public const string CollectionUpdated = "CollectionUpdated";
|
||||
/// <summary>
|
||||
/// Event sent out during backing up the database
|
||||
/// </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
|
||||
{
|
||||
Name = SeriesAddedToCollection,
|
||||
Name = CollectionUpdated,
|
||||
Progress = ProgressType.None,
|
||||
EventType = ProgressEventType.Single,
|
||||
Body = new
|
||||
{
|
||||
TagId = tagId,
|
||||
SeriesId = seriesId
|
||||
TagId = collectionId,
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -428,7 +428,7 @@ public static class MessageFactory
|
||||
/// <param name="eventType"></param>
|
||||
/// <param name="seriesName"></param>
|
||||
/// <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()
|
||||
{
|
||||
@ -437,7 +437,12 @@ public static class MessageFactory
|
||||
SubTitle = seriesName,
|
||||
EventType = eventType,
|
||||
Progress = ProgressType.Indeterminate,
|
||||
Body = null
|
||||
Body = new
|
||||
{
|
||||
SeriesName = seriesName,
|
||||
LibraryName = libraryName,
|
||||
LeftToProcess = totalToProcess
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -139,7 +139,7 @@ public class Startup
|
||||
{
|
||||
Version = BuildInfo.Version.ToString(),
|
||||
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
|
||||
{
|
||||
Name = "GPL-3.0",
|
||||
|
@ -14,10 +14,10 @@
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" 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>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="xunit.assert" Version="2.7.1" />
|
||||
<PackageReference Include="xunit.assert" Version="2.8.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
18
Kavita.Common/RateLimitException.cs
Normal file
18
Kavita.Common/RateLimitException.cs
Normal 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) { }
|
||||
}
|
@ -24,11 +24,11 @@
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"builder": "@angular-devkit/build-angular:browser-esbuild",
|
||||
"options": {
|
||||
"outputPath": "dist",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
|
44
UI/Web/package-lock.json
generated
44
UI/Web/package-lock.json
generated
@ -21,7 +21,7 @@
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"@iharbeck/ngx-virtual-scroller": "^17.0.2",
|
||||
"@iplab/ngx-file-upload": "^17.1.0",
|
||||
"@microsoft/signalr": "^7.0.12",
|
||||
"@microsoft/signalr": "^8.0.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@ngneat/transloco": "^6.0.4",
|
||||
"@ngneat/transloco-locale": "^5.1.2",
|
||||
@ -504,7 +504,6 @@
|
||||
"version": "17.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz",
|
||||
"integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.23.9",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@ -532,7 +531,6 @@
|
||||
"version": "7.23.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz",
|
||||
"integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
@ -561,14 +559,12 @@
|
||||
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
},
|
||||
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
@ -749,7 +745,6 @@
|
||||
"version": "7.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
|
||||
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
@ -778,14 +773,12 @@
|
||||
"node_modules/@babel/core/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
@ -3284,9 +3277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/signalr": {
|
||||
"version": "7.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.14.tgz",
|
||||
"integrity": "sha512-dnS7gSJF5LxByZwJaj82+F1K755ya7ttPT+JnSeCBef3sL8p8FBkHePXphK8NSuOquIb7vsphXWa28A+L2SPpw==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.0.tgz",
|
||||
"integrity": "sha512-K/wS/VmzRWePCGqGh8MU8OWbS1Zvu7DG7LSJS62fBB8rJUXwwj4axQtqrAAwKGUZHQF6CuteuQR9xMsVpM2JNA==",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"eventsource": "^2.0.2",
|
||||
@ -5629,7 +5622,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
@ -5642,7 +5634,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
@ -5914,7 +5905,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
@ -6226,7 +6216,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
@ -6518,8 +6507,7 @@
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
@ -7421,7 +7409,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
@ -7431,7 +7418,6 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@ -8540,7 +8526,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -9222,7 +9207,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
@ -11063,7 +11047,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -12453,7 +12436,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
@ -12465,7 +12447,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
@ -12476,8 +12457,7 @@
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
|
||||
},
|
||||
"node_modules/regenerate": {
|
||||
"version": "1.4.2",
|
||||
@ -12945,7 +12925,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.71.1",
|
||||
@ -13064,7 +13044,6 @@
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
|
||||
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
@ -13079,7 +13058,6 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
@ -13090,8 +13068,7 @@
|
||||
"node_modules/semver/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.18.0",
|
||||
@ -14222,7 +14199,6 @@
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
@ -28,7 +28,7 @@
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"@iharbeck/ngx-virtual-scroller": "^17.0.2",
|
||||
"@iplab/ngx-file-upload": "^17.1.0",
|
||||
"@microsoft/signalr": "^7.0.12",
|
||||
"@microsoft/signalr": "^8.0.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@ngneat/transloco": "^6.0.4",
|
||||
"@ngneat/transloco-locale": "^5.1.2",
|
||||
|
@ -1,19 +1,6 @@
|
||||
import {ScrobbleProvider} from "../_services/scrobbling.service";
|
||||
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 {
|
||||
id: number;
|
||||
title: string;
|
||||
@ -28,6 +15,7 @@ export interface UserCollection {
|
||||
owner: string;
|
||||
source: ScrobbleProvider;
|
||||
sourceUrl: string | null;
|
||||
totalSourceCount: number;
|
||||
missingSeriesFromSource: string | null;
|
||||
ageRating: AgeRating;
|
||||
|
||||
}
|
||||
|
19
UI/Web/src/app/_pipes/safe-url.pipe.ts
Normal file
19
UI/Web/src/app/_pipes/safe-url.pipe.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
@ -64,4 +64,8 @@ export class CollectionTagService {
|
||||
if (isPromotionAction) return canPromote;
|
||||
return true;
|
||||
}
|
||||
|
||||
importStack(stack: MalStack) {
|
||||
return this.httpClient.post(this.baseUrl + 'collection/import-stack', stack, TextResonse);
|
||||
}
|
||||
}
|
||||
|
@ -17,7 +17,10 @@ export enum EVENTS {
|
||||
SeriesRemoved = 'SeriesRemoved',
|
||||
ScanLibraryProgress = 'ScanLibraryProgress',
|
||||
OnlineUsers = 'OnlineUsers',
|
||||
SeriesAddedToCollection = 'SeriesAddedToCollection',
|
||||
/**
|
||||
* When a Collection has been updated
|
||||
*/
|
||||
CollectionUpdated = 'CollectionUpdated',
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@ -141,7 +148,7 @@ export class MessageHubService {
|
||||
accessTokenFactory: () => user.token
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
//.withStatefulReconnect() // Needs @microsoft/signalr@8
|
||||
.withStatefulReconnect()
|
||||
.build();
|
||||
|
||||
this.hubConnection
|
||||
@ -214,9 +221,9 @@ export class MessageHubService {
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => {
|
||||
this.hubConnection.on(EVENTS.CollectionUpdated, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.SeriesAddedToCollection,
|
||||
event: EVENTS.CollectionUpdated,
|
||||
payload: resp.body
|
||||
});
|
||||
});
|
||||
|
@ -10,7 +10,7 @@
|
||||
<div class="offcanvas-body">
|
||||
<ng-container *ngIf="CoverUrl as coverUrl">
|
||||
<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>
|
||||
</ng-container>
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4">
|
||||
<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-template #localPerson>
|
||||
<i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i>
|
||||
|
@ -5,37 +5,42 @@
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<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 === ''">
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
<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 id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
|
||||
<div *ngIf="email?.errors?.required">
|
||||
{{t('required-field')}}
|
||||
@if (emailLink === '') {
|
||||
<form [formGroup]="inviteForm">
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
<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 id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
|
||||
<div *ngIf="email?.errors?.required">
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6">
|
||||
<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 class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
|
||||
</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 !== ''">
|
||||
<h4>{{t('setup-user-title')}}</h4>
|
||||
|
@ -48,51 +48,53 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabID.Series">
|
||||
<a ngbNavLink>{{t(TabID.Series)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@if (!isLoading) {
|
||||
<div class="list-group">
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
|
||||
@if (tag.source === ScrobbleProvider.Kavita) {
|
||||
<li [ngbNavItem]="TabID.Series">
|
||||
<a ngbNavLink>{{t(TabID.Series)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@if (!isLoading) {
|
||||
<div class="list-group">
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
|
||||
</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>
|
||||
</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>
|
||||
<ul>
|
||||
@for (item of series | filter: filterList; let i = $index; track item.id) {
|
||||
<li class="list-group-item">
|
||||
<div class="form-check">
|
||||
<input id="series-{{i}}" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
|
||||
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
|
||||
<label for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
|
||||
</div>
|
||||
</li>
|
||||
<ul>
|
||||
@for (item of series | filter: filterList; let i = $index; track item.id) {
|
||||
<li class="list-group-item">
|
||||
<div class="form-check">
|
||||
<input id="series-{{i}}" type="checkbox" class="form-check-input" [disabled]="tag.source !== ScrobbleProvider.Kavita"
|
||||
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
|
||||
<label for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (pagination && series.length !== 0 && pagination.totalPages > 1) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<ngb-pagination
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
|
||||
[collectionSize]="pagination.totalItems"></ngb-pagination>
|
||||
</div>
|
||||
}
|
||||
</ul>
|
||||
@if (pagination && series.length !== 0 && pagination.totalPages > 1) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<ngb-pagination
|
||||
[(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>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.CoverImage">
|
||||
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
||||
@ -102,6 +104,43 @@
|
||||
(resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</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>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
|
||||
|
@ -24,26 +24,34 @@ import {LibraryService} from 'src/app/_services/library.service';
|
||||
import {SeriesService} from 'src/app/_services/series.service';
|
||||
import {UploadService} from 'src/app/_services/upload.service';
|
||||
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 {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
|
||||
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
||||
import {ScrobbleError} from "../../../_models/scrobbling/scrobble-error";
|
||||
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 {
|
||||
General = 'general-tab',
|
||||
CoverImage = 'cover-image-tab',
|
||||
Series = 'series-tab'
|
||||
Series = 'series-tab',
|
||||
Info = 'info-tab'
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-collection-tags',
|
||||
standalone: true,
|
||||
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',
|
||||
styleUrls: ['./edit-collection-tags.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -2,9 +2,9 @@
|
||||
<div class="card-item-container card {{selected ? 'selected-highlight' : ''}}">
|
||||
<div class="overlay" (click)="handleClick($event)">
|
||||
@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) {
|
||||
<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">
|
||||
|
@ -1,17 +1,17 @@
|
||||
<div class="list-item-container d-flex flex-row g-0 mb-2 p-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 class="flex-grow-1">
|
||||
<div class="g-0">
|
||||
<h5 class="mb-0">
|
||||
<ng-content select="[title]"></ng-content>
|
||||
</h5>
|
||||
<ng-container *ngIf="summary && summary.length > 0">
|
||||
@if (summary && summary.length > 0) {
|
||||
<div class="mt-2 ps-2">
|
||||
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,7 +3,7 @@
|
||||
<div class="card-item-container card clickable">
|
||||
<div class="overlay" (click)="handleClick()">
|
||||
<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>
|
||||
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
<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="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>
|
||||
<span class="download">
|
||||
<app-download-indicator [download$]="download$"></app-download-indicator>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class="card-item-container card">
|
||||
<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>
|
||||
<ng-container *ngIf="entity.title | safeHtml as info">
|
||||
|
@ -11,15 +11,34 @@
|
||||
</div>
|
||||
|
||||
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="collectionTag !== undefined" #scrollingBlock>
|
||||
<div class="row mb-3" *ngIf="summary.length > 0">
|
||||
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
|
||||
<app-image maxWidth="481px" [imageUrl]="imageService.getCollectionCoverImage(collectionTag.id)"></app-image>
|
||||
@if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) {
|
||||
<div class="row mb-3">
|
||||
<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 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-card-detail-layout *ngIf="filter"
|
||||
|
@ -41,3 +41,11 @@ h2 {
|
||||
margin-bottom: 0;
|
||||
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;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {DOCUMENT, NgIf, NgStyle} from '@angular/common';
|
||||
import {DatePipe, DOCUMENT, NgIf, NgStyle} from '@angular/common';
|
||||
import {
|
||||
AfterContentChecked,
|
||||
ChangeDetectionStrategy,
|
||||
@ -15,14 +15,14 @@ import {
|
||||
} from '@angular/core';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
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 {debounceTime, take} from 'rxjs/operators';
|
||||
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
||||
import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
|
||||
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
|
||||
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 {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-to-collection-event';
|
||||
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 {AccountService} from "../../../_services/account.service";
|
||||
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({
|
||||
selector: 'app-collection-detail',
|
||||
@ -61,7 +67,7 @@ import {User} from "../../../_models/user";
|
||||
styleUrls: ['./collection-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
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 {
|
||||
|
||||
@ -82,7 +88,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
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 => {
|
||||
if (event.event == EVENTS.SeriesAddedToCollection) {
|
||||
if (event.event == EVENTS.CollectionUpdated) {
|
||||
const collectionEvent = event.payload as SeriesAddedToCollectionEvent;
|
||||
if (collectionEvent.tagId === this.collectionTag.id) {
|
||||
this.loadPage();
|
||||
@ -326,4 +332,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.loadPage();
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly ScrobbleProvider = ScrobbleProvider;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
}
|
||||
|
@ -9,9 +9,12 @@
|
||||
|
||||
<ul>
|
||||
@for(stack of stacks; track stack.url) {
|
||||
<li>
|
||||
<div><a [href]="stack.url" rel="noreferrer noopener" target="_blank">{{stack.title}}</a></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>
|
||||
<li class="mb-2">
|
||||
<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>
|
||||
}
|
||||
</ul>
|
||||
@ -31,6 +34,9 @@
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>-->
|
||||
<!-- </div>-->
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-secondary" (click)="ngbModal.dismiss()">{{t('close')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
@ -1,10 +1,15 @@
|
||||
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 {Select2Module} from "ng-select2-component";
|
||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {CollectionTagService} from "../../../_services/collection-tag.service";
|
||||
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({
|
||||
selector: 'app-import-mal-collection-modal',
|
||||
@ -12,7 +17,8 @@ import {MalStack} from "../../../_models/collection/mal-stack";
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
Select2Module
|
||||
Select2Module,
|
||||
DecimalPipe
|
||||
],
|
||||
templateUrl: './import-mal-collection-modal.component.html',
|
||||
styleUrl: './import-mal-collection-modal.component.scss',
|
||||
@ -21,19 +27,41 @@ import {MalStack} from "../../../_models/collection/mal-stack";
|
||||
export class ImportMalCollectionModalComponent {
|
||||
|
||||
protected readonly ngbModal = inject(NgbActiveModal);
|
||||
protected readonly collectionService = inject(CollectionTagService);
|
||||
protected readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly collectionService = inject(CollectionTagService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
stacks: Array<MalStack> = [];
|
||||
isLoading = true;
|
||||
collectionMap: {[key: string]: UserCollection | MalStack} = {};
|
||||
|
||||
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'));
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
@ -23,71 +23,81 @@
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="debugMode">
|
||||
<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>
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<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>
|
||||
@if (debugMode) {
|
||||
<ng-container>
|
||||
<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>
|
||||
<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 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="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>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item 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>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item info">
|
||||
<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>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="d-inline-flex">
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item 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>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item info">
|
||||
<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>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="d-inline-flex">
|
||||
<span class="download">
|
||||
<app-circular-loader [currentValue]="25" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
10% downloaded
|
||||
</span>
|
||||
</span>
|
||||
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
|
||||
</div>
|
||||
<div class="accent-text">PDFs</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
|
||||
</div>
|
||||
<div class="accent-text">PDFs</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
|
||||
<!-- Progress Events-->
|
||||
<ng-container *ngIf="progressEvents$ | async as 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">
|
||||
<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" style="height: 5px;" *ngIf="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>
|
||||
@if(message.progress === 'indeterminate') {
|
||||
<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>
|
||||
<ng-template #progressEvent>
|
||||
@ -165,12 +175,17 @@
|
||||
</ng-container>
|
||||
|
||||
<!-- Online Users -->
|
||||
<ng-container *ngIf="messageHub.onlineUsers$ | async as onlineUsers">
|
||||
<li class="list-group-item dark-menu-item" *ngIf="onlineUsers.length > 1">
|
||||
<div>{{t('users-online-count', {num: onlineUsers.length})}}</div>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item" *ngIf="debugMode">{{t('active-events-title')}} {{activeEvents}}</li>
|
||||
</ng-container>
|
||||
@if (messageHub.onlineUsers$ | async; as onlineUsers) {
|
||||
@if (onlineUsers.length > 1) {
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div>{{t('users-online-count', {num: onlineUsers.length})}}</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (debugMode) {
|
||||
<li class="list-group-item dark-menu-item">{{t('active-events-title')}} {{activeEvents}}</li>
|
||||
}
|
||||
}
|
||||
|
||||
<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>
|
||||
|
@ -15,10 +15,10 @@
|
||||
}
|
||||
|
||||
::ng-deep .nav-events {
|
||||
|
||||
|
||||
.popover-body {
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
padding: 0px;
|
||||
box-shadow: 0px 0px 12px rgb(0 0 0 / 75%);
|
||||
max-height: calc(100vh - 60px);
|
||||
@ -42,7 +42,7 @@
|
||||
width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
overflow:hidden;
|
||||
white-space:nowrap;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:hover {
|
||||
@ -76,7 +76,7 @@
|
||||
|
||||
.update-available {
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
i.fa {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
@ -119,4 +119,4 @@
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -39,7 +39,7 @@
|
||||
|
||||
<div class="row mb-2">
|
||||
<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 class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="row g-0 mb-3">
|
||||
|
@ -1,7 +1,7 @@
|
||||
<ng-container *transloco="let t; read: 'reading-list-item'">
|
||||
<div class="d-flex flex-row g-0 mb-2 reading-list-item">
|
||||
<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) {
|
||||
<div class="not-read-badge" ></div>
|
||||
}
|
||||
|
@ -58,7 +58,7 @@
|
||||
<div class="to-read-counter" *ngIf="unreadCount > 0 && unreadCount !== totalCount">
|
||||
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
|
||||
</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">
|
||||
<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>
|
||||
|
@ -1,18 +1,19 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
Renderer2,
|
||||
RendererStyleFlags2,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { CoverUpdateEvent } from 'src/app/_models/events/cover-update-event';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import {CoverUpdateEvent} from 'src/app/_models/events/cover-update-event';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {CommonModule, NgOptimizedImage} from "@angular/common";
|
||||
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
|
||||
*/
|
||||
@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
|
||||
*/
|
||||
@ -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
|
||||
*/
|
||||
@Input() styles: string = '';
|
||||
@Input() styles: {[key: string]: string} = {};
|
||||
@Input() errorImage: string = this.imageService.errorImage;
|
||||
|
||||
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;
|
||||
@ -110,41 +91,25 @@ export class ImageComponent implements OnChanges {
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
if (this.width != '') {
|
||||
if (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);
|
||||
}
|
||||
|
||||
if (this.maxWidth != '') {
|
||||
this.renderer.setStyle(this.imgElem.nativeElement, 'max-width', this.maxWidth);
|
||||
}
|
||||
|
||||
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);
|
||||
const styleKeys = Object.keys(this.styles);
|
||||
if (styleKeys.length !== 0) {
|
||||
styleKeys.forEach(key => {
|
||||
this.renderer.setStyle(this.imgElem.nativeElement, key, this.styles[key], RendererStyleFlags2.Important);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.classes != '') {
|
||||
this.renderer.addClass(this.imgElem.nativeElement, this.classes);
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="tagbadge cursor clickable" *ngIf="person !== undefined">
|
||||
<div class="d-flex">
|
||||
<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-template #localPerson>
|
||||
<i class="fa fa-user-circle align-self-center me-2" aria-hidden="true"></i>
|
||||
|
@ -65,7 +65,7 @@ export class SideNavComponent implements OnInit {
|
||||
homeActions = [
|
||||
{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-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 = '';
|
||||
|
@ -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-header text-center">
|
||||
{{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>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item" [ngClass]="{'underline': handleClick !== undefined}" *ngFor="let item of data" (click)="doClick(item)">
|
||||
<ng-container *ngIf="image && image(item) as url">
|
||||
<app-image *ngIf="url && url.length > 0" width="32px" maxHeight="32px" class="img-top me-1" [imageUrl]="url"></app-image>
|
||||
</ng-container>
|
||||
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value | compactNumber}} {{label}}</span>
|
||||
</li>
|
||||
@for(item of data; track item) {
|
||||
<li class="list-group-item" [ngClass]="{'underline': handleClick !== undefined}" (click)="doClick(item)">
|
||||
@if (image && image(item); as url) {
|
||||
@if (url && url.length > 0) {
|
||||
<app-image width="32px" [styles]="{'max-height': '32px'}" class="img-top me-1" [imageUrl]="url"></app-image>
|
||||
}
|
||||
}
|
||||
{{item.name}}
|
||||
@if (item.value >= 0) {
|
||||
<span class="float-end">{{item.value | compactNumber}} {{label}}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #tooltip></ng-template>
|
||||
}
|
||||
|
||||
<ng-template #tooltip>{{description}}</ng-template>
|
||||
|
@ -4,7 +4,7 @@ import { PieDataItem } from '../../_models/pie-data-item';
|
||||
import { CompactNumberPipe } from '../../../_pipes/compact-number.pipe';
|
||||
import { ImageComponent } from '../../../shared/image/image.component';
|
||||
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";
|
||||
|
||||
@Component({
|
||||
@ -13,7 +13,7 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
||||
styleUrls: ['./stat-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, NgbTooltip, NgFor, NgClass, ImageComponent, AsyncPipe, CompactNumberPipe, TranslocoDirective]
|
||||
imports: [NgbTooltip, NgClass, ImageComponent, AsyncPipe, CompactNumberPipe, TranslocoDirective]
|
||||
})
|
||||
export class StatListComponent {
|
||||
|
||||
|
@ -949,7 +949,7 @@
|
||||
"general-tab": "General",
|
||||
"metadata-tab": "Metadata",
|
||||
"cover-tab": "Cover",
|
||||
"info-tab": "Info",
|
||||
"info-tab": "{{edit-series-modal.info-tab}}",
|
||||
"progress-tab": "Progress",
|
||||
"no-summary": "No Summary available.",
|
||||
"writers-title": "{{series-metadata-detail.writers-title}}",
|
||||
@ -1167,7 +1167,7 @@
|
||||
"encode-as-description-part-2": "Can I Use WebP?",
|
||||
"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.",
|
||||
"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-tooltip": "All media Kavita manages (covers, bookmarks, favicons) will be encoded as this type.",
|
||||
"bookmark-dir-label": "Bookmarks Directory",
|
||||
@ -1373,6 +1373,7 @@
|
||||
"cancel": "{{common.cancel}}",
|
||||
"general-tab": "General",
|
||||
"cover-image-tab": "Cover Image",
|
||||
"info-tab": "{{edit-series-modal.info-tab}}",
|
||||
"series-tab": "Series",
|
||||
"name-label": "Name",
|
||||
"name-validation": "Name must be unique",
|
||||
@ -1381,7 +1382,11 @@
|
||||
"summary-label": "Summary",
|
||||
"deselect-all": "{{common.deselect-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": {
|
||||
@ -1423,7 +1428,9 @@
|
||||
"no-data": "There are no items. Try adding a series.",
|
||||
"no-data-filtered": "No items match your current filter.",
|
||||
"title-alt": "Kavita - {{collectionName}} Collection",
|
||||
"series-header": "Series"
|
||||
"series-header": "Series",
|
||||
"sync-progress": "Series Collected: {{title}}",
|
||||
"last-sync": "Last Sync: {{date}}"
|
||||
},
|
||||
|
||||
"all-collections": {
|
||||
@ -1490,7 +1497,8 @@
|
||||
"close": "{{common.close}}",
|
||||
"users-online-count": "{{num}} Users online",
|
||||
"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": {
|
||||
@ -2176,7 +2184,8 @@
|
||||
"collections-unpromoted": "Collections un-promoted",
|
||||
"confirm-delete-collections": "Are you sure you want to delete multiple collections?",
|
||||
"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"
|
||||
|
||||
},
|
||||
|
||||
|
166
openapi.json
166
openapi.json
@ -2,12 +2,12 @@
|
||||
"openapi": "3.0.1",
|
||||
"info": {
|
||||
"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": {
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.8.0.9"
|
||||
"version": "0.8.1.0"
|
||||
},
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -1548,23 +1598,23 @@
|
||||
"tags": [
|
||||
"Collection"
|
||||
],
|
||||
"summary": "Promote/UnPromote multiple collections in one go",
|
||||
"summary": "Delete multiple collections in one go",
|
||||
"requestBody": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PromoteCollectionsDto"
|
||||
"$ref": "#/components/schemas/DeleteCollectionsDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PromoteCollectionsDto"
|
||||
"$ref": "#/components/schemas/DeleteCollectionsDto"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"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": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@ -13296,6 +13379,16 @@
|
||||
"description": "For Non-Kavita sourced collections, the url to sync from",
|
||||
"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": {
|
||||
"$ref": "#/components/schemas/AppUser"
|
||||
},
|
||||
@ -13380,6 +13473,16 @@
|
||||
"type": "string",
|
||||
"description": "For Non-Kavita sourced collections, the url to sync from",
|
||||
"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
|
||||
@ -15240,35 +15343,6 @@
|
||||
},
|
||||
"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": {
|
||||
"required": [
|
||||
"email",
|
||||
@ -15538,6 +15612,22 @@
|
||||
"additionalProperties": false,
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -20347,6 +20437,7 @@
|
||||
"UpdateLibraryDto": {
|
||||
"required": [
|
||||
"allowScrobbling",
|
||||
"excludePatterns",
|
||||
"fileGroupTypes",
|
||||
"folders",
|
||||
"folderWatching",
|
||||
@ -20428,8 +20519,7 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "A set of Glob patterns that the scanner will exclude processing",
|
||||
"nullable": true
|
||||
"description": "A set of Glob patterns that the scanner will exclude processing"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@ -20843,7 +20933,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"$ref": "#/components/schemas/CollectionTagDto"
|
||||
"$ref": "#/components/schemas/AppUserCollectionDto"
|
||||
},
|
||||
"seriesIdsToRemove": {
|
||||
"type": "array",
|
||||
|
Loading…
x
Reference in New Issue
Block a user