mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Bugfixes and Cover Chooser Upgrades (#1146)
* Fixed a bug where GetNextChapter would return a loose leaf chapter from a special when it should return nothing. * Fixed a bug in events widget when an update comes in after a user refreshes, the active event counter could get out of sync, thus showing "Nothing going on here" Refactored the events widget to be named appropriately. * Refactored code to have errors during threaded tasks propagate to the UI via events widget (css still needed). Removed ScanLibraryError in favor of generic Error event. * Fixed up some code and added ability to remove the event from events widget * Fixed a bug where modifiying certain fields, like summary, wouldn't lock the field * Fixed a few bugs where lock state was not being set in the DB correctly nor were certain combinations of locking fields and editing fields. * Removed debug code * Updated the discord alert to tag new group * Refactored cover upload to actually handle uploading a temp file via url on the backend so that users can user change cover by url. Fixed up some bugs that occured when chaning the image container in a previous PR. * Code cleanup * Cleaned up the css on the error items * Code cleanup
This commit is contained in:
parent
d2f05cf5ae
commit
e41b455d09
2
.github/workflows/sonar-scan.yml
vendored
2
.github/workflows/sonar-scan.yml
vendored
@ -230,7 +230,7 @@ jobs:
|
|||||||
severity: info
|
severity: info
|
||||||
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
|
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
|
||||||
details: '${{ steps.parse-body.outputs.BODY }}'
|
details: '${{ steps.parse-body.outputs.BODY }}'
|
||||||
text: <@&939225350775406643> A new nightly build has been released for docker.
|
text: <@&950058626658234398> A new nightly build has been released for docker.
|
||||||
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
|
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
|
||||||
|
|
||||||
stable:
|
stable:
|
||||||
|
@ -595,7 +595,7 @@ public class ReaderServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter()
|
public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials()
|
||||||
{
|
{
|
||||||
await ResetDB();
|
await ResetDB();
|
||||||
|
|
||||||
@ -636,7 +636,7 @@ public class ReaderServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial()
|
public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial_NoLooseLeafChapters()
|
||||||
{
|
{
|
||||||
await ResetDB();
|
await ResetDB();
|
||||||
|
|
||||||
@ -678,6 +678,87 @@ public class ReaderServiceTests
|
|||||||
Assert.Equal("A.cbz", actualChapter.Range);
|
Assert.Equal("A.cbz", actualChapter.Range);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNextChapterIdAsync_ShouldMoveFromLooseLeafChapterToSpecial()
|
||||||
|
{
|
||||||
|
await ResetDB();
|
||||||
|
|
||||||
|
_context.Series.Add(new Series()
|
||||||
|
{
|
||||||
|
Name = "Test",
|
||||||
|
Library = new Library() {
|
||||||
|
Name = "Test LIb",
|
||||||
|
Type = LibraryType.Manga,
|
||||||
|
},
|
||||||
|
Volumes = new List<Volume>()
|
||||||
|
{
|
||||||
|
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||||
|
{
|
||||||
|
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||||
|
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||||
|
EntityFactory.CreateChapter("A.cbz", true, new List<MangaFile>()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_context.AppUser.Add(new AppUser()
|
||||||
|
{
|
||||||
|
UserName = "majora2007"
|
||||||
|
});
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||||
|
|
||||||
|
|
||||||
|
var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1);
|
||||||
|
Assert.NotEqual(-1, nextChapter);
|
||||||
|
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter);
|
||||||
|
Assert.Equal("A.cbz", actualChapter.Range);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromSpecial_WithVolumeAndLooseLeafChapters()
|
||||||
|
{
|
||||||
|
await ResetDB();
|
||||||
|
|
||||||
|
_context.Series.Add(new Series()
|
||||||
|
{
|
||||||
|
Name = "Test",
|
||||||
|
Library = new Library() {
|
||||||
|
Name = "Test LIb",
|
||||||
|
Type = LibraryType.Manga,
|
||||||
|
},
|
||||||
|
Volumes = new List<Volume>()
|
||||||
|
{
|
||||||
|
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||||
|
{
|
||||||
|
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||||
|
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||||
|
EntityFactory.CreateChapter("A.cbz", true, new List<MangaFile>()),
|
||||||
|
}),
|
||||||
|
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||||
|
{
|
||||||
|
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_context.AppUser.Add(new AppUser()
|
||||||
|
{
|
||||||
|
UserName = "majora2007"
|
||||||
|
});
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||||
|
|
||||||
|
|
||||||
|
var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 3, 1);
|
||||||
|
Assert.Equal(-1, nextChapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial()
|
public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial()
|
||||||
{
|
{
|
||||||
|
@ -4,6 +4,7 @@ using API.Data;
|
|||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace API.Controllers
|
namespace API.Controllers
|
||||||
@ -110,5 +111,22 @@ namespace API.Controllers
|
|||||||
Response.AddCacheHeader(file.FullName);
|
Response.AddCacheHeader(file.FullName);
|
||||||
return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName));
|
return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a temp coverupload image
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filename">Filename of file. This is used with upload/upload-by-url</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("cover-upload")]
|
||||||
|
public ActionResult GetCoverUploadImage(string filename)
|
||||||
|
{
|
||||||
|
var path = Path.Join(_directoryService.TempDirectory, filename);
|
||||||
|
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist");
|
||||||
|
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
|
||||||
|
|
||||||
|
Response.AddCacheHeader(path);
|
||||||
|
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -150,26 +150,14 @@ namespace API.Controllers
|
|||||||
return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library.");
|
return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!series.Name.Equals(updateSeries.Name.Trim()))
|
series.Name = updateSeries.Name.Trim();
|
||||||
{
|
series.SortName = updateSeries.SortName.Trim();
|
||||||
series.Name = updateSeries.Name.Trim();
|
series.LocalizedName = updateSeries.LocalizedName.Trim();
|
||||||
series.NameLocked = true;
|
|
||||||
}
|
|
||||||
if (!series.SortName.Equals(updateSeries.SortName.Trim()))
|
|
||||||
{
|
|
||||||
series.SortName = updateSeries.SortName.Trim();
|
|
||||||
series.SortNameLocked = true;
|
|
||||||
}
|
|
||||||
if (!series.LocalizedName.Equals(updateSeries.LocalizedName.Trim()))
|
|
||||||
{
|
|
||||||
series.LocalizedName = updateSeries.LocalizedName.Trim();
|
|
||||||
series.LocalizedNameLocked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
series.NameLocked = updateSeries.NameLocked;
|
||||||
|
series.SortNameLocked = updateSeries.SortNameLocked;
|
||||||
|
series.LocalizedNameLocked = updateSeries.LocalizedNameLocked;
|
||||||
|
|
||||||
if (!series.NameLocked) series.NameLocked = false;
|
|
||||||
if (!series.SortNameLocked) series.SortNameLocked = false;
|
|
||||||
if (!series.LocalizedNameLocked) series.LocalizedNameLocked = false;
|
|
||||||
|
|
||||||
var needsRefreshMetadata = false;
|
var needsRefreshMetadata = false;
|
||||||
// This is when you hit Reset
|
// This is when you hit Reset
|
||||||
|
@ -7,6 +7,7 @@ using API.DTOs.Update;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Tasks;
|
using API.Services.Tasks;
|
||||||
|
using Hangfire;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs.Uploads;
|
using API.DTOs.Uploads;
|
||||||
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using Flurl.Http;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -19,14 +22,37 @@ namespace API.Controllers
|
|||||||
private readonly IImageService _imageService;
|
private readonly IImageService _imageService;
|
||||||
private readonly ILogger<UploadController> _logger;
|
private readonly ILogger<UploadController> _logger;
|
||||||
private readonly ITaskScheduler _taskScheduler;
|
private readonly ITaskScheduler _taskScheduler;
|
||||||
|
private readonly IDirectoryService _directoryService;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger, ITaskScheduler taskScheduler)
|
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger,
|
||||||
|
ITaskScheduler taskScheduler, IDirectoryService directoryService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_imageService = imageService;
|
_imageService = imageService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_taskScheduler = taskScheduler;
|
_taskScheduler = taskScheduler;
|
||||||
|
_directoryService = directoryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This stores a file (image) in temp directory for use in a cover image replacement flow.
|
||||||
|
/// This is automatically cleaned up.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">Escaped url to download from</param>
|
||||||
|
/// <returns>filename</returns>
|
||||||
|
[Authorize(Policy = "RequireAdminRole")]
|
||||||
|
[HttpPost("upload-by-url")]
|
||||||
|
public async Task<ActionResult<string>> GetImageFromFile(UploadUrlDto dto)
|
||||||
|
{
|
||||||
|
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
|
||||||
|
var format = _directoryService.FileSystem.Path.GetExtension(dto.Url).Replace(".", "");
|
||||||
|
var path = await dto.Url
|
||||||
|
.DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"Could not download file");
|
||||||
|
|
||||||
|
return $"coverupload_{dateString}.{format}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -8,8 +8,8 @@
|
|||||||
public string SortName { get; init; }
|
public string SortName { get; init; }
|
||||||
public bool CoverImageLocked { get; set; }
|
public bool CoverImageLocked { get; set; }
|
||||||
|
|
||||||
public bool UnlockName { get; set; }
|
public bool NameLocked { get; set; }
|
||||||
public bool UnlockSortName { get; set; }
|
public bool SortNameLocked { get; set; }
|
||||||
public bool UnlockLocalizedName { get; set; }
|
public bool LocalizedNameLocked { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
9
API/DTOs/Uploads/UploadUrlDto.cs
Normal file
9
API/DTOs/Uploads/UploadUrlDto.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace API.DTOs.Uploads;
|
||||||
|
|
||||||
|
public class UploadUrlDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// External url
|
||||||
|
/// </summary>
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
@ -278,7 +278,7 @@ public class ReaderService : IReaderService
|
|||||||
{
|
{
|
||||||
// Handle Chapters within current Volume
|
// Handle Chapters within current Volume
|
||||||
// In this case, i need 0 first because 0 represents a full volume file.
|
// In this case, i need 0 first because 0 represents a full volume file.
|
||||||
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting),
|
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer),
|
||||||
currentChapter.Range, dto => dto.Range);
|
currentChapter.Range, dto => dto.Range);
|
||||||
if (chapterId > 0) return chapterId;
|
if (chapterId > 0) return chapterId;
|
||||||
|
|
||||||
@ -291,6 +291,9 @@ public class ReaderService : IReaderService
|
|||||||
var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList();
|
var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList();
|
||||||
if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0"))
|
if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0"))
|
||||||
{
|
{
|
||||||
|
// We need to handle an extra check if the current chapter is the last special, as we should return -1
|
||||||
|
if (currentChapter.IsSpecial) return -1;
|
||||||
|
|
||||||
return chapters.Last().Id;
|
return chapters.Last().Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,6 +91,8 @@ public class BackupService : IBackupService
|
|||||||
if (!_directoryService.ExistOrCreate(backupDirectory))
|
if (!_directoryService.ExistOrCreate(backupDirectory))
|
||||||
{
|
{
|
||||||
_logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory);
|
_logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory);
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||||
|
MessageFactory.ErrorEvent("Backup Service Error",$"Could not write to {backupDirectory}; aborting backup"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,7 +103,9 @@ public class BackupService : IBackupService
|
|||||||
|
|
||||||
if (File.Exists(zipPath))
|
if (File.Exists(zipPath))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("{ZipFile} already exists, aborting", zipPath);
|
_logger.LogCritical("{ZipFile} already exists, aborting", zipPath);
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||||
|
MessageFactory.ErrorEvent("Backup Service Error",$"{zipPath} already exists, aborting"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,9 +72,9 @@ public class ScannerService : IScannerService
|
|||||||
var folderPaths = library.Folders.Select(f => f.Path).ToList();
|
var folderPaths = library.Folders.Select(f => f.Path).ToList();
|
||||||
|
|
||||||
|
|
||||||
if (!await CheckMounts(library.Folders.Select(f => f.Path).ToList()))
|
if (!await CheckMounts(library.Name, library.Folders.Select(f => f.Path).ToList()))
|
||||||
{
|
{
|
||||||
_logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
|
_logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,33 +190,25 @@ public class ScannerService : IScannerService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CheckMounts(IList<string> folders)
|
private async Task<bool> CheckMounts(string libraryName, IList<string> folders)
|
||||||
{
|
{
|
||||||
// TODO: IF false, inform UI
|
|
||||||
// Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
|
// Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
|
||||||
if (folders.Any(f => !_directoryService.IsDriveMounted(f)))
|
if (folders.Any(f => !_directoryService.IsDriveMounted(f)))
|
||||||
{
|
{
|
||||||
_logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
|
_logger.LogError("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName);
|
||||||
await _eventHub.SendMessageAsync("library.scan.error", new SignalRMessage()
|
|
||||||
{
|
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||||
Name = "library.scan.error",
|
MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted",
|
||||||
Body =
|
string.Join(", ", folders.Where(f => !_directoryService.IsDriveMounted(f)))));
|
||||||
new {
|
|
||||||
Message =
|
|
||||||
"Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted",
|
|
||||||
Details = ""
|
|
||||||
},
|
|
||||||
Title = "Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted",
|
|
||||||
SubTitle = string.Join(", ", folders.Where(f => !_directoryService.IsDriveMounted(f)))
|
|
||||||
});
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are
|
// For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are
|
||||||
if (folders.Any(f => _directoryService.IsDirectoryEmpty(f)))
|
if (folders.Any(f => _directoryService.IsDirectoryEmpty(f)))
|
||||||
{
|
{
|
||||||
// TODO: Food for thought, move this to throw an exception and let a middleware inform the UI to keep the code clean. (We can throw a custom exception which
|
// NOTE: Food for thought, move this to throw an exception and let a middleware inform the UI to keep the code clean. (We can throw a custom exception which
|
||||||
// will always propagate to the UI)
|
// will always propagate to the UI)
|
||||||
// That way logging and UI informing is all in one place with full context
|
// That way logging and UI informing is all in one place with full context
|
||||||
_logger.LogError("Some of the root folders for the library are empty. " +
|
_logger.LogError("Some of the root folders for the library are empty. " +
|
||||||
@ -224,23 +216,10 @@ public class ScannerService : IScannerService
|
|||||||
"Scan will be aborted. " +
|
"Scan will be aborted. " +
|
||||||
"Check that your mount is connected or change the library's root folder and rescan");
|
"Check that your mount is connected or change the library's root folder and rescan");
|
||||||
|
|
||||||
// TODO: Use a factory method
|
await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.",
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.Error, new SignalRMessage()
|
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
|
||||||
{
|
"Scan will be aborted. " +
|
||||||
Name = MessageFactory.Error,
|
"Check that your mount is connected or change the library's root folder and rescan"));
|
||||||
Title = "Some of the root folders for the library are empty.",
|
|
||||||
SubTitle = "Either your mount has been disconnected or you are trying to delete all series in the library. " +
|
|
||||||
"Scan will be aborted. " +
|
|
||||||
"Check that your mount is connected or change the library's root folder and rescan",
|
|
||||||
Body =
|
|
||||||
new {
|
|
||||||
Title =
|
|
||||||
"Some of the root folders for the library are empty.",
|
|
||||||
SubTitle = "Either your mount has been disconnected or you are trying to delete all series in the library. " +
|
|
||||||
"Scan will be aborted. " +
|
|
||||||
"Check that your mount is connected or change the library's root folder and rescan"
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -285,25 +264,12 @@ public class ScannerService : IScannerService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await CheckMounts(library.Folders.Select(f => f.Path).ToList()))
|
if (!await CheckMounts(library.Name, library.Folders.Select(f => f.Path).ToList()))
|
||||||
{
|
{
|
||||||
_logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
|
_logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
|
||||||
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
|
|
||||||
// MessageFactory.ScanLibraryProgressEvent(libraryId, 1F));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are
|
|
||||||
if (library.Folders.Any(f => _directoryService.IsDirectoryEmpty(f.Path)))
|
|
||||||
{
|
|
||||||
_logger.LogCritical("Some of the root folders for the library are empty. " +
|
|
||||||
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
|
|
||||||
"Scan will be aborted. " +
|
|
||||||
"Check that your mount is connected or change the library's root folder and rescan");
|
|
||||||
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
|
|
||||||
// MessageFactory.ScanLibraryProgressEvent(libraryId, 1F));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name);
|
_logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name);
|
||||||
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
|
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
|
||||||
@ -437,13 +403,16 @@ public class ScannerService : IScannerService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogCritical(ex, "[ScannerService] There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB. If debug mode, series to check will be printed", chunk);
|
_logger.LogCritical(ex, "[ScannerService] There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB", chunk);
|
||||||
foreach (var series in nonLibrarySeries)
|
foreach (var series in nonLibrarySeries)
|
||||||
{
|
{
|
||||||
_logger.LogCritical("[ScannerService] There may be a constraint issue with {SeriesName}", series.OriginalName);
|
_logger.LogCritical("[ScannerService] There may be a constraint issue with {SeriesName}", series.OriginalName);
|
||||||
}
|
}
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.ScanLibraryError,
|
|
||||||
MessageFactory.ScanLibraryErrorEvent(library.Id, library.Name));
|
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||||
|
MessageFactory.ErrorEvent("There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB",
|
||||||
|
"The following series had constraint issues: " + string.Join(",", nonLibrarySeries.Select(s => s.OriginalName))));
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
|
@ -38,10 +38,6 @@ namespace API.SignalR
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string SeriesAddedToCollection = "SeriesAddedToCollection";
|
public const string SeriesAddedToCollection = "SeriesAddedToCollection";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When an error occurs during a scan library task
|
|
||||||
/// </summary>
|
|
||||||
public const string ScanLibraryError = "ScanLibraryError";
|
|
||||||
/// <summary>
|
|
||||||
/// Event sent out during backing up the database
|
/// Event sent out during backing up the database
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string BackupDatabaseProgress = "BackupDatabaseProgress";
|
private const string BackupDatabaseProgress = "BackupDatabaseProgress";
|
||||||
@ -209,18 +205,22 @@ namespace API.SignalR
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SignalRMessage ScanLibraryErrorEvent(int libraryId, string libraryName)
|
/**
|
||||||
|
* A generic error that will show on events widget in the UI
|
||||||
|
*/
|
||||||
|
public static SignalRMessage ErrorEvent(string title, string subtitle)
|
||||||
{
|
{
|
||||||
return new SignalRMessage
|
return new SignalRMessage
|
||||||
{
|
{
|
||||||
Name = ScanLibraryError,
|
Name = Error,
|
||||||
Title = "Error",
|
Title = title,
|
||||||
SubTitle = $"Error Scanning {libraryName}",
|
SubTitle = subtitle,
|
||||||
Progress = ProgressType.None,
|
Progress = ProgressType.None,
|
||||||
EventType = ProgressEventType.Single,
|
EventType = ProgressEventType.Single,
|
||||||
Body = new
|
Body = new
|
||||||
{
|
{
|
||||||
LibraryId = libraryId,
|
Title = title,
|
||||||
|
SubTitle = subtitle,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
32
UI/Web/src/app/_models/events/error-event.ts
Normal file
32
UI/Web/src/app/_models/events/error-event.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { EVENTS } from "src/app/_services/message-hub.service";
|
||||||
|
|
||||||
|
export interface ErrorEvent {
|
||||||
|
/**
|
||||||
|
* Payload of the event subtype
|
||||||
|
*/
|
||||||
|
body: any;
|
||||||
|
/**
|
||||||
|
* Subtype event
|
||||||
|
*/
|
||||||
|
name: EVENTS.Error;
|
||||||
|
/**
|
||||||
|
* Title to display in events widget
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Optional subtitle to display. Defaults to empty string
|
||||||
|
*/
|
||||||
|
subTitle: string;
|
||||||
|
/**
|
||||||
|
* Type of event. Helps events widget to understand how to handle said event
|
||||||
|
*/
|
||||||
|
eventType: 'single';
|
||||||
|
/**
|
||||||
|
* Type of progress. Helps widget understand how to display spinner
|
||||||
|
*/
|
||||||
|
progress: 'none';
|
||||||
|
/**
|
||||||
|
* When event was sent
|
||||||
|
*/
|
||||||
|
eventTime: string;
|
||||||
|
}
|
@ -83,6 +83,10 @@ export class ImageService implements OnDestroy {
|
|||||||
return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey);
|
return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCoverUploadImage(filename: string) {
|
||||||
|
return this.baseUrl + 'image/cover-upload?filename=' + encodeURIComponent(filename);
|
||||||
|
}
|
||||||
|
|
||||||
updateErroredImage(event: any) {
|
updateErroredImage(event: any) {
|
||||||
event.target.src = this.placeholderImage;
|
event.target.src = this.placeholderImage;
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,10 @@ export enum EVENTS {
|
|||||||
ScanLibraryProgress = 'ScanLibraryProgress',
|
ScanLibraryProgress = 'ScanLibraryProgress',
|
||||||
OnlineUsers = 'OnlineUsers',
|
OnlineUsers = 'OnlineUsers',
|
||||||
SeriesAddedToCollection = 'SeriesAddedToCollection',
|
SeriesAddedToCollection = 'SeriesAddedToCollection',
|
||||||
ScanLibraryError = 'ScanLibraryError',
|
/**
|
||||||
|
* A generic error that occurs during operations on the server
|
||||||
|
*/
|
||||||
|
Error = 'Error',
|
||||||
BackupDatabaseProgress = 'BackupDatabaseProgress',
|
BackupDatabaseProgress = 'BackupDatabaseProgress',
|
||||||
/**
|
/**
|
||||||
* A subtype of NotificationProgress that represents maintenance cleanup on server-owned resources
|
* A subtype of NotificationProgress that represents maintenance cleanup on server-owned resources
|
||||||
@ -149,15 +152,11 @@ export class MessageHubService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hubConnection.on(EVENTS.ScanLibraryError, resp => {
|
this.hubConnection.on(EVENTS.Error, resp => {
|
||||||
this.messagesSource.next({
|
this.messagesSource.next({
|
||||||
event: EVENTS.ScanLibraryError,
|
event: EVENTS.Error,
|
||||||
payload: resp.body
|
payload: resp.body
|
||||||
});
|
});
|
||||||
if (this.isAdmin) {
|
|
||||||
// TODO: Just show the error, RBS is done in eventhub
|
|
||||||
this.toastr.error('Library Scan had a critical error. Some series were not saved. Check logs');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hubConnection.on(EVENTS.SeriesAdded, resp => {
|
this.hubConnection.on(EVENTS.SeriesAdded, resp => {
|
||||||
|
@ -12,6 +12,10 @@ export class UploadService {
|
|||||||
constructor(private httpClient: HttpClient) { }
|
constructor(private httpClient: HttpClient) { }
|
||||||
|
|
||||||
|
|
||||||
|
uploadByUrl(url: string) {
|
||||||
|
return this.httpClient.post<string>(this.baseUrl + 'upload/upload-by-url', {url}, {responseType: 'text' as 'json'});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param seriesId Series to overwrite cover image for
|
* @param seriesId Series to overwrite cover image for
|
||||||
|
@ -27,7 +27,7 @@ import { CardsModule } from './cards/cards.module';
|
|||||||
import { CollectionsModule } from './collections/collections.module';
|
import { CollectionsModule } from './collections/collections.module';
|
||||||
import { ReadingListModule } from './reading-list/reading-list.module';
|
import { ReadingListModule } from './reading-list/reading-list.module';
|
||||||
import { SAVER, getSaver } from './shared/_providers/saver.provider';
|
import { SAVER, getSaver } from './shared/_providers/saver.provider';
|
||||||
import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component';
|
import { EventsWidgetComponent } from './events-widget/events-widget.component';
|
||||||
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
|
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
|
||||||
import { AllSeriesComponent } from './all-series/all-series.component';
|
import { AllSeriesComponent } from './all-series/all-series.component';
|
||||||
import { RegistrationModule } from './registration/registration.module';
|
import { RegistrationModule } from './registration/registration.module';
|
||||||
@ -48,7 +48,7 @@ import { ColorPickerModule } from 'ngx-color-picker';
|
|||||||
ReviewSeriesModalComponent,
|
ReviewSeriesModalComponent,
|
||||||
RecentlyAddedComponent,
|
RecentlyAddedComponent,
|
||||||
DashboardComponent,
|
DashboardComponent,
|
||||||
NavEventsToggleComponent,
|
EventsWidgetComponent,
|
||||||
SeriesMetadataDetailComponent,
|
SeriesMetadataDetailComponent,
|
||||||
AllSeriesComponent,
|
AllSeriesComponent,
|
||||||
GroupedTypeaheadComponent,
|
GroupedTypeaheadComponent,
|
||||||
|
@ -32,6 +32,10 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
@Input() series!: Series;
|
@Input() series!: Series;
|
||||||
seriesVolumes: any[] = [];
|
seriesVolumes: any[] = [];
|
||||||
isLoadingVolumes = false;
|
isLoadingVolumes = false;
|
||||||
|
/**
|
||||||
|
* A copy of the series from init. This is used to compare values for name fields to see if lock was modified
|
||||||
|
*/
|
||||||
|
initSeries!: Series;
|
||||||
|
|
||||||
isCollapsed = true;
|
isCollapsed = true;
|
||||||
volumeCollapsed: any = {};
|
volumeCollapsed: any = {};
|
||||||
@ -94,6 +98,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id));
|
this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id));
|
||||||
|
|
||||||
|
this.initSeries = Object.assign({}, this.series);
|
||||||
|
|
||||||
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
|
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
|
||||||
this.libraryName = names[this.series.libraryId];
|
this.libraryName = names[this.series.libraryId];
|
||||||
});
|
});
|
||||||
@ -133,28 +139,24 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
this.metadata = metadata;
|
this.metadata = metadata;
|
||||||
|
|
||||||
this.setupTypeaheads();
|
this.setupTypeaheads();
|
||||||
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
|
this.editSeriesForm.get('summary')?.patchValue(this.metadata.summary);
|
||||||
this.editSeriesForm.get('ageRating')?.setValue(this.metadata.ageRating);
|
this.editSeriesForm.get('ageRating')?.patchValue(this.metadata.ageRating);
|
||||||
this.editSeriesForm.get('publicationStatus')?.setValue(this.metadata.publicationStatus);
|
this.editSeriesForm.get('publicationStatus')?.patchValue(this.metadata.publicationStatus);
|
||||||
this.editSeriesForm.get('language')?.setValue(this.metadata.language);
|
this.editSeriesForm.get('language')?.patchValue(this.metadata.language);
|
||||||
|
|
||||||
this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||||
if (!this.editSeriesForm.get('name')?.touched) return;
|
|
||||||
this.series.nameLocked = true;
|
this.series.nameLocked = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.editSeriesForm.get('sortName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
this.editSeriesForm.get('sortName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||||
if (!this.editSeriesForm.get('sortName')?.touched) return;
|
|
||||||
this.series.sortNameLocked = true;
|
this.series.sortNameLocked = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.editSeriesForm.get('localizedName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
this.editSeriesForm.get('localizedName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||||
if (!this.editSeriesForm.get('localizedName')?.touched) return;
|
|
||||||
this.series.localizedNameLocked = true;
|
this.series.localizedNameLocked = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.editSeriesForm.get('summary')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
this.editSeriesForm.get('summary')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||||
if (!this.editSeriesForm.get('summary')?.touched) return;
|
|
||||||
this.metadata.summaryLocked = true;
|
this.metadata.summaryLocked = true;
|
||||||
this.metadata.summary = val;
|
this.metadata.summary = val;
|
||||||
});
|
});
|
||||||
@ -203,7 +205,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
this.setupLanguageTypeahead()
|
this.setupLanguageTypeahead()
|
||||||
]).subscribe(results => {
|
]).subscribe(results => {
|
||||||
this.collectionTags = this.metadata.collectionTags;
|
this.collectionTags = this.metadata.collectionTags;
|
||||||
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,7 +346,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher),
|
this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher),
|
||||||
this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator)
|
this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator)
|
||||||
]).pipe(map(results => {
|
]).pipe(map(results => {
|
||||||
//this.resetTypeaheads.next(true);
|
|
||||||
return of(true);
|
return of(true);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -406,7 +406,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image
|
// We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image
|
||||||
if (this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty || this.coverImageReset) {
|
const nameFieldsDirty = this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty;
|
||||||
|
const nameFieldLockChanged = this.series.nameLocked !== this.initSeries.nameLocked || this.series.sortNameLocked !== this.initSeries.sortNameLocked || this.series.localizedNameLocked !== this.initSeries.localizedNameLocked;
|
||||||
|
if (nameFieldsDirty || nameFieldLockChanged || this.coverImageReset) {
|
||||||
|
model.nameLocked = this.series.nameLocked;
|
||||||
|
model.sortNameLocked = this.series.sortNameLocked;
|
||||||
|
model.localizedNameLocked = this.series.localizedNameLocked;
|
||||||
apis.push(this.seriesService.updateSeries(model));
|
apis.push(this.seriesService.updateSeries(model));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import { takeWhile } from 'rxjs/operators';
|
|||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { ImageService } from 'src/app/_services/image.service';
|
import { ImageService } from 'src/app/_services/image.service';
|
||||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||||
|
import { UploadService } from 'src/app/_services/upload.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cover-image-chooser',
|
selector: 'app-cover-image-chooser',
|
||||||
@ -41,7 +42,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
|||||||
mode: 'file' | 'url' | 'all' = 'all';
|
mode: 'file' | 'url' | 'all' = 'all';
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService) { }
|
constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService, private uploadService: UploadService) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@ -72,49 +73,31 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
|||||||
if (this.selectedIndex === index) { return; }
|
if (this.selectedIndex === index) { return; }
|
||||||
this.selectedIndex = index;
|
this.selectedIndex = index;
|
||||||
this.imageSelected.emit(this.selectedIndex);
|
this.imageSelected.emit(this.selectedIndex);
|
||||||
const selector = `.chooser img[src="${this.imageUrls[this.selectedIndex]}"]`;
|
this.selectedBase64Url.emit(this.imageUrls[this.selectedIndex]);
|
||||||
|
|
||||||
|
|
||||||
const elem = document.querySelector(selector) || document.querySelectorAll('.chooser img.card-img-top')[this.selectedIndex];
|
|
||||||
if (elem) {
|
|
||||||
const imageElem = <HTMLImageElement>elem;
|
|
||||||
if (imageElem.src.startsWith('data')) {
|
|
||||||
this.selectedBase64Url.emit(imageElem.src);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const image = this.getBase64Image(imageElem);
|
|
||||||
if (image != '') {
|
|
||||||
this.selectedBase64Url.emit(image);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadImage() {
|
loadImage() {
|
||||||
const url = this.form.get('coverImageUrl')?.value.trim();
|
const url = this.form.get('coverImageUrl')?.value.trim();
|
||||||
if (url && url != '') {
|
if (url && url != '') {
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = 'Anonymous';
|
this.uploadService.uploadByUrl(url).subscribe(filename => {
|
||||||
img.onload = (e) => this.handleUrlImageAdd(e);
|
const img = new Image();
|
||||||
img.onerror = (e) => {
|
img.crossOrigin = 'Anonymous';
|
||||||
this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.');
|
img.src = this.imageService.getCoverUploadImage(filename);
|
||||||
this.form.get('coverImageUrl')?.setValue('');
|
img.onload = (e) => this.handleUrlImageAdd(e);
|
||||||
};
|
img.onerror = (e) => {
|
||||||
img.src = this.form.get('coverImageUrl')?.value;
|
this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.');
|
||||||
this.form.get('coverImageUrl')?.setValue('');
|
this.form.get('coverImageUrl')?.setValue('');
|
||||||
|
};
|
||||||
|
this.form.get('coverImageUrl')?.setValue('');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
changeMode(mode: 'url') {
|
changeMode(mode: 'url') {
|
||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
this.setupEnterHandler();
|
this.setupEnterHandler();
|
||||||
setTimeout(() => {
|
|
||||||
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public dropped(files: NgxFileDropEntry[]) {
|
public dropped(files: NgxFileDropEntry[]) {
|
||||||
this.files = files;
|
this.files = files;
|
||||||
@ -151,7 +134,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Auto select newly uploaded image and tell parent of new base64 url
|
// Auto select newly uploaded image and tell parent of new base64 url
|
||||||
this.selectImage(this.selectedIndex + 1)
|
this.selectImage(this.selectedIndex + 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<ng-container *ngIf="isAdmin">
|
<ng-container *ngIf="isAdmin">
|
||||||
|
|
||||||
<button type="button" class="btn btn-icon {{activeEvents > 0 ? 'colored' : ''}}"
|
<button type="button" class="btn btn-icon {{activeEvents > 0 ? 'colored' : ''}}"
|
||||||
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'">
|
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
|
||||||
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
|
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
<ng-container *ngIf="debugMode">
|
<ng-container *ngIf="debugMode">
|
||||||
<li class="list-group-item dark-menu-item">
|
<li class="list-group-item dark-menu-item">
|
||||||
<!-- <div class="spinner-grow text-primary small-spinner" role="status"></div> -->
|
|
||||||
<div class="h6 mb-1">Title goes here</div>
|
<div class="h6 mb-1">Title goes here</div>
|
||||||
<div class="accent-text mb-1">Subtitle goes here</div>
|
<div class="accent-text mb-1">Subtitle goes here</div>
|
||||||
<div class="progress-container row g-0 align-items-center">
|
<div class="progress-container row g-0 align-items-center">
|
||||||
@ -34,8 +33,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item dark-menu-item error">
|
||||||
|
<div>
|
||||||
|
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div>
|
||||||
|
<div class="accent-text mb-1">Click for more information</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||||
</li>
|
</li>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<!-- Progress Events-->
|
<!-- Progress Events-->
|
||||||
@ -78,12 +83,26 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<ng-container *ngIf="errors$ | async as errors">
|
||||||
|
<ng-container *ngFor="let error of errors">
|
||||||
|
<li class="list-group-item dark-menu-item error" role="alert" (click)="seeMoreError(error)">
|
||||||
|
<div>
|
||||||
|
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>{{error.title}}</div>
|
||||||
|
<div class="accent-text mb-1">Click for more information</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close float-end" aria-label="close" (click)="removeError(error, $event)"></button>
|
||||||
|
</li>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<!-- Online Users -->
|
<!-- Online Users -->
|
||||||
<ng-container *ngIf="messageHub.onlineUsers$ | async as onlineUsers">
|
<ng-container *ngIf="messageHub.onlineUsers$ | async as onlineUsers">
|
||||||
<li class="list-group-item dark-menu-item" *ngIf="onlineUsers.length > 1">
|
<li class="list-group-item dark-menu-item" *ngIf="onlineUsers.length > 1">
|
||||||
<div>{{onlineUsers.length}} Users online</div>
|
<div>{{onlineUsers.length}} Users online</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item dark-menu-item" *ngIf="activeEvents < 1 && onlineUsers.length <= 1">Not much going on here</li>
|
<li class="list-group-item dark-menu-item" *ngIf="activeEvents < 1 && onlineUsers.length <= 1">Not much going on here</li>
|
||||||
|
<li class="list-group-item dark-menu-item" *ngIf="debugMode">Active Events: {{activeEvents}}</li>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ul>
|
</ul>
|
||||||
</ng-template>
|
</ng-template>
|
@ -73,4 +73,23 @@
|
|||||||
color: var(--primary-color) !important;
|
color: var(--primary-color) !important;
|
||||||
}
|
}
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
.h6 {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
i.fa {
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
top: 5px;
|
||||||
|
right: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { BehaviorSubject, Subject } from 'rxjs';
|
import { BehaviorSubject, Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -8,17 +8,17 @@ import { UpdateVersionEvent } from '../_models/events/update-version-event';
|
|||||||
import { User } from '../_models/user';
|
import { User } from '../_models/user';
|
||||||
import { AccountService } from '../_services/account.service';
|
import { AccountService } from '../_services/account.service';
|
||||||
import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
|
import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
|
||||||
|
import { ErrorEvent } from '../_models/events/error-event';
|
||||||
|
import { ConfirmService } from '../shared/confirm.service';
|
||||||
|
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
|
||||||
|
import { ServerService } from '../_services/server.service';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// TODO: Rename this to events widget
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-nav-events-toggle',
|
selector: 'app-nav-events-toggle',
|
||||||
templateUrl: './nav-events-toggle.component.html',
|
templateUrl: './events-widget.component.html',
|
||||||
styleUrls: ['./nav-events-toggle.component.scss']
|
styleUrls: ['./events-widget.component.scss']
|
||||||
})
|
})
|
||||||
export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
export class EventsWidgetComponent implements OnInit, OnDestroy {
|
||||||
@Input() user!: User;
|
@Input() user!: User;
|
||||||
|
|
||||||
isAdmin: boolean = false;
|
isAdmin: boolean = false;
|
||||||
@ -34,6 +34,9 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||||||
singleUpdateSource = new BehaviorSubject<NotificationProgressEvent[]>([]);
|
singleUpdateSource = new BehaviorSubject<NotificationProgressEvent[]>([]);
|
||||||
singleUpdates$ = this.singleUpdateSource.asObservable();
|
singleUpdates$ = this.singleUpdateSource.asObservable();
|
||||||
|
|
||||||
|
errorSource = new BehaviorSubject<ErrorEvent[]>([]);
|
||||||
|
errors$ = this.errorSource.asObservable();
|
||||||
|
|
||||||
private updateNotificationModalRef: NgbModalRef | null = null;
|
private updateNotificationModalRef: NgbModalRef | null = null;
|
||||||
|
|
||||||
activeEvents: number = 0;
|
activeEvents: number = 0;
|
||||||
@ -45,22 +48,27 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||||||
return EVENTS;
|
return EVENTS;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(public messageHub: MessageHubService, private modalService: NgbModal, private accountService: AccountService) { }
|
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
|
||||||
|
private accountService: AccountService, private confirmService: ConfirmService) { }
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.onDestroy.next();
|
this.onDestroy.next();
|
||||||
this.onDestroy.complete();
|
this.onDestroy.complete();
|
||||||
this.progressEventsSource.complete();
|
this.progressEventsSource.complete();
|
||||||
this.singleUpdateSource.complete();
|
this.singleUpdateSource.complete();
|
||||||
|
this.errorSource.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Debounce for testing. Kavita's too fast
|
// Debounce for testing. Kavita's too fast
|
||||||
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
|
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
|
||||||
if (event.event.endsWith('error')) {
|
if (event.event === EVENTS.NotificationProgress) {
|
||||||
// TODO: Show an error handle
|
|
||||||
} else if (event.event === EVENTS.NotificationProgress) {
|
|
||||||
this.processNotificationProgressEvent(event);
|
this.processNotificationProgressEvent(event);
|
||||||
|
} else if (event.event === EVENTS.Error) {
|
||||||
|
const values = this.errorSource.getValue();
|
||||||
|
values.push(event.payload as ErrorEvent);
|
||||||
|
this.errorSource.next(values);
|
||||||
|
this.activeEvents += 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
|
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
|
||||||
@ -94,6 +102,7 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||||||
const index = data.findIndex(m => m.name === message.name);
|
const index = data.findIndex(m => m.name === message.name);
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
data.push(message);
|
data.push(message);
|
||||||
|
this.activeEvents += 1;
|
||||||
} else {
|
} else {
|
||||||
data[index] = message;
|
data[index] = message;
|
||||||
}
|
}
|
||||||
@ -103,7 +112,7 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||||||
data = this.progressEventsSource.getValue();
|
data = this.progressEventsSource.getValue();
|
||||||
data = data.filter(m => m.name !== message.name); // This does not work // && m.title !== message.title
|
data = data.filter(m => m.name !== message.name); // This does not work // && m.title !== message.title
|
||||||
this.progressEventsSource.next(data);
|
this.progressEventsSource.next(data);
|
||||||
this.activeEvents = Math.max(this.activeEvents - 1, 0);
|
this.activeEvents = Math.max(this.activeEvents - 1, 0);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@ -123,6 +132,31 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async seeMoreError(error: ErrorEvent) {
|
||||||
|
const config = new ConfirmConfig();
|
||||||
|
config.buttons = [
|
||||||
|
{text: 'Dismiss', type: 'primary'},
|
||||||
|
{text: 'Ok', type: 'secondary'},
|
||||||
|
];
|
||||||
|
config.header = error.title;
|
||||||
|
config.content = error.subTitle;
|
||||||
|
var result = await this.confirmService.alert(error.subTitle || error.title, config);
|
||||||
|
if (result) {
|
||||||
|
this.removeError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeError(error: ErrorEvent, event?: MouseEvent) {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
let data = this.errorSource.getValue();
|
||||||
|
data = data.filter(m => m !== error);
|
||||||
|
this.errorSource.next(data);
|
||||||
|
this.activeEvents = Math.max(this.activeEvents - 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
prettyPrintProgress(progress: number) {
|
prettyPrintProgress(progress: number) {
|
||||||
return Math.trunc(progress * 100);
|
return Math.trunc(progress * 100);
|
||||||
}
|
}
|
@ -532,6 +532,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||||||
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
|
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
if (closeResult.success) {
|
if (closeResult.success) {
|
||||||
|
this.seriesService.getSeries(this.seriesId).subscribe(s => {
|
||||||
|
this.series = s;
|
||||||
|
});
|
||||||
|
|
||||||
this.loadSeries(this.seriesId);
|
this.loadSeries(this.seriesId);
|
||||||
if (closeResult.coverImageUpdate) {
|
if (closeResult.coverImageUpdate) {
|
||||||
// Random triggers a load change without any problems with API
|
// Random triggers a load change without any problems with API
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
export interface ConfirmButton {
|
export interface ConfirmButton {
|
||||||
text: string;
|
text: string;
|
||||||
|
/**
|
||||||
|
* Type for css class. ie) primary, secondary
|
||||||
|
*/
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user