This commit is contained in:
Joe Milazzo 2024-11-20 07:17:36 -06:00 committed by GitHub
parent cb810a2d8f
commit 3e3b6ba92b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1631 additions and 212 deletions

View File

@ -10,6 +10,7 @@ using API.Helpers;
using API.Helpers.Builders; using API.Helpers.Builders;
using API.Services; using API.Services;
using AutoMapper; using AutoMapper;
using Hangfire;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -48,6 +49,9 @@ public abstract class AbstractDbTest
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>()); var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper(); var mapper = config.CreateMapper();
// Set up Hangfire to use in-memory storage for testing
GlobalConfiguration.Configuration.UseInMemoryStorage();
_unitOfWork = new UnitOfWork(_context, mapper, null); _unitOfWork = new UnitOfWork(_context, mapper, null);
} }

File diff suppressed because it is too large Load Diff

View File

@ -159,4 +159,6 @@ public class SeriesRepositoryTests
} }
} }
// TODO: GetSeriesDtoForLibraryIdV2Async Tests (On Deck)
} }

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
@ -13,6 +14,7 @@ using API.Services.Tasks.Scanner.Parser;
using API.SignalR; using API.SignalR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Nager.ArticleNumber; using Nager.ArticleNumber;
namespace API.Controllers; namespace API.Controllers;
@ -22,12 +24,14 @@ public class ChapterController : BaseApiController
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService; private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub; private readonly IEventHub _eventHub;
private readonly ILogger<ChapterController> _logger;
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub) public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_localizationService = localizationService; _localizationService = localizationService;
_eventHub = eventHub; _eventHub = eventHub;
_logger = logger;
} }
/// <summary> /// <summary>
@ -84,6 +88,83 @@ public class ChapterController : BaseApiController
return Ok(true); return Ok(true);
} }
/// <summary>
/// Deletes multiple chapters and any volumes with no leftover chapters
/// </summary>
/// <param name="seriesId">The ID of the series</param>
/// <param name="chapterIds">The IDs of the chapters to be deleted</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("delete-multiple")]
public async Task<ActionResult<bool>> DeleteMultipleChapters([FromQuery] int seriesId, DeleteChaptersDto dto)
{
try
{
var chapterIds = dto.ChapterIds;
if (chapterIds == null || chapterIds.Count == 0)
{
return BadRequest("ChapterIds required");
}
// Fetch all chapters to be deleted
var chapters = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)).ToList();
// Group chapters by their volume
var volumesToUpdate = chapters.GroupBy(c => c.VolumeId).ToList();
var removedVolumes = new List<int>();
foreach (var volumeGroup in volumesToUpdate)
{
var volumeId = volumeGroup.Key;
var chaptersToDelete = volumeGroup.ToList();
// Fetch the volume
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
if (volume == null)
return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist"));
// Check if all chapters in the volume are being deleted
var isVolumeToBeRemoved = volume.Chapters.Count == chaptersToDelete.Count;
if (isVolumeToBeRemoved)
{
_unitOfWork.VolumeRepository.Remove(volume);
removedVolumes.Add(volume.Id);
}
else
{
// Remove only the specified chapters
_unitOfWork.ChapterRepository.Remove(chaptersToDelete);
}
}
if (!await _unitOfWork.CommitAsync()) return Ok(false);
// Send events for removed chapters
foreach (var chapter in chapters)
{
await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved,
MessageFactory.ChapterRemovedEvent(chapter.Id, seriesId), false);
}
// Send events for removed volumes
foreach (var volumeId in removedVolumes)
{
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved,
MessageFactory.VolumeRemovedEvent(volumeId, seriesId), false);
}
return Ok(true);
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occured while deleting chapters");
return BadRequest(_localizationService.Translate(User.GetUserId(), "generic-error"));
}
}
/// <summary> /// <summary>
/// Update chapter metadata /// Update chapter metadata
/// </summary> /// </summary>

View File

@ -110,18 +110,18 @@ public class DeviceController : BaseApiController
[HttpPost("send-to")] [HttpPost("send-to")]
public async Task<ActionResult> SendToDevice(SendToDeviceDto dto) public async Task<ActionResult> SendToDevice(SendToDeviceDto dto)
{ {
if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds")); var userId = User.GetUserId();
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId")); if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(userId, "greater-0", "ChapterIds"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId"));
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice(); var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
if (!isEmailSetup) if (!isEmailSetup)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email")); return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email"));
// // Validate that the device belongs to the user // // Validate that the device belongs to the user
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Devices); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Devices);
if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-unallowed")); if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(userId, "send-to-unallowed"));
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"), MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"started"), userId); "started"), userId);
@ -145,26 +145,30 @@ public class DeviceController : BaseApiController
} }
/// <summary>
/// Attempts to send a whole series to a device.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("send-series-to")] [HttpPost("send-series-to")]
public async Task<ActionResult> SendSeriesToDevice(SendSeriesToDeviceDto dto) public async Task<ActionResult> SendSeriesToDevice(SendSeriesToDeviceDto dto)
{ {
if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId")); var userId = User.GetUserId();
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId")); if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "SeriesId"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId"));
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice(); var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
if (!isEmailSetup) if (!isEmailSetup)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email")); return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email"));
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"started"), userId); "started"), userId);
var series = var series =
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
SeriesIncludes.Volumes | SeriesIncludes.Chapters); SeriesIncludes.Volumes | SeriesIncludes.Chapters);
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); if (series == null) return BadRequest(await _localizationService.Translate(userId, "series-doesnt-exist"));
var chapterIds = series.Volumes.SelectMany(v => v.Chapters.Select(c => c.Id)).ToList(); var chapterIds = series.Volumes.SelectMany(v => v.Chapters.Select(c => c.Id)).ToList();
try try
{ {
@ -173,16 +177,16 @@ public class DeviceController : BaseApiController
} }
catch (KavitaException ex) catch (KavitaException ex)
{ {
return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); return BadRequest(await _localizationService.Translate(userId, ex.Message));
} }
finally finally
{ {
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"ended"), userId); "ended"), userId);
} }
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to")); return BadRequest(await _localizationService.Translate(userId, "generic-send-to"));
} }
} }

View File

@ -134,7 +134,7 @@ public class SeriesController : BaseApiController
var username = User.GetUsername(); var username = User.GetUsername();
_logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username); _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username);
return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId})); return Ok(await _seriesService.DeleteMultipleSeries([seriesId]));
} }
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]

View File

@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace API.DTOs;
public class DeleteChaptersDto
{
public IList<int> ChapterIds { get; set; } = default!;
}

View File

@ -31,6 +31,7 @@ public interface IChapterRepository
{ {
void Update(Chapter chapter); void Update(Chapter chapter);
void Remove(Chapter chapter); void Remove(Chapter chapter);
void Remove(IList<Chapter> chapters);
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None); Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None);
Task<IChapterInfoDto?> GetChapterInfoDtoAsync(int chapterId); Task<IChapterInfoDto?> GetChapterInfoDtoAsync(int chapterId);
Task<int> GetChapterTotalPagesAsync(int chapterId); Task<int> GetChapterTotalPagesAsync(int chapterId);
@ -68,6 +69,11 @@ public class ChapterRepository : IChapterRepository
_context.Chapter.Remove(chapter); _context.Chapter.Remove(chapter);
} }
public void Remove(IList<Chapter> chapters)
{
_context.Chapter.RemoveRange(chapters);
}
public async Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None) public async Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None)
{ {
return await _context.Chapter return await _context.Chapter

View File

@ -696,7 +696,7 @@ public class SeriesRepository : ISeriesRepository
var retSeries = query var retSeries = query
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery() //.AsSplitQuery()
.AsNoTracking(); .AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
@ -1065,9 +1065,10 @@ public class SeriesRepository : ISeriesRepository
query = await ApplyCollectionFilter(filter, query, userId, userRating); query = await ApplyCollectionFilter(filter, query, userId, userRating);
query = BuildFilterQuery(userId, filter, query);
query = BuildFilterQuery(userId, filter, query);
query = query query = query
.WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId)) .WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId))
.WhereIf(onlyParentSeries, s => .WhereIf(onlyParentSeries, s =>
@ -1078,7 +1079,8 @@ public class SeriesRepository : ISeriesRepository
return ApplyLimit(query return ApplyLimit(query
.Sort(userId, filter.SortOptions) .Sort(userId, filter.SortOptions)
.AsSplitQuery(), filter.LimitTo); .AsSplitQuery()
, filter.LimitTo);
} }
private async Task<IQueryable<Series>> ApplyCollectionFilter(FilterV2Dto filter, IQueryable<Series> query, int userId, AgeRestriction userRating) private async Task<IQueryable<Series>> ApplyCollectionFilter(FilterV2Dto filter, IQueryable<Series> query, int userId, AgeRestriction userRating)

View File

@ -21,7 +21,7 @@ public class ExternalSeriesMetadata
public ICollection<ExternalRecommendation> ExternalRecommendations { get; set; } = null!; public ICollection<ExternalRecommendation> ExternalRecommendations { get; set; } = null!;
/// <summary> /// <summary>
/// Average External Rating. -1 means not set /// Average External Rating. -1 means not set, 0 - 100
/// </summary> /// </summary>
public int AverageExternalRating { get; set; } = 0; public int AverageExternalRating { get; set; } = 0;

View File

@ -14,6 +14,7 @@ namespace API.Extensions.QueryExtensions.Filtering;
public static class SeriesFilter public static class SeriesFilter
{ {
private const float FloatingPointTolerance = 0.001f; private const float FloatingPointTolerance = 0.001f;
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition, public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<string> languages) FilterComparison comparison, IList<string> languages)
{ {
@ -255,7 +256,8 @@ public static class SeriesFilter
.Where(s => s.Progress != null) .Where(s => s.Progress != null)
.Select(s => new .Select(s => new
{ {
Series = s, SeriesId = s.Id,
SeriesName = s.Name,
Percentage = s.Progress Percentage = s.Progress
.Where(p => p != null && p.AppUserId == userId) .Where(p => p != null && p.AppUserId == userId)
.Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0f) * 100f .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0f) * 100f
@ -298,7 +300,7 @@ public static class SeriesFilter
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
} }
var ids = subQuery.Select(s => s.Series.Id).ToList(); var ids = subQuery.Select(s => s.SeriesId);
return queryable.Where(s => ids.Contains(s.Id)); return queryable.Where(s => ids.Contains(s.Id));
} }
@ -312,7 +314,8 @@ public static class SeriesFilter
.Include(s => s.ExternalSeriesMetadata) .Include(s => s.ExternalSeriesMetadata)
.Select(s => new .Select(s => new
{ {
Series = s, SeriesId = s.Id,
SeriesName = s.Name,
AverageRating = s.ExternalSeriesMetadata.AverageExternalRating AverageRating = s.ExternalSeriesMetadata.AverageExternalRating
}) })
.AsSplitQuery() .AsSplitQuery()
@ -354,7 +357,7 @@ public static class SeriesFilter
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
} }
var ids = subQuery.Select(s => s.Series.Id).ToList(); var ids = subQuery.Select(s => s.SeriesId);
return queryable.Where(s => ids.Contains(s.Id)); return queryable.Where(s => ids.Contains(s.Id));
} }
@ -372,7 +375,8 @@ public static class SeriesFilter
.Where(s => s.Progress != null) .Where(s => s.Progress != null)
.Select(s => new .Select(s => new
{ {
Series = s, SeriesId = s.Id,
SeriesName = s.Name,
MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId) MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId)
.Select(p => (DateTime?) p.LastModified) .Select(p => (DateTime?) p.LastModified)
.DefaultIfEmpty() .DefaultIfEmpty()
@ -420,7 +424,7 @@ public static class SeriesFilter
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
} }
var ids = subQuery.Select(s => s.Series.Id).ToList(); var ids = subQuery.Select(s => s.SeriesId);
return queryable.Where(s => ids.Contains(s.Id)); return queryable.Where(s => ids.Contains(s.Id));
} }
@ -434,7 +438,8 @@ public static class SeriesFilter
.Where(s => s.Progress != null) .Where(s => s.Progress != null)
.Select(s => new .Select(s => new
{ {
Series = s, SeriesId = s.Id,
SeriesName = s.Name,
MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId) MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId)
.Select(p => (DateTime?) p.LastModified) .Select(p => (DateTime?) p.LastModified)
.DefaultIfEmpty() .DefaultIfEmpty()
@ -480,7 +485,7 @@ public static class SeriesFilter
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
} }
var ids = subQuery.Select(s => s.Series.Id).ToList(); var ids = subQuery.Select(s => s.SeriesId);
return queryable.Where(s => ids.Contains(s.Id)); return queryable.Where(s => ids.Contains(s.Id));
} }

View File

@ -113,109 +113,55 @@ public static class QueryableExtensions
return condition ? queryable.Where(predicate) : queryable; return condition ? queryable.Where(predicate) : queryable;
} }
public static IQueryable<T> WhereLike<T>(this IQueryable<T> queryable, bool condition, Expression<Func<T, string>> propertySelector, string searchQuery)
where T : class
{
if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable;
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) });
var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null);
var searchExpression = Expression.Constant($"%{searchQuery}%");
var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression);
var lambda = Expression.Lambda<Func<T, bool>>(likeExpression, propertySelector.Parameters[0]);
return queryable.Where(lambda);
}
public static IQueryable<T> WhereGreaterThan<T>(this IQueryable<T> source, public static IQueryable<T> WhereGreaterThan<T>(this IQueryable<T> source,
Expression<Func<T, float>> selector, Expression<Func<T, float>> selector,
float value, float value)
float tolerance = DefaultTolerance)
{ {
var parameter = selector.Parameters[0]; var parameter = selector.Parameters[0];
var propertyAccess = selector.Body; var propertyAccess = selector.Body;
// Absolute difference comparison: (propertyAccess - value) > tolerance
var difference = Expression.Subtract(propertyAccess, Expression.Constant(value));
var absoluteDifference = Expression.Condition(
Expression.LessThan(difference, Expression.Constant(0f)),
Expression.Negate(difference),
difference);
var greaterThanExpression = Expression.GreaterThan(propertyAccess, Expression.Constant(value)); var greaterThanExpression = Expression.GreaterThan(propertyAccess, Expression.Constant(value));
var toleranceExpression = Expression.GreaterThan(absoluteDifference, Expression.Constant(tolerance)); var lambda = Expression.Lambda<Func<T, bool>>(greaterThanExpression, parameter);
var combinedExpression = Expression.AndAlso(greaterThanExpression, toleranceExpression);
var lambda = Expression.Lambda<Func<T, bool>>(combinedExpression, parameter);
return source.Where(lambda); return source.Where(lambda);
} }
public static IQueryable<T> WhereGreaterThanOrEqual<T>(this IQueryable<T> source, public static IQueryable<T> WhereGreaterThanOrEqual<T>(this IQueryable<T> source,
Expression<Func<T, float>> selector, Expression<Func<T, float>> selector,
float value, float value)
float tolerance = DefaultTolerance)
{ {
var parameter = selector.Parameters[0]; var parameter = selector.Parameters[0];
var propertyAccess = selector.Body; var propertyAccess = selector.Body;
var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); var greaterThanExpression = Expression.GreaterThanOrEqual(propertyAccess, Expression.Constant(value));
var absoluteDifference = Expression.Condition( var lambda = Expression.Lambda<Func<T, bool>>(greaterThanExpression, parameter);
Expression.LessThan(difference, Expression.Constant(0f)),
Expression.Negate(difference),
difference);
var greaterThanOrEqualExpression = Expression.GreaterThanOrEqual(propertyAccess, Expression.Constant(value));
var toleranceExpression = Expression.GreaterThanOrEqual(absoluteDifference, Expression.Constant(tolerance));
var combinedExpression = Expression.AndAlso(greaterThanOrEqualExpression, toleranceExpression);
var lambda = Expression.Lambda<Func<T, bool>>(combinedExpression, parameter);
return source.Where(lambda); return source.Where(lambda);
} }
public static IQueryable<T> WhereLessThan<T>(this IQueryable<T> source, public static IQueryable<T> WhereLessThan<T>(this IQueryable<T> source,
Expression<Func<T, float>> selector, Expression<Func<T, float>> selector,
float value, float value)
float tolerance = DefaultTolerance)
{ {
var parameter = selector.Parameters[0]; var parameter = selector.Parameters[0];
var propertyAccess = selector.Body; var propertyAccess = selector.Body;
var difference = Expression.Subtract(propertyAccess, Expression.Constant(value));
var absoluteDifference = Expression.Condition(
Expression.LessThan(difference, Expression.Constant(0f)),
Expression.Negate(difference),
difference);
var lessThanExpression = Expression.LessThan(propertyAccess, Expression.Constant(value)); var lessThanExpression = Expression.LessThan(propertyAccess, Expression.Constant(value));
var toleranceExpression = Expression.LessThan(absoluteDifference, Expression.Constant(tolerance)); var lambda = Expression.Lambda<Func<T, bool>>(lessThanExpression, parameter);
var combinedExpression = Expression.AndAlso(lessThanExpression, toleranceExpression);
var lambda = Expression.Lambda<Func<T, bool>>(combinedExpression, parameter);
return source.Where(lambda); return source.Where(lambda);
} }
public static IQueryable<T> WhereLessThanOrEqual<T>(this IQueryable<T> source, public static IQueryable<T> WhereLessThanOrEqual<T>(this IQueryable<T> source,
Expression<Func<T, float>> selector, Expression<Func<T, float>> selector,
float value, float value)
float tolerance = DefaultTolerance)
{ {
var parameter = selector.Parameters[0]; var parameter = selector.Parameters[0];
var propertyAccess = selector.Body; var propertyAccess = selector.Body;
var difference = Expression.Subtract(propertyAccess, Expression.Constant(value));
var absoluteDifference = Expression.Condition(
Expression.LessThan(difference, Expression.Constant(0f)),
Expression.Negate(difference),
difference);
var lessThanOrEqualExpression = Expression.LessThanOrEqual(propertyAccess, Expression.Constant(value)); var lessThanOrEqualExpression = Expression.LessThanOrEqual(propertyAccess, Expression.Constant(value));
var toleranceExpression = Expression.LessThanOrEqual(absoluteDifference, Expression.Constant(tolerance)); var lambda = Expression.Lambda<Func<T, bool>>(lessThanOrEqualExpression, parameter);
var combinedExpression = Expression.AndAlso(lessThanOrEqualExpression, toleranceExpression);
var lambda = Expression.Lambda<Func<T, bool>>(combinedExpression, parameter);
return source.Where(lambda); return source.Where(lambda);
} }

View File

@ -0,0 +1,26 @@
using System;
using API.Entities.Metadata;
namespace API.Helpers.Builders;
public class ExternalSeriesMetadataBuilder : IEntityBuilder<ExternalSeriesMetadata>
{
private readonly ExternalSeriesMetadata _metadata;
public ExternalSeriesMetadata Build() => _metadata;
public ExternalSeriesMetadataBuilder()
{
_metadata = new ExternalSeriesMetadata();
}
/// <summary>
/// -1 for not set, Range 0 - 100
/// </summary>
/// <param name="rating"></param>
/// <returns></returns>
public ExternalSeriesMetadataBuilder WithAverageExternalRating(int rating)
{
_metadata.AverageExternalRating = Math.Clamp(rating, -1, 100);
return this;
}
}

View File

@ -98,4 +98,12 @@ public class SeriesBuilder : IEntityBuilder<Series>
_series.Metadata.PublicationStatus = status; _series.Metadata.PublicationStatus = status;
return this; return this;
} }
public SeriesBuilder WithExternalMetadata(ExternalSeriesMetadata metadata)
{
_series.ExternalSeriesMetadata = metadata;
return this;
}
} }

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Metadata; using API.Entities.Metadata;
@ -21,12 +22,15 @@ public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
}; };
} }
[Obsolete]
public SeriesMetadataBuilder WithCollectionTag(CollectionTag tag) public SeriesMetadataBuilder WithCollectionTag(CollectionTag tag)
{ {
_seriesMetadata.CollectionTags ??= new List<CollectionTag>(); _seriesMetadata.CollectionTags ??= new List<CollectionTag>();
_seriesMetadata.CollectionTags.Add(tag); _seriesMetadata.CollectionTags.Add(tag);
return this; return this;
} }
[Obsolete]
public SeriesMetadataBuilder WithCollectionTags(IList<CollectionTag> tags) public SeriesMetadataBuilder WithCollectionTags(IList<CollectionTag> tags)
{ {
if (tags == null) return this; if (tags == null) return this;
@ -34,6 +38,7 @@ public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
_seriesMetadata.CollectionTags = tags; _seriesMetadata.CollectionTags = tags;
return this; return this;
} }
public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status) public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status)
{ {
_seriesMetadata.PublicationStatus = status; _seriesMetadata.PublicationStatus = status;
@ -58,4 +63,22 @@ public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
return this; return this;
} }
public SeriesMetadataBuilder WithLanguage(string languageCode)
{
_seriesMetadata.Language = languageCode;
return this;
}
public SeriesMetadataBuilder WithReleaseYear(int year)
{
_seriesMetadata.ReleaseYear = year;
return this;
}
public SeriesMetadataBuilder WithSummary(string summary)
{
_seriesMetadata.Summary = summary;
return this;
}
} }

View File

@ -1,7 +1,6 @@
import {inject, Injectable, OnDestroy} from '@angular/core'; import {inject, Injectable} from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import {Subject, tap} from 'rxjs';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component';
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
@ -22,7 +21,6 @@ import { SeriesService } from './series.service';
import {translate} from "@jsverse/transloco"; import {translate} from "@jsverse/transloco";
import {UserCollection} from "../_models/collection-tag"; import {UserCollection} from "../_models/collection-tag";
import {CollectionTagService} from "./collection-tag.service"; import {CollectionTagService} from "./collection-tag.service";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {FilterService} from "./filter.service"; import {FilterService} from "./filter.service";
import {ReadingListService} from "./reading-list.service"; import {ReadingListService} from "./reading-list.service";
import {ChapterService} from "./chapter.service"; import {ChapterService} from "./chapter.service";
@ -468,6 +466,16 @@ export class ActionService {
}); });
} }
async deleteMultipleChapters(seriesId: number, chapterIds: Array<Chapter>, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters'))) return;
this.chapterService.deleteMultipleChapters(seriesId, chapterIds.map(c => c.id)).subscribe(() => {
if (callback) {
callback(true);
}
});
}
/** /**
* Deletes multiple collections * Deletes multiple collections
* @param readingLists ReadingList, should have id * @param readingLists ReadingList, should have id

View File

@ -21,6 +21,10 @@ export class ChapterService {
return this.httpClient.delete<boolean>(this.baseUrl + 'chapter?chapterId=' + chapterId); return this.httpClient.delete<boolean>(this.baseUrl + 'chapter?chapterId=' + chapterId);
} }
deleteMultipleChapters(seriesId: number, chapterIds: Array<number>) {
return this.httpClient.post<boolean>(this.baseUrl + `chapter/delete-multiple?seriesId=${seriesId}`, {chapterIds});
}
updateChapter(chapter: Chapter) { updateChapter(chapter: Chapter) {
return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse); return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse);
} }

View File

@ -15,7 +15,6 @@
scrollbar-width: thin; scrollbar-width: thin;
mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%); mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
//margin-top: 7px;
// For firefox // For firefox
@supports (-moz-appearance:none) { @supports (-moz-appearance:none) {

View File

@ -138,11 +138,14 @@ export class BulkSelectionService {
return ret; return ret;
} }
/**
* Returns the appropriate set of supported actions for the given mix of cards
* @param callback
*/
getActions(callback: (action: ActionItem<any>, data: any) => void) { getActions(callback: (action: ActionItem<any>, data: any) => void) {
// checks if series is present. If so, returns only series actions
// else returns volume/chapter items
const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection, const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection,
Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList]; Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList];
if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) { if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) {
return this.applyFilterToList(this.actionFactory.getSeriesActions(callback), allowedActions); return this.applyFilterToList(this.actionFactory.getSeriesActions(callback), allowedActions);
} }
@ -163,7 +166,8 @@ export class BulkSelectionService {
return this.applyFilterToList(this.actionFactory.getReadingListActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]); return this.applyFilterToList(this.actionFactory.getReadingListActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
} }
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions); // Chapter/Volume
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), [...allowedActions, Action.SendTo]);
} }
private debugLog(message: string, extraData?: any) { private debugLog(message: string, extraData?: any) {
@ -177,18 +181,25 @@ export class BulkSelectionService {
} }
private applyFilter(action: ActionItem<any>, allowedActions: Array<Action>) { private applyFilter(action: ActionItem<any>, allowedActions: Array<Action>) {
let hasValidAction = false;
let ret = false; // Check if the current action is valid or a submenu
if (action.action === Action.Submenu || allowedActions.includes(action.action)) { if (action.action === Action.Submenu || allowedActions.includes(action.action)) {
// Do something hasValidAction = true;
ret = true;
} }
if (action.children === null || action.children?.length === 0) return ret; // If the action has children, filter them recursively
if (action.children && action.children.length > 0) {
action.children = action.children.filter((childAction) => this.applyFilter(childAction, allowedActions)); action.children = action.children.filter((childAction) => this.applyFilter(childAction, allowedActions));
return ret; // If no valid children remain, the parent submenu should not be considered valid
if (action.children.length === 0 && action.action === Action.Submenu) {
hasValidAction = false;
}
}
// Return whether this action or its children are valid
return hasValidAction;
} }
private applyFilterToList(list: Array<ActionItem<any>>, allowedActions: Array<Action>): Array<ActionItem<any>> { private applyFilterToList(list: Array<ActionItem<any>>, allowedActions: Array<Action>): Array<ActionItem<any>> {

View File

@ -7,13 +7,8 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component"; import {AsyncPipe, DOCUMENT, NgStyle, NgClass, DatePipe, Location} from "@angular/common";
import {TagBadgeComponent} from "../shared/tag-badge/tag-badge.component";
import {AsyncPipe, DecimalPipe, DOCUMENT, NgStyle, NgClass, DatePipe, Location} from "@angular/common";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
import {ExternalSeriesCardComponent} from "../cards/external-series-card/external-series-card.component";
import {ImageComponent} from "../shared/image/image.component";
import {LoadingComponent} from "../shared/loading/loading.component"; import {LoadingComponent} from "../shared/loading/loading.component";
import { import {
NgbDropdown, NgbDropdown,
@ -23,12 +18,8 @@ import {
NgbNav, NgbNavChangeEvent, NgbNav, NgbNavChangeEvent,
NgbNavContent, NgbNavItem, NgbNavContent, NgbNavItem,
NgbNavLink, NgbNavOutlet, NgbNavLink, NgbNavOutlet,
NgbProgressbar,
NgbTooltip NgbTooltip
} from "@ng-bootstrap/ng-bootstrap"; } from "@ng-bootstrap/ng-bootstrap";
import {PersonBadgeComponent} from "../shared/person-badge/person-badge.component";
import {ReviewCardComponent} from "../_single-module/review-card/review-card.component";
import {SeriesCardComponent} from "../cards/series-card/series-card.component";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {ActivatedRoute, Router, RouterLink} from "@angular/router"; import {ActivatedRoute, Router, RouterLink} from "@angular/router";
import {ImageService} from "../_services/image.service"; import {ImageService} from "../_services/image.service";
@ -38,9 +29,6 @@ import {forkJoin, map, Observable, tap} from "rxjs";
import {SeriesService} from "../_services/series.service"; import {SeriesService} from "../_services/series.service";
import {Series} from "../_models/series"; import {Series} from "../_models/series";
import {AgeRating} from "../_models/metadata/age-rating"; import {AgeRating} from "../_models/metadata/age-rating";
import {AgeRatingPipe} from "../_pipes/age-rating.pipe";
import {TimeDurationPipe} from "../_pipes/time-duration.pipe";
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
import {LibraryType} from "../_models/library/library"; import {LibraryType} from "../_models/library/library";
import {LibraryService} from "../_services/library.service"; import {LibraryService} from "../_services/library.service";
import {ThemeService} from "../_services/theme.service"; import {ThemeService} from "../_services/theme.service";
@ -54,18 +42,13 @@ import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component"; import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
import {EntityTitleComponent} from "../cards/entity-title/entity-title.component"; import {EntityTitleComponent} from "../cards/entity-title/entity-title.component";
import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component"; import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component";
import {ReadTimePipe} from "../_pipes/read-time.pipe";
import {FilterField} from "../_models/metadata/v2/filter-field"; import {FilterField} from "../_models/metadata/v2/filter-field";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {DefaultValuePipe} from "../_pipes/default-value.pipe";
import {ReadingList} from "../_models/reading-list"; import {ReadingList} from "../_models/reading-list";
import {ReadingListService} from "../_services/reading-list.service"; import {ReadingListService} from "../_services/reading-list.service";
import {CardItemComponent} from "../cards/card-item/card-item.component";
import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component"; import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component";
import {AgeRatingImageComponent} from "../_single-modules/age-rating-image/age-rating-image.component";
import {CompactNumberPipe} from "../_pipes/compact-number.pipe";
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
import { import {
MetadataDetailRowComponent MetadataDetailRowComponent
@ -79,9 +62,7 @@ import {ChapterRemovedEvent} from "../_models/events/chapter-removed-event";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service"; import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {Device} from "../_models/device/device"; import {Device} from "../_models/device/device";
import {ActionService} from "../_services/action.service"; import {ActionService} from "../_services/action.service";
import {PublicationStatusPipe} from "../_pipes/publication-status.pipe";
import {DefaultDatePipe} from "../_pipes/default-date.pipe"; import {DefaultDatePipe} from "../_pipes/default-date.pipe";
import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component"; import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../_models/default-modal-options"; import {DefaultModalOptions} from "../_models/default-modal-options";
@ -95,13 +76,8 @@ enum TabID {
selector: 'app-chapter-detail', selector: 'app-chapter-detail',
standalone: true, standalone: true,
imports: [ imports: [
BulkOperationsComponent,
AsyncPipe, AsyncPipe,
CardActionablesComponent, CardActionablesComponent,
CarouselReelComponent,
DecimalPipe,
ExternalSeriesCardComponent,
ImageComponent,
LoadingComponent, LoadingComponent,
NgbDropdown, NgbDropdown,
NgbDropdownItem, NgbDropdownItem,
@ -110,18 +86,10 @@ enum TabID {
NgbNav, NgbNav,
NgbNavContent, NgbNavContent,
NgbNavLink, NgbNavLink,
NgbProgressbar,
NgbTooltip, NgbTooltip,
PersonBadgeComponent,
ReviewCardComponent,
SeriesCardComponent,
TagBadgeComponent,
VirtualScrollerModule, VirtualScrollerModule,
NgStyle, NgStyle,
NgClass, NgClass,
AgeRatingPipe,
TimeDurationPipe,
ExternalRatingComponent,
TranslocoDirective, TranslocoDirective,
ReadMoreComponent, ReadMoreComponent,
NgbNavItem, NgbNavItem,
@ -129,19 +97,12 @@ enum TabID {
DetailsTabComponent, DetailsTabComponent,
RouterLink, RouterLink,
EntityTitleComponent, EntityTitleComponent,
ReadTimePipe,
DefaultValuePipe,
CardItemComponent,
RelatedTabComponent, RelatedTabComponent,
AgeRatingImageComponent,
CompactNumberPipe,
BadgeExpanderComponent, BadgeExpanderComponent,
MetadataDetailRowComponent, MetadataDetailRowComponent,
DownloadButtonComponent, DownloadButtonComponent,
PublicationStatusPipe,
DatePipe, DatePipe,
DefaultDatePipe, DefaultDatePipe,
MangaFormatPipe,
CoverImageComponent CoverImageComponent
], ],
templateUrl: './chapter-detail.component.html', templateUrl: './chapter-detail.component.html',

View File

@ -13,7 +13,6 @@ import {
Component, Component,
DestroyRef, DestroyRef,
ElementRef, ElementRef,
HostListener,
Inject, Inject,
inject, inject,
OnInit, OnInit,
@ -37,7 +36,7 @@ import {
NgbTooltip NgbTooltip
} from '@ng-bootstrap/ng-bootstrap'; } from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import {catchError, forkJoin, Observable, of, tap} from 'rxjs'; import {catchError, debounceTime, forkJoin, Observable, of, ReplaySubject, tap} from 'rxjs';
import {map} from 'rxjs/operators'; import {map} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import { import {
@ -45,7 +44,7 @@ import {
EditSeriesModalComponent EditSeriesModalComponent
} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component'; } from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service'; import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service';
import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter'; import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
import {Device} from 'src/app/_models/device/device'; import {Device} from 'src/app/_models/device/device';
import {ScanSeriesEvent} from 'src/app/_models/events/scan-series-event'; import {ScanSeriesEvent} from 'src/app/_models/events/scan-series-event';
@ -247,6 +246,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
downloadInProgress: boolean = false; downloadInProgress: boolean = false;
nextExpectedChapter: NextExpectedChapter | undefined; nextExpectedChapter: NextExpectedChapter | undefined;
loadPageSource = new ReplaySubject<boolean>(1);
loadPage$ = this.loadPageSource.asObservable();
/** /**
* Track by function for Volume to tell when to refresh card data * Track by function for Volume to tell when to refresh card data
@ -256,14 +257,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
* Track by function for Chapter to tell when to refresh card data * Track by function for Chapter to tell when to refresh card data
*/ */
trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.minNumber}_${item.maxNumber}_${item.volumeId}_${item.pagesRead}`; trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.minNumber}_${item.maxNumber}_${item.volumeId}_${item.pagesRead}`;
trackByRelatedSeriesIdentify = (index: number, item: RelatedSeriesPair) => `${item.series.name}_${item.series.libraryId}_${item.series.pagesRead}_${item.relation}`;
trackBySeriesIdentify = (index: number, item: Series) => `${item.name}_${item.libraryId}_${item.pagesRead}`;
trackByStoryLineIdentity = (index: number, item: StoryLineItem) => {
if (item.isChapter) {
return this.trackByChapterIdentity(index, item!.chapter!)
}
return this.trackByVolumeIdentity(index, item!.volume!);
};
/** /**
* Are there any related series * Are there any related series
@ -307,7 +300,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
*/ */
download$: Observable<DownloadEvent | null> | null = null; download$: Observable<DownloadEvent | null> | null = null;
bulkActionCallback = (action: ActionItem<any>, data: any) => { bulkActionCallback = async (action: ActionItem<any>, data: any) => {
if (this.series === undefined) { if (this.series === undefined) {
return; return;
} }
@ -355,6 +348,19 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
break; break;
case Action.SendTo:
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(chapters.map(c => c.id), device);
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
break;
case Action.Delete:
await this.actionService.deleteMultipleChapters(seriesId, chapters, () => {
// No need to update the page as the backend will spam volume/chapter deletions
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
} }
} }
@ -459,6 +465,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
return this.downloadService.mapToEntityType(events, this.series); return this.downloadService.mapToEntityType(events, this.series);
})); }));
this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(300), tap(val => this.loadSeries(this.seriesId, val))).subscribe();
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => { this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
if (event.event === EVENTS.SeriesRemoved) { if (event.event === EVENTS.SeriesRemoved) {
const seriesRemovedEvent = event.payload as SeriesRemovedEvent; const seriesRemovedEvent = event.payload as SeriesRemovedEvent;
@ -469,7 +477,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} else if (event.event === EVENTS.ScanSeries) { } else if (event.event === EVENTS.ScanSeries) {
const seriesScanEvent = event.payload as ScanSeriesEvent; const seriesScanEvent = event.payload as ScanSeriesEvent;
if (seriesScanEvent.seriesId === this.seriesId) { if (seriesScanEvent.seriesId === this.seriesId) {
this.loadSeries(this.seriesId); //this.loadSeries(this.seriesId);
this.loadPageSource.next(false);
} }
} else if (event.event === EVENTS.CoverUpdate) { } else if (event.event === EVENTS.CoverUpdate) {
const coverUpdateEvent = event.payload as CoverUpdateEvent; const coverUpdateEvent = event.payload as CoverUpdateEvent;
@ -479,7 +488,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} else if (event.event === EVENTS.ChapterRemoved) { } else if (event.event === EVENTS.ChapterRemoved) {
const removedEvent = event.payload as ChapterRemovedEvent; const removedEvent = event.payload as ChapterRemovedEvent;
if (removedEvent.seriesId !== this.seriesId) return; if (removedEvent.seriesId !== this.seriesId) return;
this.loadSeries(this.seriesId, false); //this.loadSeries(this.seriesId, false);
this.loadPageSource.next(false);
} }
}); });
@ -508,7 +518,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} }
}), takeUntilDestroyed(this.destroyRef)).subscribe(); }), takeUntilDestroyed(this.destroyRef)).subscribe();
this.loadSeries(this.seriesId, true); //this.loadSeries(this.seriesId, true);
this.loadPageSource.next(true);
this.pageExtrasGroup.get('renderMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((val: PageLayoutMode | null) => { this.pageExtrasGroup.get('renderMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((val: PageLayoutMode | null) => {
if (val == null) return; if (val == null) return;
@ -535,12 +546,12 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
switch(action.action) { switch(action.action) {
case(Action.MarkAsRead): case(Action.MarkAsRead):
this.actionService.markSeriesAsRead(series, (series: Series) => { this.actionService.markSeriesAsRead(series, (series: Series) => {
this.loadSeries(series.id); this.loadPageSource.next(false);
}); });
break; break;
case(Action.MarkAsUnread): case(Action.MarkAsUnread):
this.actionService.markSeriesAsUnread(series, (series: Series) => { this.actionService.markSeriesAsUnread(series, (series: Series) => {
this.loadSeries(series.id); this.loadPageSource.next(false);
}); });
break; break;
case(Action.Scan): case(Action.Scan):
@ -600,7 +611,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
case(Action.Delete): case(Action.Delete):
await this.actionService.deleteVolume(volume.id, (b) => { await this.actionService.deleteVolume(volume.id, (b) => {
if (!b) return; if (!b) return;
this.loadSeries(this.seriesId, false); this.loadPageSource.next(false);
}); });
break; break;
case(Action.AddToReadingList): case(Action.AddToReadingList):
@ -1010,7 +1021,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
ref.closed.subscribe((res: EditChapterModalCloseResult) => { ref.closed.subscribe((res: EditChapterModalCloseResult) => {
if (res.success && res.isDeleted) { if (res.success && res.isDeleted) {
this.loadSeries(this.seriesId, false); this.loadPageSource.next(false);
} }
}); });
} }
@ -1024,7 +1035,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
ref.closed.subscribe((res: EditChapterModalCloseResult) => { ref.closed.subscribe((res: EditChapterModalCloseResult) => {
if (res.success && res.isDeleted) { if (res.success && res.isDeleted) {
this.loadSeries(this.seriesId, false); this.loadPageSource.next(false);
} }
}); });
} }
@ -1035,9 +1046,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
modalRef.closed.subscribe((closeResult: EditSeriesModalCloseResult) => { modalRef.closed.subscribe((closeResult: EditSeriesModalCloseResult) => {
if (closeResult.success) { if (closeResult.success) {
window.scrollTo(0, 0); window.scrollTo(0, 0);
this.loadSeries(this.seriesId, closeResult.updateExternal); this.loadPageSource.next(closeResult.updateExternal);
} else if (closeResult.updateExternal) { } else if (closeResult.updateExternal) {
this.loadSeries(this.seriesId, closeResult.updateExternal); this.loadPageSource.next(closeResult.updateExternal);
} }
}); });
} }

View File

@ -3,10 +3,12 @@
<div class="d-flex flex-column"> <div class="d-flex flex-column">
@if (HasCoverImage) { @if (HasCoverImage) {
<div class="mx-auto"> <div class="mx-auto">
<a class="btn btn-icon p-0" routerLink="/person/{{person.name}}">
<app-image height="24px" width="24px" [styles]="{'background': 'none', 'max-height': '48px', 'height': '48px', 'width': '48px', 'border-radius': '50%'}" <app-image height="24px" width="24px" [styles]="{'background': 'none', 'max-height': '48px', 'height': '48px', 'width': '48px', 'border-radius': '50%'}"
[imageUrl]="ImageUrl" [imageUrl]="ImageUrl"
[errorImage]="imageService.noPersonImage"> [errorImage]="imageService.noPersonImage">
</app-image> </app-image>
</a>
</div> </div>
} @else { } @else {
<div style="background: none; max-height: 48px; height: 48px; width: 48px; border-radius: 50%" class="mx-auto"> <div style="background: none; max-height: 48px; height: 48px; width: 48px; border-radius: 50%" class="mx-auto">

View File

@ -8,7 +8,7 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {AsyncPipe, DecimalPipe, DOCUMENT, NgStyle, NgClass, DatePipe, Location} from "@angular/common"; import {AsyncPipe, DOCUMENT, NgStyle, NgClass, Location} from "@angular/common";
import {ActivatedRoute, Router, RouterLink} from "@angular/router"; import {ActivatedRoute, Router, RouterLink} from "@angular/router";
import {ImageService} from "../_services/image.service"; import {ImageService} from "../_services/image.service";
import {SeriesService} from "../_services/series.service"; import {SeriesService} from "../_services/series.service";
@ -30,7 +30,6 @@ import {
NgbNavItem, NgbNavItem,
NgbNavLink, NgbNavLink,
NgbNavOutlet, NgbNavOutlet,
NgbProgressbar,
NgbTooltip NgbTooltip
} from "@ng-bootstrap/ng-bootstrap"; } from "@ng-bootstrap/ng-bootstrap";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
@ -49,19 +48,13 @@ import {LoadingComponent} from "../shared/loading/loading.component";
import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component"; import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
import {ReadMoreComponent} from "../shared/read-more/read-more.component"; import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {Person} from "../_models/metadata/person"; import {Person} from "../_models/metadata/person";
import {hasAnyCast, IHasCast} from "../_models/common/i-has-cast"; import {IHasCast} from "../_models/common/i-has-cast";
import {ReadTimePipe} from "../_pipes/read-time.pipe";
import {AgeRatingPipe} from "../_pipes/age-rating.pipe";
import {EntityTitleComponent} from "../cards/entity-title/entity-title.component"; import {EntityTitleComponent} from "../cards/entity-title/entity-title.component";
import {ImageComponent} from "../shared/image/image.component";
import {CardItemComponent} from "../cards/card-item/card-item.component";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service"; import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {Breakpoint, UtilityService} from "../shared/_services/utility.service"; import {Breakpoint, UtilityService} from "../shared/_services/utility.service";
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component"; import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
import {DefaultValuePipe} from "../_pipes/default-value.pipe";
import { import {
EditVolumeModalCloseResult,
EditVolumeModalComponent EditVolumeModalComponent
} from "../_single-module/edit-volume-modal/edit-volume-modal.component"; } from "../_single-module/edit-volume-modal/edit-volume-modal.component";
import {Genre} from "../_models/metadata/genre"; import {Genre} from "../_models/metadata/genre";
@ -69,8 +62,6 @@ import {Tag} from "../_models/tag";
import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component"; import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component";
import {ReadingList} from "../_models/reading-list"; import {ReadingList} from "../_models/reading-list";
import {ReadingListService} from "../_services/reading-list.service"; import {ReadingListService} from "../_services/reading-list.service";
import {AgeRatingImageComponent} from "../_single-modules/age-rating-image/age-rating-image.component";
import {CompactNumberPipe} from "../_pipes/compact-number.pipe";
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
import { import {
MetadataDetailRowComponent MetadataDetailRowComponent
@ -85,8 +76,6 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
import {Device} from "../_models/device/device"; import {Device} from "../_models/device/device";
import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component"; import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component";
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component"; import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component";
import {DefaultDatePipe} from "../_pipes/default-date.pipe";
import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component"; import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../_models/default-modal-options"; import {DefaultModalOptions} from "../_models/default-modal-options";
@ -145,32 +134,20 @@ interface VolumeCast extends IHasCast {
NgbDropdownMenu, NgbDropdownMenu,
NgbDropdown, NgbDropdown,
NgbDropdownToggle, NgbDropdownToggle,
ReadTimePipe,
AgeRatingPipe,
EntityTitleComponent, EntityTitleComponent,
RouterLink, RouterLink,
NgbProgressbar,
DecimalPipe,
NgbTooltip, NgbTooltip,
ImageComponent,
NgStyle, NgStyle,
NgClass, NgClass,
TranslocoDirective, TranslocoDirective,
CardItemComponent,
VirtualScrollerModule, VirtualScrollerModule,
ChapterCardComponent, ChapterCardComponent,
DefaultValuePipe,
RelatedTabComponent, RelatedTabComponent,
AgeRatingImageComponent,
CompactNumberPipe,
BadgeExpanderComponent, BadgeExpanderComponent,
MetadataDetailRowComponent, MetadataDetailRowComponent,
DownloadButtonComponent, DownloadButtonComponent,
CardActionablesComponent, CardActionablesComponent,
BulkOperationsComponent, BulkOperationsComponent,
DatePipe,
DefaultDatePipe,
MangaFormatPipe,
CoverImageComponent CoverImageComponent
], ],
templateUrl: './volume-detail.component.html', templateUrl: './volume-detail.component.html',
@ -226,7 +203,7 @@ export class VolumeDetailComponent implements OnInit {
volumeActions: Array<ActionItem<Volume>> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this)); volumeActions: Array<ActionItem<Volume>> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this));
chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)); chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
bulkActionCallback = (action: ActionItem<Chapter>, data: any) => { bulkActionCallback = async (action: ActionItem<Chapter>, _: any) => {
if (this.volume === null) { if (this.volume === null) {
return; return;
} }
@ -256,6 +233,19 @@ export class VolumeDetailComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
break; break;
case Action.SendTo:
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(selectedChapterIds.map(c => c.id), device);
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
break;
case Action.Delete:
await this.actionService.deleteMultipleChapters(this.seriesId, selectedChapterIds, () => {
// No need to update the page as the backend will spam volume/chapter deletions
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
} }
} }
@ -609,14 +599,14 @@ export class VolumeDetailComponent implements OnInit {
}); });
break; break;
case Action.MarkAsRead: case Action.MarkAsRead:
this.actionService.markVolumeAsRead(this.seriesId, this.volume!, res => { this.actionService.markVolumeAsRead(this.seriesId, this.volume!, _ => {
this.volume!.pagesRead = this.volume!.pages; this.volume!.pagesRead = this.volume!.pages;
this.setContinuePoint(); this.setContinuePoint();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
break; break;
case Action.MarkAsUnread: case Action.MarkAsUnread:
this.actionService.markVolumeAsUnread(this.seriesId, this.volume!, res => { this.actionService.markVolumeAsUnread(this.seriesId, this.volume!, _ => {
this.volume!.pagesRead = 0; this.volume!.pagesRead = 0;
this.setContinuePoint(); this.setContinuePoint();
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View File

@ -2397,6 +2397,7 @@
"confirm-regen-covers": "Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don't want to perform a Scan instead?", "confirm-regen-covers": "Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don't want to perform a Scan instead?",
"alert-long-running": "This is a long running process. Please give it the time to complete before invoking again.", "alert-long-running": "This is a long running process. Please give it the time to complete before invoking again.",
"confirm-delete-multiple-series": "Are you sure you want to delete {{count}} series? It will not modify files on disk.", "confirm-delete-multiple-series": "Are you sure you want to delete {{count}} series? It will not modify files on disk.",
"confirm-delete-multiple-chapters": "Are you sure you want to delete {{count}} chapter/volumes? It will not modify files on disk.",
"confirm-delete-series": "Are you sure you want to delete this series? It will not modify files on disk.", "confirm-delete-series": "Are you sure you want to delete this series? It will not modify files on disk.",
"confirm-delete-chapter": "Are you sure you want to delete this chapter? It will not modify files on disk.", "confirm-delete-chapter": "Are you sure you want to delete this chapter? It will not modify files on disk.",
"confirm-delete-volume": "Are you sure you want to delete this volume? It will not modify files on disk.", "confirm-delete-volume": "Are you sure you want to delete this volume? It will not modify files on disk.",

View File

@ -1,8 +1,10 @@
@import '../variables';
.navbar { .navbar {
background-color: var(--navbar-bg-color); background-color: var(--navbar-bg-color);
color: var(--navbar-text-color); color: var(--navbar-text-color);
z-index: 1040; z-index: 1040;
border-radius: 4px; border-radius: var(--navbar-border-radius);
left: 0px; left: 0px;
margin: var(--navbar-header-margin); margin: var(--navbar-header-margin);
padding: 0; padding: 0;
@ -20,6 +22,6 @@ i.fa.nav {
@media (max-width: $grid-breakpoints-lg) { @media (max-width: $grid-breakpoints-lg) {
.navbar { .navbar {
margin: 8px 12px; margin: var(--navbar-header-mobile-x-margin) var(--navbar-header-mobile-y-margin);
} }
} }

View File

@ -105,8 +105,11 @@
--navbar-bg-color: black; --navbar-bg-color: black;
--navbar-text-color: white; --navbar-text-color: white;
--navbar-fa-icon-color: white; --navbar-fa-icon-color: white;
--navbar-border-radius: 0px; // 4px for Plex navbar
--navbar-btn-hover-outline-color: rgba(255, 255, 255, 1); --navbar-btn-hover-outline-color: rgba(255, 255, 255, 1);
--navbar-header-margin: 0px; // 8px allows for the Plex navbar + --nav-offset: 56px; --navbar-header-margin: 0px; // 8px allows for the Plex navbar + --nav-offset: 56px;
--navbar-header-mobile-x-margin: 0px; // 8px allows for the Plex navbar
--navbar-header-mobile-y-margin: 0px; // 12px allows for the Plex navbar
/* Inputs */ /* Inputs */
--input-bg-color: #343a40; --input-bg-color: #343a40;
@ -388,7 +391,7 @@
/* Bulk Selection */ /* Bulk Selection */
--bulk-selection-text-color: var(--navbar-text-color); --bulk-selection-text-color: var(--navbar-text-color);
--bulk-selection-highlight-text-color: var(--primary-color); --bulk-selection-highlight-text-color: var(--primary-color);
--bulk-selection-bg-color: var(--elevation-layer11-dark); --bulk-selection-bg-color: black;
/* List Card Item */ /* List Card Item */
--card-list-item-bg-color: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%); --card-list-item-bg-color: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%);