mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Report Media Issues (#1964)
* Started working on a report problems implementation. * Started code * Added logging to book and archive service. * Removed an additional ComicInfo read when comicinfo is null when trying to load. But we've already done it once earlier, so there really isn't any point. * Added basic implementation for media errors. * MediaErrors will ignore duplicate errors when there are multiple issues on same file in a scan. * Fixed unit tests * Basic code in place to view and clear. Just UI Cleanup needed. * Slight css upgrade * Fixed up centering and simplified the code to use regular array instead of observables as it wasn't working. * Fixed unit tests * Fixed unit tests for real
This commit is contained in:
parent
642b23ed61
commit
d1e4878345
@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using API.Services;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using BenchmarkDotNet.Order;
|
||||
using NSubstitute;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.Formats.Webp;
|
||||
@ -31,7 +32,7 @@ public class ArchiveServiceBenchmark
|
||||
{
|
||||
_directoryService = new DirectoryService(null, new FileSystem());
|
||||
_imageService = new ImageService(null, _directoryService);
|
||||
_archiveService = new ArchiveService(new NullLogger<ArchiveService>(), _directoryService, _imageService);
|
||||
_archiveService = new ArchiveService(new NullLogger<ArchiveService>(), _directoryService, _imageService, Substitute.For<IMediaErrorService>());
|
||||
}
|
||||
|
||||
[Benchmark(Baseline = true)]
|
||||
|
@ -26,7 +26,9 @@ public class ArchiveServiceTests
|
||||
public ArchiveServiceTests(ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
_testOutputHelper = testOutputHelper;
|
||||
_archiveService = new ArchiveService(_logger, _directoryService, new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService));
|
||||
_archiveService = new ArchiveService(_logger, _directoryService,
|
||||
new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService),
|
||||
Substitute.For<IMediaErrorService>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@ -164,7 +166,7 @@ public class ArchiveServiceTests
|
||||
{
|
||||
var ds = Substitute.For<DirectoryService>(_directoryServiceLogger, new FileSystem());
|
||||
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), ds);
|
||||
var archiveService = Substitute.For<ArchiveService>(_logger, ds, imageService);
|
||||
var archiveService = Substitute.For<ArchiveService>(_logger, ds, imageService, Substitute.For<IMediaErrorService>());
|
||||
|
||||
var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"));
|
||||
var expectedBytes = Image.Thumbnail(Path.Join(testDirectory, expectedOutputFile), 320).WriteToBuffer(".png");
|
||||
@ -196,7 +198,8 @@ public class ArchiveServiceTests
|
||||
{
|
||||
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService);
|
||||
var archiveService = Substitute.For<ArchiveService>(_logger,
|
||||
new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService);
|
||||
new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService,
|
||||
Substitute.For<IMediaErrorService>());
|
||||
var testDirectory = API.Services.Tasks.Scanner.Parser.Parser.NormalizePath(Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")));
|
||||
|
||||
var outputDir = Path.Join(testDirectory, "output");
|
||||
@ -220,7 +223,7 @@ public class ArchiveServiceTests
|
||||
{
|
||||
var imageService = Substitute.For<IImageService>();
|
||||
imageService.WriteCoverThumbnail(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<string>()).Returns(x => "cover.jpg");
|
||||
var archiveService = new ArchiveService(_logger, _directoryService, imageService);
|
||||
var archiveService = new ArchiveService(_logger, _directoryService, imageService, Substitute.For<IMediaErrorService>());
|
||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/");
|
||||
var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile));
|
||||
var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output");
|
||||
|
@ -15,7 +15,9 @@ public class BookServiceTests
|
||||
public BookServiceTests()
|
||||
{
|
||||
var directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
|
||||
_bookService = new BookService(_logger, directoryService, new ImageService(Substitute.For<ILogger<ImageService>>(), directoryService));
|
||||
_bookService = new BookService(_logger, directoryService,
|
||||
new ImageService(Substitute.For<ILogger<ImageService>>(), directoryService)
|
||||
, Substitute.For<IMediaErrorService>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
6
API/Constants/ControllerConstants.cs
Normal file
6
API/Constants/ControllerConstants.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace API.Constants;
|
||||
|
||||
public abstract class ControllerConstants
|
||||
{
|
||||
public const int MaxUploadSizeBytes = 8_000_000;
|
||||
}
|
@ -3,10 +3,13 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Jobs;
|
||||
using API.DTOs.MediaErrors;
|
||||
using API.DTOs.Stats;
|
||||
using API.DTOs.Update;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using Hangfire;
|
||||
@ -14,7 +17,6 @@ using Hangfire.Storage;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TaskScheduler = API.Services.TaskScheduler;
|
||||
|
||||
@ -23,7 +25,6 @@ namespace API.Controllers;
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
public class ServerController : BaseApiController
|
||||
{
|
||||
private readonly IHostApplicationLifetime _applicationLifetime;
|
||||
private readonly ILogger<ServerController> _logger;
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly IArchiveService _archiveService;
|
||||
@ -34,13 +35,13 @@ public class ServerController : BaseApiController
|
||||
private readonly IScannerService _scannerService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger,
|
||||
public ServerController(ILogger<ServerController> logger,
|
||||
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
|
||||
ICleanupService cleanupService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService,
|
||||
ITaskScheduler taskScheduler)
|
||||
ITaskScheduler taskScheduler, IUnitOfWork unitOfWork)
|
||||
{
|
||||
_applicationLifetime = applicationLifetime;
|
||||
_logger = logger;
|
||||
_backupService = backupService;
|
||||
_archiveService = archiveService;
|
||||
@ -51,6 +52,7 @@ public class ServerController : BaseApiController
|
||||
_scannerService = scannerService;
|
||||
_accountService = accountService;
|
||||
_taskScheduler = taskScheduler;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -213,5 +215,28 @@ public class ServerController : BaseApiController
|
||||
return Ok(recurringJobs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of issues found during scanning or reading in which files may have corruption or bad metadata (structural metadata)
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpGet("media-errors")]
|
||||
public ActionResult<PagedList<MediaErrorDto>> GetMediaErrors()
|
||||
{
|
||||
return Ok(_unitOfWork.MediaErrorRepository.GetAllErrorDtosAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all media errors
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpPost("clear-media-alerts")]
|
||||
public async Task<ActionResult> ClearMediaErrors()
|
||||
{
|
||||
await _unitOfWork.MediaErrorRepository.DeleteAll();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.DTOs.Uploads;
|
||||
using API.Extensions;
|
||||
@ -78,7 +79,7 @@ public class UploadController : BaseApiController
|
||||
/// <param name="uploadFileDto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[RequestSizeLimit(8_000_000)]
|
||||
[RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
|
||||
[HttpPost("series")]
|
||||
public async Task<ActionResult> UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto)
|
||||
{
|
||||
@ -126,7 +127,7 @@ public class UploadController : BaseApiController
|
||||
/// <param name="uploadFileDto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[RequestSizeLimit(8_000_000)]
|
||||
[RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
|
||||
[HttpPost("collection")]
|
||||
public async Task<ActionResult> UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto)
|
||||
{
|
||||
@ -174,7 +175,7 @@ public class UploadController : BaseApiController
|
||||
/// <remarks>This is the only API that can be called by non-admins, but the authenticated user must have a readinglist permission</remarks>
|
||||
/// <param name="uploadFileDto"></param>
|
||||
/// <returns></returns>
|
||||
[RequestSizeLimit(8_000_000)]
|
||||
[RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
|
||||
[HttpPost("reading-list")]
|
||||
public async Task<ActionResult> UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto)
|
||||
{
|
||||
@ -238,7 +239,7 @@ public class UploadController : BaseApiController
|
||||
/// <param name="uploadFileDto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[RequestSizeLimit(8_000_000)]
|
||||
[RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
|
||||
[HttpPost("chapter")]
|
||||
public async Task<ActionResult> UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto)
|
||||
{
|
||||
@ -294,7 +295,7 @@ public class UploadController : BaseApiController
|
||||
/// <param name="uploadFileDto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[RequestSizeLimit(8_000_000)]
|
||||
[RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
|
||||
[HttpPost("library")]
|
||||
public async Task<ActionResult> UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto)
|
||||
{
|
||||
|
25
API/DTOs/MediaErrors/MediaErrorDto.cs
Normal file
25
API/DTOs/MediaErrors/MediaErrorDto.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using System;
|
||||
|
||||
namespace API.DTOs.MediaErrors;
|
||||
|
||||
public class MediaErrorDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Format Type (RAR, ZIP, 7Zip, Epub, PDF)
|
||||
/// </summary>
|
||||
public required string Extension { get; set; }
|
||||
/// <summary>
|
||||
/// Full Filepath to the file that has some issue
|
||||
/// </summary>
|
||||
public required string FilePath { get; set; }
|
||||
/// <summary>
|
||||
/// Developer defined string
|
||||
/// </summary>
|
||||
public string Comment { get; set; }
|
||||
/// <summary>
|
||||
/// Exception message
|
||||
/// </summary>
|
||||
public string Details { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
@ -47,6 +47,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
public DbSet<FolderPath> FolderPath { get; set; } = null!;
|
||||
public DbSet<Device> Device { get; set; } = null!;
|
||||
public DbSet<ServerStatistics> ServerStatistics { get; set; } = null!;
|
||||
public DbSet<MediaError> MediaError { get; set; } = null!;
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
|
1912
API/Data/Migrations/20230505124430_MediaError.Designer.cs
generated
Normal file
1912
API/Data/Migrations/20230505124430_MediaError.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
API/Data/Migrations/20230505124430_MediaError.cs
Normal file
42
API/Data/Migrations/20230505124430_MediaError.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MediaError : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MediaError",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Extension = table.Column<string>(type: "TEXT", nullable: true),
|
||||
FilePath = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Comment = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Details = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MediaError", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "MediaError");
|
||||
}
|
||||
}
|
||||
}
|
@ -714,6 +714,41 @@ namespace API.Data.Migrations
|
||||
b.ToTable("MangaFile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MediaError", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Extension")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModifiedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MediaError");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
83
API/Data/Repositories/MediaErrorRepository.cs
Normal file
83
API/Data/Repositories/MediaErrorRepository.cs
Normal file
@ -0,0 +1,83 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.MediaErrors;
|
||||
using API.Entities;
|
||||
using API.Helpers;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IMediaErrorRepository
|
||||
{
|
||||
void Attach(MediaError error);
|
||||
void Remove(MediaError error);
|
||||
Task<MediaError> Find(string filename);
|
||||
Task<PagedList<MediaErrorDto>> GetAllErrorDtosAsync(UserParams userParams);
|
||||
IEnumerable<MediaErrorDto> GetAllErrorDtosAsync();
|
||||
Task<bool> ExistsAsync(MediaError error);
|
||||
Task DeleteAll();
|
||||
}
|
||||
|
||||
public class MediaErrorRepository : IMediaErrorRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public MediaErrorRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Attach(MediaError? error)
|
||||
{
|
||||
if (error == null) return;
|
||||
_context.MediaError.Attach(error);
|
||||
}
|
||||
|
||||
public void Remove(MediaError? error)
|
||||
{
|
||||
if (error == null) return;
|
||||
_context.MediaError.Remove(error);
|
||||
}
|
||||
|
||||
public Task<MediaError?> Find(string filename)
|
||||
{
|
||||
return _context.MediaError.Where(e => e.FilePath == filename).SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public Task<PagedList<MediaErrorDto>> GetAllErrorDtosAsync(UserParams userParams)
|
||||
{
|
||||
var query = _context.MediaError
|
||||
.OrderByDescending(m => m.Created)
|
||||
.ProjectTo<MediaErrorDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
return PagedList<MediaErrorDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public IEnumerable<MediaErrorDto> GetAllErrorDtosAsync()
|
||||
{
|
||||
var query = _context.MediaError
|
||||
.OrderByDescending(m => m.Created)
|
||||
.ProjectTo<MediaErrorDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
return query.AsEnumerable();
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(MediaError error)
|
||||
{
|
||||
return _context.MediaError.AnyAsync(m => m.FilePath.Equals(error.FilePath)
|
||||
&& m.Comment.Equals(error.Comment)
|
||||
&& m.Details.Equals(error.Details)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task DeleteAll()
|
||||
{
|
||||
_context.MediaError.RemoveRange(await _context.MediaError.ToListAsync());
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
@ -25,6 +25,7 @@ public interface IUnitOfWork
|
||||
ISiteThemeRepository SiteThemeRepository { get; }
|
||||
IMangaFileRepository MangaFileRepository { get; }
|
||||
IDeviceRepository DeviceRepository { get; }
|
||||
IMediaErrorRepository MediaErrorRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
@ -62,6 +63,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
|
||||
public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context);
|
||||
public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper);
|
||||
public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper);
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
36
API/Entities/MediaError.cs
Normal file
36
API/Entities/MediaError.cs
Normal file
@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents issues found during scanning or interacting with media. For example) Can't open file, corrupt media, missing content in epub.
|
||||
/// </summary>
|
||||
public class MediaError : IEntityDate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Format Type (RAR, ZIP, 7Zip, Epub, PDF)
|
||||
/// </summary>
|
||||
public required string Extension { get; set; }
|
||||
/// <summary>
|
||||
/// Full Filepath to the file that has some issue
|
||||
/// </summary>
|
||||
public required string FilePath { get; set; }
|
||||
/// <summary>
|
||||
/// Developer defined string
|
||||
/// </summary>
|
||||
public string Comment { get; set; }
|
||||
/// <summary>
|
||||
/// Exception message
|
||||
/// </summary>
|
||||
public string Details { get; set; }
|
||||
/// <summary>
|
||||
/// Was the file imported or not
|
||||
/// </summary>
|
||||
//public bool Imported { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime LastModifiedUtc { get; set; }
|
||||
}
|
@ -49,6 +49,7 @@ public static class ApplicationServiceExtensions
|
||||
services.AddScoped<IReadingListService, ReadingListService>();
|
||||
services.AddScoped<IDeviceService, DeviceService>();
|
||||
services.AddScoped<IStatisticService, StatisticService>();
|
||||
services.AddScoped<IMediaErrorService, MediaErrorService>();
|
||||
|
||||
services.AddScoped<IScannerService, ScannerService>();
|
||||
services.AddScoped<IMetadataService, MetadataService>();
|
||||
|
@ -4,6 +4,7 @@ using API.DTOs;
|
||||
using API.DTOs.Account;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Device;
|
||||
using API.DTOs.MediaErrors;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.ReadingLists;
|
||||
@ -33,6 +34,7 @@ public class AutoMapperProfiles : Profile
|
||||
CreateMap<Tag, TagDto>();
|
||||
CreateMap<AgeRating, AgeRatingDto>();
|
||||
CreateMap<PublicationStatus, PublicationStatusDto>();
|
||||
CreateMap<MediaError, MediaErrorDto>();
|
||||
|
||||
CreateMap<AppUserProgress, ProgressDto>()
|
||||
.ForMember(dest => dest.PageNum,
|
||||
|
31
API/Helpers/Builders/MediaErrorBuilder.cs
Normal file
31
API/Helpers/Builders/MediaErrorBuilder.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using System.IO;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class MediaErrorBuilder : IEntityBuilder<MediaError>
|
||||
{
|
||||
private readonly MediaError _mediaError;
|
||||
public MediaError Build() => _mediaError;
|
||||
|
||||
public MediaErrorBuilder(string filePath)
|
||||
{
|
||||
_mediaError = new MediaError()
|
||||
{
|
||||
FilePath = filePath,
|
||||
Extension = Path.GetExtension(filePath).Replace(".", string.Empty).ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
public MediaErrorBuilder WithComment(string comment)
|
||||
{
|
||||
_mediaError.Comment = comment.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
public MediaErrorBuilder WithDetails(string details)
|
||||
{
|
||||
_mediaError.Details = details.Trim();
|
||||
return this;
|
||||
}
|
||||
}
|
@ -44,13 +44,16 @@ public class ArchiveService : IArchiveService
|
||||
private readonly ILogger<ArchiveService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly IMediaErrorService _mediaErrorService;
|
||||
private const string ComicInfoFilename = "ComicInfo.xml";
|
||||
|
||||
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService, IImageService imageService)
|
||||
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService,
|
||||
IImageService imageService, IMediaErrorService mediaErrorService)
|
||||
{
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
_imageService = imageService;
|
||||
_mediaErrorService = mediaErrorService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -120,6 +123,8 @@ public class ArchiveService : IArchiveService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath);
|
||||
_mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService,
|
||||
"This archive cannot be read or not supported", ex);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@ -238,6 +243,8 @@ public class ArchiveService : IArchiveService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath);
|
||||
_mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService,
|
||||
"This archive cannot be read or not supported", ex);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
@ -403,6 +410,8 @@ public class ArchiveService : IArchiveService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[GetComicInfo] There was an exception when reading archive stream: {Filepath}", archivePath);
|
||||
_mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService,
|
||||
"This archive cannot be read or not supported", ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -485,9 +494,11 @@ public class ArchiveService : IArchiveService
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(e, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath);
|
||||
_logger.LogWarning(ex, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath);
|
||||
_mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService,
|
||||
"This archive cannot be read or not supported", ex);
|
||||
throw new KavitaException(
|
||||
$"There was an error when extracting {archivePath}. Check the file exists, has read permissions or the server OS can support all path characters.");
|
||||
}
|
||||
|
@ -60,6 +60,7 @@ public class BookService : IBookService
|
||||
private readonly ILogger<BookService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly IMediaErrorService _mediaErrorService;
|
||||
private readonly StylesheetParser _cssParser = new ();
|
||||
private static readonly RecyclableMemoryStreamManager StreamManager = new ();
|
||||
private const string CssScopeClass = ".book-content";
|
||||
@ -72,11 +73,12 @@ public class BookService : IBookService
|
||||
}
|
||||
};
|
||||
|
||||
public BookService(ILogger<BookService> logger, IDirectoryService directoryService, IImageService imageService)
|
||||
public BookService(ILogger<BookService> logger, IDirectoryService directoryService, IImageService imageService, IMediaErrorService mediaErrorService)
|
||||
{
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
_imageService = imageService;
|
||||
_mediaErrorService = mediaErrorService;
|
||||
}
|
||||
|
||||
private static bool HasClickableHrefPart(HtmlNode anchor)
|
||||
@ -394,6 +396,8 @@ public class BookService : IBookService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error reading css file for inlining likely due to a key mismatch in metadata");
|
||||
await _mediaErrorService.ReportMediaIssueAsync(book.FilePath, MediaErrorProducer.BookService,
|
||||
"There was an error reading css file for inlining likely due to a key mismatch in metadata", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -480,7 +484,9 @@ public class BookService : IBookService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[GetComicInfo] There was an exception getting metadata");
|
||||
_logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata");
|
||||
_mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
|
||||
"There was an exception parsing metadata", ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -553,6 +559,8 @@ public class BookService : IBookService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[BookService] There was an exception getting number of pages, defaulting to 0");
|
||||
_mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
|
||||
"There was an exception getting number of pages, defaulting to 0", ex);
|
||||
}
|
||||
|
||||
return 0;
|
||||
@ -697,6 +705,8 @@ public class BookService : IBookService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[BookService] There was an exception when opening epub book: {FileName}", filePath);
|
||||
_mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
|
||||
"There was an exception when opening epub book", ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -916,8 +926,9 @@ public class BookService : IBookService
|
||||
}
|
||||
} catch (Exception ex)
|
||||
{
|
||||
// NOTE: We can log this to media analysis service
|
||||
_logger.LogError(ex, "There was an issue reading one of the pages for {Book}", book.FilePath);
|
||||
await _mediaErrorService.ReportMediaIssueAsync(book.FilePath, MediaErrorProducer.BookService,
|
||||
"There was an issue reading one of the pages for", ex);
|
||||
}
|
||||
|
||||
throw new KavitaException("Could not find the appropriate html for that page");
|
||||
@ -990,6 +1001,8 @@ public class BookService : IBookService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath);
|
||||
_mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService,
|
||||
"There was a critical error and prevented thumbnail generation", ex);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
@ -1014,6 +1027,8 @@ public class BookService : IBookService
|
||||
_logger.LogWarning(ex,
|
||||
"[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image",
|
||||
fileFilePath);
|
||||
_mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService,
|
||||
"There was a critical error and prevented thumbnail generation", ex);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
|
67
API/Services/MediaErrorService.cs
Normal file
67
API/Services/MediaErrorService.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Helpers.Builders;
|
||||
using Hangfire;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public enum MediaErrorProducer
|
||||
{
|
||||
BookService = 0,
|
||||
ArchiveService = 1
|
||||
|
||||
}
|
||||
|
||||
public interface IMediaErrorService
|
||||
{
|
||||
Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, string details);
|
||||
void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, string details);
|
||||
Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, Exception ex);
|
||||
void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, Exception ex);
|
||||
}
|
||||
|
||||
public class MediaErrorService : IMediaErrorService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public MediaErrorService(IUnitOfWork unitOfWork)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, Exception ex)
|
||||
{
|
||||
await ReportMediaIssueAsync(filename, producer, errorMessage, ex.Message);
|
||||
}
|
||||
|
||||
public void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, Exception ex)
|
||||
{
|
||||
// To avoid overhead on commits, do async. We don't need to wait.
|
||||
BackgroundJob.Enqueue(() => ReportMediaIssueAsync(filename, producer, errorMessage, ex.Message));
|
||||
}
|
||||
|
||||
public void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, string details)
|
||||
{
|
||||
// To avoid overhead on commits, do async. We don't need to wait.
|
||||
BackgroundJob.Enqueue(() => ReportMediaIssueAsync(filename, producer, errorMessage, details));
|
||||
}
|
||||
|
||||
public async Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, string details)
|
||||
{
|
||||
var error = new MediaErrorBuilder(filename)
|
||||
.WithComment(errorMessage)
|
||||
.WithDetails(details)
|
||||
.Build();
|
||||
|
||||
if (await _unitOfWork.MediaErrorRepository.ExistsAsync(error))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_unitOfWork.MediaErrorRepository.Attach(error);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
}
|
@ -399,6 +399,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
|
||||
var scheduledJobs = JobStorage.Current.GetMonitoringApi().ScheduledJobs(0, int.MaxValue);
|
||||
ret = scheduledJobs.Any(j =>
|
||||
j.Value.Job != null &&
|
||||
j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) &&
|
||||
j.Value.Job.Method.Name.Equals(methodName) &&
|
||||
j.Value.Job.Method.DeclaringType.Name.Equals(className));
|
||||
|
@ -657,19 +657,13 @@ public class ProcessSeries : IProcessSeries
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? info)
|
||||
public void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo)
|
||||
{
|
||||
if (comicInfo == null) return;
|
||||
var firstFile = chapter.Files.MinBy(x => x.Chapter);
|
||||
if (firstFile == null ||
|
||||
_cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) return;
|
||||
|
||||
var comicInfo = info;
|
||||
if (info == null)
|
||||
{
|
||||
comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath);
|
||||
}
|
||||
|
||||
if (comicInfo == null) return;
|
||||
_logger.LogTrace("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath);
|
||||
|
||||
chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating);
|
||||
@ -807,11 +801,15 @@ public class ProcessSeries : IProcessSeries
|
||||
private static IList<string> GetTagValues(string comicInfoTagSeparatedByComma)
|
||||
{
|
||||
// TODO: Move this to an extension and test it
|
||||
if (!string.IsNullOrEmpty(comicInfoTagSeparatedByComma))
|
||||
if (string.IsNullOrEmpty(comicInfoTagSeparatedByComma))
|
||||
{
|
||||
return comicInfoTagSeparatedByComma.Split(",").Select(s => s.Trim()).DistinctBy(Parser.Parser.Normalize).ToList();
|
||||
return ImmutableList<string>.Empty;
|
||||
}
|
||||
return ImmutableList<string>.Empty;
|
||||
|
||||
return comicInfoTagSeparatedByComma.Split(",")
|
||||
.Select(s => s.Trim())
|
||||
.DistinctBy(Parser.Parser.Normalize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -4,6 +4,7 @@ import { environment } from 'src/environments/environment';
|
||||
import { ServerInfo } from '../admin/_models/server-info';
|
||||
import { UpdateVersionEvent } from '../_models/events/update-version-event';
|
||||
import { Job } from '../_models/job/job';
|
||||
import { KavitaMediaError } from '../admin/_models/media-error';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -61,4 +62,12 @@ export class ServerService {
|
||||
convertCovers() {
|
||||
return this.httpClient.post(this.baseUrl + 'server/convert-covers', {});
|
||||
}
|
||||
|
||||
getMediaErrors() {
|
||||
return this.httpClient.get<Array<KavitaMediaError>>(this.baseUrl + 'server/media-errors', {});
|
||||
}
|
||||
|
||||
clearMediaAlerts() {
|
||||
return this.httpClient.post(this.baseUrl + 'server/clear-media-alerts', {});
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ export interface SortEvent<T> {
|
||||
'(click)': 'rotate()',
|
||||
},
|
||||
})
|
||||
// eslint-disable-next-line @angular-eslint/directive-class-suffix
|
||||
export class SortableHeader<T> {
|
||||
@Input() sortable: SortColumn<T> = '';
|
||||
@Input() direction: SortDirection = '';
|
||||
|
8
UI/Web/src/app/admin/_models/media-error.ts
Normal file
8
UI/Web/src/app/admin/_models/media-error.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface KavitaMediaError {
|
||||
extension: string;
|
||||
filePath: string;
|
||||
comment: string;
|
||||
details: string;
|
||||
created: string;
|
||||
createdUtc: string;
|
||||
}
|
@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AdminRoutingModule } from './admin-routing.module';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { NgbDropdownModule, NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgbAccordionModule, NgbDropdownModule, NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ManageLibraryComponent } from './manage-library/manage-library.component';
|
||||
import { ManageUsersComponent } from './manage-users/manage-users.component';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
@ -25,6 +25,7 @@ import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tas
|
||||
import { ManageLogsComponent } from './manage-logs/manage-logs.component';
|
||||
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
|
||||
import { StatisticsModule } from '../statistics/statistics.module';
|
||||
import { ManageAlertsComponent } from './manage-alerts/manage-alerts.component';
|
||||
|
||||
|
||||
|
||||
@ -47,6 +48,7 @@ import { StatisticsModule } from '../statistics/statistics.module';
|
||||
ManageEmailSettingsComponent,
|
||||
ManageTasksSettingsComponent,
|
||||
ManageLogsComponent,
|
||||
ManageAlertsComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -57,6 +59,7 @@ import { StatisticsModule } from '../statistics/statistics.module';
|
||||
NgbTooltipModule,
|
||||
NgbTypeaheadModule, // Directory Picker
|
||||
NgbDropdownModule,
|
||||
NgbAccordionModule,
|
||||
SharedModule,
|
||||
PipeModule,
|
||||
SidenavModule,
|
||||
|
@ -0,0 +1,39 @@
|
||||
<p>This table contains issues found during scan or reading of your media. This list is non-managed. You can clear it at any time and use Library (Force) Scan to perform analysis.</p>
|
||||
|
||||
<button class="btn btn-primary mb-2" (click)="clear()">Clear Alerts</button>
|
||||
<table class="table table-light table-hover table-sm table-hover">
|
||||
<thead #header>
|
||||
<tr>
|
||||
<th scope="col"sortable="extension" (sort)="onSort($event)">
|
||||
Extension
|
||||
</th>
|
||||
<th scope="col" sortable="filePath" (sort)="onSort($event)">
|
||||
File
|
||||
</th>
|
||||
<th scope="col" sortable="comment" (sort)="onSort($event)">
|
||||
Comment
|
||||
</th>
|
||||
<th scope="col" sortable="details" (sort)="onSort($event)">
|
||||
Details
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody #container>
|
||||
<tr *ngIf="isLoading"><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
|
||||
<tr *ngIf="data.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
|
||||
<tr *ngFor="let item of data; index as i">
|
||||
<td>
|
||||
{{item.extension}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.filePath}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.comment}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.details}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
@ -0,0 +1,75 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnInit, QueryList, ViewChildren, inject } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subject, combineLatest, filter, map, shareReplay, takeUntil } from 'rxjs';
|
||||
import { SortEvent, SortableHeader, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
|
||||
import { KavitaMediaError } from '../_models/media-error';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-alerts',
|
||||
templateUrl: './manage-alerts.component.html',
|
||||
styleUrls: ['./manage-alerts.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ManageAlertsComponent implements OnInit {
|
||||
|
||||
@ViewChildren(SortableHeader<KavitaMediaError>) headers!: QueryList<SortableHeader<KavitaMediaError>>;
|
||||
private readonly serverService = inject(ServerService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
messageHubUpdate$ = this.messageHub.messages$.pipe(takeUntil(this.onDestroy), filter(m => m.event === EVENTS.ScanSeries), shareReplay());
|
||||
currentSort = new BehaviorSubject<SortEvent<KavitaMediaError>>({column: 'extension', direction: 'asc'});
|
||||
currentSort$: Observable<SortEvent<KavitaMediaError>> = this.currentSort.asObservable();
|
||||
|
||||
data: Array<KavitaMediaError> = [];
|
||||
isLoading = true;
|
||||
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.loadData();
|
||||
|
||||
this.messageHubUpdate$.subscribe(_ => this.loadData());
|
||||
|
||||
this.currentSort$.subscribe(sortConfig => {
|
||||
this.data = (sortConfig.column) ? this.data.sort((a: KavitaMediaError, b: KavitaMediaError) => {
|
||||
if (sortConfig.column === '') return 0;
|
||||
const res = compare(a[sortConfig.column], b[sortConfig.column]);
|
||||
return sortConfig.direction === 'asc' ? res : -res;
|
||||
}) : this.data;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
onSort(evt: any) {
|
||||
//SortEvent<KavitaMediaError>
|
||||
this.currentSort.next(evt);
|
||||
|
||||
// Must clear out headers here
|
||||
this.headers.forEach((header) => {
|
||||
if (header.sortable !== evt.column) {
|
||||
header.direction = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadData() {
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.serverService.getMediaErrors().subscribe(d => {
|
||||
this.data = d;
|
||||
this.isLoading = false;
|
||||
console.log(this.data)
|
||||
console.log(this.isLoading)
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.serverService.clearMediaAlerts().subscribe(_ => this.loadData());
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
<div class="container-fluid">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined" class="mb-2">
|
||||
|
||||
<div class="row g-0">
|
||||
<p>WebP can drastically reduce space requirements for files. WebP is not supported on all browsers or versions. To learn if these settings are appropriate for your setup, visit <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">Can I Use</a>.</p>
|
||||
@ -48,6 +48,14 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Accordion with Issues from Media anaysis -->
|
||||
|
||||
<ngb-accordion #a="ngbAccordion">
|
||||
<ngb-panel>
|
||||
<ng-template ngbPanelTitle>
|
||||
Media Issues
|
||||
</ng-template>
|
||||
<ng-template ngbPanelContent>
|
||||
<app-manage-alerts></app-manage-alerts>
|
||||
</ng-template>
|
||||
</ngb-panel>
|
||||
</ngb-accordion>
|
||||
</div>
|
||||
|
@ -1,28 +1,28 @@
|
||||
<ng-container>
|
||||
|
||||
<ng-container *ngIf="items.length > 100; else dragList">
|
||||
<div class="example-list list-group-flush">
|
||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll">
|
||||
<div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity">
|
||||
<div class="d-flex list-container">
|
||||
<div class="me-3 align-middle">
|
||||
<div style="padding-top: 40px">
|
||||
<label for="reorder-{{i}}" class="form-label visually-hidden">Reorder</label>
|
||||
<input *ngIf="accessibilityMode" id="reorder-{{i}}" class="form-control" type="number" inputmode="numeric" min="0" [max]="items.length - 1" [value]="i" style="width: 60px"
|
||||
(focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
|
||||
</div>
|
||||
<div class="example-list list-group-flush">
|
||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll">
|
||||
<div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity">
|
||||
<div class="d-flex list-container">
|
||||
<div class="me-3 align-middle">
|
||||
<div style="padding-top: 40px">
|
||||
<label for="reorder-{{i}}" class="form-label visually-hidden">Reorder</label>
|
||||
<input *ngIf="accessibilityMode" id="reorder-{{i}}" class="form-control" type="number" inputmode="numeric" min="0" [max]="items.length - 1" [value]="i" style="width: 60px"
|
||||
(focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
|
||||
</div>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
|
||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">Remove item</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
|
||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">Remove item</span>
|
||||
</button>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #dragList>
|
||||
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
|
||||
|
88
openapi.json
88
openapi.json
@ -7638,6 +7638,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Server/media-errors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Returns a list of issues found during scanning or reading in which files may have corruption or bad metadata (structural metadata)",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MediaErrorDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MediaErrorDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MediaErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Server/clear-media-alerts": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Deletes all media errors",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Settings/base-url": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -12231,6 +12283,40 @@
|
||||
"additionalProperties": false,
|
||||
"description": "This is used for bulk updating a set of volume and or chapters in one go"
|
||||
},
|
||||
"MediaErrorDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"extension": {
|
||||
"type": "string",
|
||||
"description": "Format Type (RAR, ZIP, 7Zip, Epub, PDF)",
|
||||
"nullable": true
|
||||
},
|
||||
"filePath": {
|
||||
"type": "string",
|
||||
"description": "Full Filepath to the file that has some issue",
|
||||
"nullable": true
|
||||
},
|
||||
"comment": {
|
||||
"type": "string",
|
||||
"description": "Developer defined string",
|
||||
"nullable": true
|
||||
},
|
||||
"details": {
|
||||
"type": "string",
|
||||
"description": "Exception message",
|
||||
"nullable": true
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"createdUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MemberDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -15566,4 +15652,4 @@
|
||||
"description": "Responsible for all things Want To Read"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user