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:
Joe Milazzo 2023-05-07 12:14:39 -05:00 committed by GitHub
parent 642b23ed61
commit d1e4878345
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 2586 additions and 57 deletions

View File

@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using API.Services; using API.Services;
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order; using BenchmarkDotNet.Order;
using NSubstitute;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Formats.Webp;
@ -31,7 +32,7 @@ public class ArchiveServiceBenchmark
{ {
_directoryService = new DirectoryService(null, new FileSystem()); _directoryService = new DirectoryService(null, new FileSystem());
_imageService = new ImageService(null, _directoryService); _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)] [Benchmark(Baseline = true)]

View File

@ -26,7 +26,9 @@ public class ArchiveServiceTests
public ArchiveServiceTests(ITestOutputHelper testOutputHelper) public ArchiveServiceTests(ITestOutputHelper testOutputHelper)
{ {
_testOutputHelper = 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] [Theory]
@ -164,7 +166,7 @@ public class ArchiveServiceTests
{ {
var ds = Substitute.For<DirectoryService>(_directoryServiceLogger, new FileSystem()); var ds = Substitute.For<DirectoryService>(_directoryServiceLogger, new FileSystem());
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), ds); 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 testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"));
var expectedBytes = Image.Thumbnail(Path.Join(testDirectory, expectedOutputFile), 320).WriteToBuffer(".png"); 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 imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService);
var archiveService = Substitute.For<ArchiveService>(_logger, 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 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"); var outputDir = Path.Join(testDirectory, "output");
@ -220,7 +223,7 @@ public class ArchiveServiceTests
{ {
var imageService = Substitute.For<IImageService>(); var imageService = Substitute.For<IImageService>();
imageService.WriteCoverThumbnail(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<string>()).Returns(x => "cover.jpg"); 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 testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/");
var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile)); var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile));
var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output"); var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output");

View File

@ -15,7 +15,9 @@ public class BookServiceTests
public BookServiceTests() public BookServiceTests()
{ {
var directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem()); 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] [Theory]

View File

@ -0,0 +1,6 @@
namespace API.Constants;
public abstract class ControllerConstants
{
public const int MaxUploadSizeBytes = 8_000_000;
}

View File

@ -3,10 +3,13 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data;
using API.DTOs.Jobs; using API.DTOs.Jobs;
using API.DTOs.MediaErrors;
using API.DTOs.Stats; using API.DTOs.Stats;
using API.DTOs.Update; using API.DTOs.Update;
using API.Extensions; using API.Extensions;
using API.Helpers;
using API.Services; using API.Services;
using API.Services.Tasks; using API.Services.Tasks;
using Hangfire; using Hangfire;
@ -14,7 +17,6 @@ using Hangfire.Storage;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler; using TaskScheduler = API.Services.TaskScheduler;
@ -23,7 +25,6 @@ namespace API.Controllers;
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
public class ServerController : BaseApiController public class ServerController : BaseApiController
{ {
private readonly IHostApplicationLifetime _applicationLifetime;
private readonly ILogger<ServerController> _logger; private readonly ILogger<ServerController> _logger;
private readonly IBackupService _backupService; private readonly IBackupService _backupService;
private readonly IArchiveService _archiveService; private readonly IArchiveService _archiveService;
@ -34,13 +35,13 @@ public class ServerController : BaseApiController
private readonly IScannerService _scannerService; private readonly IScannerService _scannerService;
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly ITaskScheduler _taskScheduler; 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, IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
ICleanupService cleanupService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService, ICleanupService cleanupService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService,
ITaskScheduler taskScheduler) ITaskScheduler taskScheduler, IUnitOfWork unitOfWork)
{ {
_applicationLifetime = applicationLifetime;
_logger = logger; _logger = logger;
_backupService = backupService; _backupService = backupService;
_archiveService = archiveService; _archiveService = archiveService;
@ -51,6 +52,7 @@ public class ServerController : BaseApiController
_scannerService = scannerService; _scannerService = scannerService;
_accountService = accountService; _accountService = accountService;
_taskScheduler = taskScheduler; _taskScheduler = taskScheduler;
_unitOfWork = unitOfWork;
} }
/// <summary> /// <summary>
@ -213,5 +215,28 @@ public class ServerController : BaseApiController
return Ok(recurringJobs); 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();
}
} }

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants;
using API.Data; using API.Data;
using API.DTOs.Uploads; using API.DTOs.Uploads;
using API.Extensions; using API.Extensions;
@ -78,7 +79,7 @@ public class UploadController : BaseApiController
/// <param name="uploadFileDto"></param> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("series")] [HttpPost("series")]
public async Task<ActionResult> UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto) public async Task<ActionResult> UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto)
{ {
@ -126,7 +127,7 @@ public class UploadController : BaseApiController
/// <param name="uploadFileDto"></param> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("collection")] [HttpPost("collection")]
public async Task<ActionResult> UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto) 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> /// <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> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("reading-list")] [HttpPost("reading-list")]
public async Task<ActionResult> UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) public async Task<ActionResult> UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto)
{ {
@ -238,7 +239,7 @@ public class UploadController : BaseApiController
/// <param name="uploadFileDto"></param> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("chapter")] [HttpPost("chapter")]
public async Task<ActionResult> UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto) public async Task<ActionResult> UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto)
{ {
@ -294,7 +295,7 @@ public class UploadController : BaseApiController
/// <param name="uploadFileDto"></param> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("library")] [HttpPost("library")]
public async Task<ActionResult> UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto) public async Task<ActionResult> UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto)
{ {

View 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; }
}

View File

@ -47,6 +47,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<FolderPath> FolderPath { get; set; } = null!; public DbSet<FolderPath> FolderPath { get; set; } = null!;
public DbSet<Device> Device { get; set; } = null!; public DbSet<Device> Device { get; set; } = null!;
public DbSet<ServerStatistics> ServerStatistics { get; set; } = null!; public DbSet<ServerStatistics> ServerStatistics { get; set; } = null!;
public DbSet<MediaError> MediaError { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

View File

@ -714,6 +714,41 @@ namespace API.Data.Migrations
b.ToTable("MangaFile"); 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 => modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

View 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();
}
}

View File

@ -25,6 +25,7 @@ public interface IUnitOfWork
ISiteThemeRepository SiteThemeRepository { get; } ISiteThemeRepository SiteThemeRepository { get; }
IMangaFileRepository MangaFileRepository { get; } IMangaFileRepository MangaFileRepository { get; }
IDeviceRepository DeviceRepository { get; } IDeviceRepository DeviceRepository { get; }
IMediaErrorRepository MediaErrorRepository { get; }
bool Commit(); bool Commit();
Task<bool> CommitAsync(); Task<bool> CommitAsync();
bool HasChanges(); bool HasChanges();
@ -62,6 +63,7 @@ public class UnitOfWork : IUnitOfWork
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper); public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context); public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context);
public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper); public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper);
public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper);
/// <summary> /// <summary>
/// Commits changes to the DB. Completes the open transaction. /// Commits changes to the DB. Completes the open transaction.

View 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; }
}

View File

@ -49,6 +49,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IReadingListService, ReadingListService>(); services.AddScoped<IReadingListService, ReadingListService>();
services.AddScoped<IDeviceService, DeviceService>(); services.AddScoped<IDeviceService, DeviceService>();
services.AddScoped<IStatisticService, StatisticService>(); services.AddScoped<IStatisticService, StatisticService>();
services.AddScoped<IMediaErrorService, MediaErrorService>();
services.AddScoped<IScannerService, ScannerService>(); services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>(); services.AddScoped<IMetadataService, MetadataService>();

View File

@ -4,6 +4,7 @@ using API.DTOs;
using API.DTOs.Account; using API.DTOs.Account;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Device; using API.DTOs.Device;
using API.DTOs.MediaErrors;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;
@ -33,6 +34,7 @@ public class AutoMapperProfiles : Profile
CreateMap<Tag, TagDto>(); CreateMap<Tag, TagDto>();
CreateMap<AgeRating, AgeRatingDto>(); CreateMap<AgeRating, AgeRatingDto>();
CreateMap<PublicationStatus, PublicationStatusDto>(); CreateMap<PublicationStatus, PublicationStatusDto>();
CreateMap<MediaError, MediaErrorDto>();
CreateMap<AppUserProgress, ProgressDto>() CreateMap<AppUserProgress, ProgressDto>()
.ForMember(dest => dest.PageNum, .ForMember(dest => dest.PageNum,

View 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;
}
}

View File

@ -44,13 +44,16 @@ public class ArchiveService : IArchiveService
private readonly ILogger<ArchiveService> _logger; private readonly ILogger<ArchiveService> _logger;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService; private readonly IImageService _imageService;
private readonly IMediaErrorService _mediaErrorService;
private const string ComicInfoFilename = "ComicInfo.xml"; 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; _logger = logger;
_directoryService = directoryService; _directoryService = directoryService;
_imageService = imageService; _imageService = imageService;
_mediaErrorService = mediaErrorService;
} }
/// <summary> /// <summary>
@ -120,6 +123,8 @@ public class ArchiveService : IArchiveService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); _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; return 0;
} }
} }
@ -238,6 +243,8 @@ public class ArchiveService : IArchiveService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); _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; return string.Empty;
@ -403,6 +410,8 @@ public class ArchiveService : IArchiveService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[GetComicInfo] There was an exception when reading archive stream: {Filepath}", archivePath); _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; 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( 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."); $"There was an error when extracting {archivePath}. Check the file exists, has read permissions or the server OS can support all path characters.");
} }

View File

@ -60,6 +60,7 @@ public class BookService : IBookService
private readonly ILogger<BookService> _logger; private readonly ILogger<BookService> _logger;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService; private readonly IImageService _imageService;
private readonly IMediaErrorService _mediaErrorService;
private readonly StylesheetParser _cssParser = new (); private readonly StylesheetParser _cssParser = new ();
private static readonly RecyclableMemoryStreamManager StreamManager = new (); private static readonly RecyclableMemoryStreamManager StreamManager = new ();
private const string CssScopeClass = ".book-content"; 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; _logger = logger;
_directoryService = directoryService; _directoryService = directoryService;
_imageService = imageService; _imageService = imageService;
_mediaErrorService = mediaErrorService;
} }
private static bool HasClickableHrefPart(HtmlNode anchor) private static bool HasClickableHrefPart(HtmlNode anchor)
@ -394,6 +396,8 @@ public class BookService : IBookService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "There was an error reading css file for inlining likely due to a key mismatch in metadata"); _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) 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; return null;
@ -553,6 +559,8 @@ public class BookService : IBookService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[BookService] There was an exception getting number of pages, defaulting to 0"); _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; return 0;
@ -697,6 +705,8 @@ public class BookService : IBookService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[BookService] There was an exception when opening epub book: {FileName}", filePath); _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; return null;
@ -916,8 +926,9 @@ public class BookService : IBookService
} }
} catch (Exception ex) } 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); _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"); throw new KavitaException("Could not find the appropriate html for that page");
@ -990,6 +1001,8 @@ public class BookService : IBookService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); _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; return string.Empty;
@ -1014,6 +1027,8 @@ public class BookService : IBookService
_logger.LogWarning(ex, _logger.LogWarning(ex,
"[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image",
fileFilePath); fileFilePath);
_mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService,
"There was a critical error and prevented thumbnail generation", ex);
} }
return string.Empty; return string.Empty;

View 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();
}
}

View File

@ -399,6 +399,7 @@ public class TaskScheduler : ITaskScheduler
var scheduledJobs = JobStorage.Current.GetMonitoringApi().ScheduledJobs(0, int.MaxValue); var scheduledJobs = JobStorage.Current.GetMonitoringApi().ScheduledJobs(0, int.MaxValue);
ret = scheduledJobs.Any(j => ret = scheduledJobs.Any(j =>
j.Value.Job != null &&
j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) && j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) &&
j.Value.Job.Method.Name.Equals(methodName) && j.Value.Job.Method.Name.Equals(methodName) &&
j.Value.Job.Method.DeclaringType.Name.Equals(className)); j.Value.Job.Method.DeclaringType.Name.Equals(className));

View File

@ -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); var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null || if (firstFile == null ||
_cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) return; _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); _logger.LogTrace("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath);
chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating); chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating);
@ -807,11 +801,15 @@ public class ProcessSeries : IProcessSeries
private static IList<string> GetTagValues(string comicInfoTagSeparatedByComma) private static IList<string> GetTagValues(string comicInfoTagSeparatedByComma)
{ {
// TODO: Move this to an extension and test it // 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> /// <summary>

View File

@ -4,6 +4,7 @@ import { environment } from 'src/environments/environment';
import { ServerInfo } from '../admin/_models/server-info'; import { ServerInfo } from '../admin/_models/server-info';
import { UpdateVersionEvent } from '../_models/events/update-version-event'; import { UpdateVersionEvent } from '../_models/events/update-version-event';
import { Job } from '../_models/job/job'; import { Job } from '../_models/job/job';
import { KavitaMediaError } from '../admin/_models/media-error';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -61,4 +62,12 @@ export class ServerService {
convertCovers() { convertCovers() {
return this.httpClient.post(this.baseUrl + 'server/convert-covers', {}); 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', {});
}
} }

View File

@ -18,6 +18,7 @@ export interface SortEvent<T> {
'(click)': 'rotate()', '(click)': 'rotate()',
}, },
}) })
// eslint-disable-next-line @angular-eslint/directive-class-suffix
export class SortableHeader<T> { export class SortableHeader<T> {
@Input() sortable: SortColumn<T> = ''; @Input() sortable: SortColumn<T> = '';
@Input() direction: SortDirection = ''; @Input() direction: SortDirection = '';

View File

@ -0,0 +1,8 @@
export interface KavitaMediaError {
extension: string;
filePath: string;
comment: string;
details: string;
created: string;
createdUtc: string;
}

View File

@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { AdminRoutingModule } from './admin-routing.module'; import { AdminRoutingModule } from './admin-routing.module';
import { DashboardComponent } from './dashboard/dashboard.component'; 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 { ManageLibraryComponent } from './manage-library/manage-library.component';
import { ManageUsersComponent } from './manage-users/manage-users.component'; import { ManageUsersComponent } from './manage-users/manage-users.component';
import { SharedModule } from '../shared/shared.module'; 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 { ManageLogsComponent } from './manage-logs/manage-logs.component';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { StatisticsModule } from '../statistics/statistics.module'; 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, ManageEmailSettingsComponent,
ManageTasksSettingsComponent, ManageTasksSettingsComponent,
ManageLogsComponent, ManageLogsComponent,
ManageAlertsComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -57,6 +59,7 @@ import { StatisticsModule } from '../statistics/statistics.module';
NgbTooltipModule, NgbTooltipModule,
NgbTypeaheadModule, // Directory Picker NgbTypeaheadModule, // Directory Picker
NgbDropdownModule, NgbDropdownModule,
NgbAccordionModule,
SharedModule, SharedModule,
PipeModule, PipeModule,
SidenavModule, SidenavModule,

View File

@ -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>

View File

@ -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());
}
}

View File

@ -1,5 +1,5 @@
<div class="container-fluid"> <div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined"> <form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined" class="mb-2">
<div class="row g-0"> <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> <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> </div>
</form> </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> </div>

View File

@ -1,28 +1,28 @@
<ng-container> <ng-container>
<ng-container *ngIf="items.length > 100; else dragList"> <ng-container *ngIf="items.length > 100; else dragList">
<div class="example-list list-group-flush"> <div class="example-list list-group-flush">
<virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll"> <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="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity">
<div class="d-flex list-container"> <div class="d-flex list-container">
<div class="me-3 align-middle"> <div class="me-3 align-middle">
<div style="padding-top: 40px"> <div style="padding-top: 40px">
<label for="reorder-{{i}}" class="form-label visually-hidden">Reorder</label> <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" <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"> (focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
</div>
</div> </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> </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> </div>
</virtual-scroller> </div>
</div> </virtual-scroller>
</div>
</ng-container> </ng-container>
<ng-template #dragList> <ng-template #dragList>
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)"> <div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">

View File

@ -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": { "/api/Settings/base-url": {
"get": { "get": {
"tags": [ "tags": [
@ -12231,6 +12283,40 @@
"additionalProperties": false, "additionalProperties": false,
"description": "This is used for bulk updating a set of volume and or chapters in one go" "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": { "MemberDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -15566,4 +15652,4 @@
"description": "Responsible for all things Want To Read" "description": "Responsible for all things Want To Read"
} }
] ]
} }