mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-02 21:24:18 -04:00
OPDS-PS v1.2 Support + a few bugfixes (#1869)
* Fixed up a localization lookup test case * Refactored some webp to a unified method * Cleaned up some code * Expanded webp conversion for covers to all entities * Code cleanup * Prompt the user when they are about to delete multiple series via bulk actions * Aligned Kavita to OPDS-PS 1.2. * Fixed a bug where clearing metadata filter of series name didn't clear the actual field. * Added some documentation * Refactored how covert covers to webp works. Now we will handle all custom covers for all entities. Volumes and Series will not be touched but instead be updated via a RefreshCovers call. This will fix up the references much faster. * Fixed up the OPDS-PS 1.2 attributes to only show on PS links
This commit is contained in:
parent
1f34068662
commit
47269b4c51
@ -6,8 +6,10 @@ using System.Threading.Tasks;
|
|||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Extensions;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Tests.Helpers.Builders;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@ -117,20 +119,20 @@ public class SeriesRepositoryTests
|
|||||||
{
|
{
|
||||||
var library = new Library()
|
var library = new Library()
|
||||||
{
|
{
|
||||||
Name = "Manga",
|
Name = "GetFullSeriesByAnyName Manga",
|
||||||
Type = LibraryType.Manga,
|
Type = LibraryType.Manga,
|
||||||
Folders = new List<FolderPath>()
|
Folders = new List<FolderPath>()
|
||||||
{
|
{
|
||||||
new FolderPath() {Path = "C:/data/manga/"}
|
new FolderPath() {Path = "C:/data/manga/"}
|
||||||
|
},
|
||||||
|
Series = new List<Series>()
|
||||||
|
{
|
||||||
|
new SeriesBuilder("The Idaten Deities Know Only Peace")
|
||||||
|
.WithLocalizedName("Heion Sedai no Idaten-tachi")
|
||||||
|
.WithFormat(MangaFormat.Archive)
|
||||||
|
.Build()
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
var s = DbFactory.Series("The Idaten Deities Know Only Peace", "Heion Sedai no Idaten-tachi");
|
|
||||||
s.Format = MangaFormat.Archive;
|
|
||||||
|
|
||||||
library.Series = new List<Series>()
|
|
||||||
{
|
|
||||||
s,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_unitOfWork.LibraryRepository.Add(library);
|
_unitOfWork.LibraryRepository.Add(library);
|
||||||
@ -138,16 +140,18 @@ public class SeriesRepositoryTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// This test case isn't ready to go
|
[Theory]
|
||||||
[InlineData("Heion Sedai no Idaten-tachi", MangaFormat.Archive, "", "The Idaten Deities Know Only Peace")] // Matching on localized name in DB
|
[InlineData("The Idaten Deities Know Only Peace", MangaFormat.Archive, "", "The Idaten Deities Know Only Peace")] // Matching on series name in DB
|
||||||
|
[InlineData("Heion Sedai no Idaten-tachi", MangaFormat.Archive, "The Idaten Deities Know Only Peace", "The Idaten Deities Know Only Peace")] // Matching on localized name in DB
|
||||||
[InlineData("Heion Sedai no Idaten-tachi", MangaFormat.Pdf, "", null)]
|
[InlineData("Heion Sedai no Idaten-tachi", MangaFormat.Pdf, "", null)]
|
||||||
public async Task GetFullSeriesByAnyName_Should(string seriesName, MangaFormat format, string localizedName, string? expected)
|
public async Task GetFullSeriesByAnyName_Should(string seriesName, MangaFormat format, string localizedName, string? expected)
|
||||||
{
|
{
|
||||||
await ResetDb();
|
await ResetDb();
|
||||||
await SetupSeriesData();
|
await SetupSeriesData();
|
||||||
|
|
||||||
var series =
|
var series =
|
||||||
await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName,
|
await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName,
|
||||||
1, format);
|
2, format, false);
|
||||||
if (expected == null)
|
if (expected == null)
|
||||||
{
|
{
|
||||||
Assert.Null(series);
|
Assert.Null(series);
|
||||||
|
@ -806,7 +806,7 @@ public class OpdsController : BaseApiController
|
|||||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
|
||||||
// We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
|
// We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
|
||||||
accLink,
|
accLink,
|
||||||
CreatePageStreamLink(series.LibraryId,seriesId, volumeId, chapterId, mangaFile, apiKey)
|
await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey)
|
||||||
},
|
},
|
||||||
Content = new FeedEntryContent()
|
Content = new FeedEntryContent()
|
||||||
{
|
{
|
||||||
@ -818,6 +818,16 @@ public class OpdsController : BaseApiController
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This returns a streamed image following OPDS-PS v1.2
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="apiKey"></param>
|
||||||
|
/// <param name="libraryId"></param>
|
||||||
|
/// <param name="seriesId"></param>
|
||||||
|
/// <param name="volumeId"></param>
|
||||||
|
/// <param name="chapterId"></param>
|
||||||
|
/// <param name="pageNumber"></param>
|
||||||
|
/// <returns></returns>
|
||||||
[HttpGet("{apiKey}/image")]
|
[HttpGet("{apiKey}/image")]
|
||||||
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
|
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
|
||||||
{
|
{
|
||||||
@ -886,10 +896,17 @@ public class OpdsController : BaseApiController
|
|||||||
throw new KavitaException("User does not exist");
|
throw new KavitaException("User does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FeedLink CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
|
private async Task<FeedLink> CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
|
||||||
{
|
{
|
||||||
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
|
var userId = await GetUser(apiKey);
|
||||||
|
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId);
|
||||||
|
|
||||||
|
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg",
|
||||||
|
$"{Prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
|
||||||
link.TotalPages = mangaFile.Pages;
|
link.TotalPages = mangaFile.Pages;
|
||||||
|
link.LastRead = progress.PageNum;
|
||||||
|
link.LastReadDate = progress.LastModifiedUtc;
|
||||||
|
link.IsPageStream = true;
|
||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,7 +103,7 @@ public class ReaderController : BaseApiController
|
|||||||
{
|
{
|
||||||
var path = _cacheService.GetCachedPagePath(chapter.Id, page);
|
var path = _cacheService.GetCachedPagePath(chapter.Id, page);
|
||||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache.");
|
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache.");
|
||||||
var format = Path.GetExtension(path).Replace(".", "");
|
var format = Path.GetExtension(path).Replace(".", string.Empty);
|
||||||
|
|
||||||
return PhysicalFile(path, "image/" + format, Path.GetFileName(path), true);
|
return PhysicalFile(path, "image/" + format, Path.GetFileName(path), true);
|
||||||
}
|
}
|
||||||
|
@ -22,14 +22,12 @@ public class ReadingListController : BaseApiController
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IEventHub _eventHub;
|
private readonly IEventHub _eventHub;
|
||||||
private readonly IReadingListService _readingListService;
|
private readonly IReadingListService _readingListService;
|
||||||
private readonly IDirectoryService _directoryService;
|
|
||||||
|
|
||||||
public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService, IDirectoryService directoryService)
|
public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_eventHub = eventHub;
|
_eventHub = eventHub;
|
||||||
_readingListService = readingListService;
|
_readingListService = readingListService;
|
||||||
_directoryService = directoryService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -30,14 +30,15 @@ public class ServerController : BaseApiController
|
|||||||
private readonly IVersionUpdaterService _versionUpdaterService;
|
private readonly IVersionUpdaterService _versionUpdaterService;
|
||||||
private readonly IStatsService _statsService;
|
private readonly IStatsService _statsService;
|
||||||
private readonly ICleanupService _cleanupService;
|
private readonly ICleanupService _cleanupService;
|
||||||
private readonly IEmailService _emailService;
|
|
||||||
private readonly IBookmarkService _bookmarkService;
|
private readonly IBookmarkService _bookmarkService;
|
||||||
private readonly IScannerService _scannerService;
|
private readonly IScannerService _scannerService;
|
||||||
private readonly IAccountService _accountService;
|
private readonly IAccountService _accountService;
|
||||||
|
private readonly ITaskScheduler _taskScheduler;
|
||||||
|
|
||||||
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger,
|
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger,
|
||||||
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
|
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
|
||||||
ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService)
|
ICleanupService cleanupService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService,
|
||||||
|
ITaskScheduler taskScheduler)
|
||||||
{
|
{
|
||||||
_applicationLifetime = applicationLifetime;
|
_applicationLifetime = applicationLifetime;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@ -46,10 +47,10 @@ public class ServerController : BaseApiController
|
|||||||
_versionUpdaterService = versionUpdaterService;
|
_versionUpdaterService = versionUpdaterService;
|
||||||
_statsService = statsService;
|
_statsService = statsService;
|
||||||
_cleanupService = cleanupService;
|
_cleanupService = cleanupService;
|
||||||
_emailService = emailService;
|
|
||||||
_bookmarkService = bookmarkService;
|
_bookmarkService = bookmarkService;
|
||||||
_scannerService = scannerService;
|
_scannerService = scannerService;
|
||||||
_accountService = accountService;
|
_accountService = accountService;
|
||||||
|
_taskScheduler = taskScheduler;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -151,7 +152,7 @@ public class ServerController : BaseApiController
|
|||||||
{
|
{
|
||||||
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(),
|
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(),
|
||||||
TaskScheduler.DefaultQueue, true)) return Ok();
|
TaskScheduler.DefaultQueue, true)) return Ok();
|
||||||
BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllCoverToWebP());
|
BackgroundJob.Enqueue(() => _taskScheduler.CovertAllCoversToWebP());
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,8 +93,7 @@ public class UploadController : BaseApiController
|
|||||||
{
|
{
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
|
||||||
if (series == null) return BadRequest("Invalid Series");
|
if (series == null) return BadRequest("Invalid Series");
|
||||||
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP;
|
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}");
|
||||||
var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, ImageService.GetSeriesFormat(uploadFileDto.Id), convertToWebP);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(filePath))
|
if (!string.IsNullOrEmpty(filePath))
|
||||||
{
|
{
|
||||||
@ -142,8 +141,7 @@ public class UploadController : BaseApiController
|
|||||||
{
|
{
|
||||||
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id);
|
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id);
|
||||||
if (tag == null) return BadRequest("Invalid Tag id");
|
if (tag == null) return BadRequest("Invalid Tag id");
|
||||||
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP;
|
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");
|
||||||
var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}", convertToWebP);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(filePath))
|
if (!string.IsNullOrEmpty(filePath))
|
||||||
{
|
{
|
||||||
@ -194,8 +192,7 @@ public class UploadController : BaseApiController
|
|||||||
{
|
{
|
||||||
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id);
|
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id);
|
||||||
if (readingList == null) return BadRequest("Reading list is not valid");
|
if (readingList == null) return BadRequest("Reading list is not valid");
|
||||||
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP;
|
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}");
|
||||||
var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}", convertToWebP);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(filePath))
|
if (!string.IsNullOrEmpty(filePath))
|
||||||
{
|
{
|
||||||
@ -222,6 +219,19 @@ public class UploadController : BaseApiController
|
|||||||
return BadRequest("Unable to save cover image to Reading List");
|
return BadRequest("Unable to save cover image to Reading List");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0)
|
||||||
|
{
|
||||||
|
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP;
|
||||||
|
if (thumbnailSize > 0)
|
||||||
|
{
|
||||||
|
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
|
||||||
|
filename, convertToWebP, thumbnailSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
|
||||||
|
filename, convertToWebP); ;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
|
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -243,8 +253,7 @@ public class UploadController : BaseApiController
|
|||||||
{
|
{
|
||||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
|
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
|
||||||
if (chapter == null) return BadRequest("Invalid Chapter");
|
if (chapter == null) return BadRequest("Invalid Chapter");
|
||||||
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP;
|
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}");
|
||||||
var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}", convertToWebP);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(filePath))
|
if (!string.IsNullOrEmpty(filePath))
|
||||||
{
|
{
|
||||||
@ -310,9 +319,7 @@ public class UploadController : BaseApiController
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP;
|
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", ImageService.LibraryThumbnailWidth);
|
||||||
var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
|
|
||||||
$"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", convertToWebP, ImageService.LibraryThumbnailWidth);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(filePath))
|
if (!string.IsNullOrEmpty(filePath))
|
||||||
{
|
{
|
||||||
|
@ -26,7 +26,7 @@ public class Feed
|
|||||||
public FeedAuthor Author { get; set; } = new FeedAuthor()
|
public FeedAuthor Author { get; set; } = new FeedAuthor()
|
||||||
{
|
{
|
||||||
Name = "Kavita",
|
Name = "Kavita",
|
||||||
Uri = "https://kavitareader.com"
|
Uri = "https://www.kavitareader.com"
|
||||||
};
|
};
|
||||||
|
|
||||||
[XmlElement("totalResults", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
|
[XmlElement("totalResults", Namespace = "http://a9.com/-/spec/opensearch/1.1/")]
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
using System.Xml.Serialization;
|
using System;
|
||||||
|
using System.Xml.Serialization;
|
||||||
|
|
||||||
namespace API.DTOs.OPDS;
|
namespace API.DTOs.OPDS;
|
||||||
|
|
||||||
public class FeedLink
|
public class FeedLink
|
||||||
{
|
{
|
||||||
|
[XmlIgnore]
|
||||||
|
public bool IsPageStream { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Relation on the Link
|
/// Relation on the Link
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -25,6 +28,34 @@ public class FeedLink
|
|||||||
[XmlAttribute("count", Namespace = "http://vaemendis.net/opds-pse/ns")]
|
[XmlAttribute("count", Namespace = "http://vaemendis.net/opds-pse/ns")]
|
||||||
public int TotalPages { get; set; }
|
public int TotalPages { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// lastRead MUST provide the last page read for this document. The numbering starts at 1.
|
||||||
|
/// </summary>
|
||||||
|
[XmlAttribute("lastRead", Namespace = "http://vaemendis.net/opds-pse/ns")]
|
||||||
|
public int LastRead { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// lastReadDate MAY provide the date of when the lastRead attribute was last updated.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Attribute MUST conform Atom's Date construct</remarks>
|
||||||
|
[XmlAttribute("lastReadDate", Namespace = "http://vaemendis.net/opds-pse/ns")]
|
||||||
|
public DateTime LastReadDate { get; set; }
|
||||||
|
|
||||||
|
public bool ShouldSerializeLastReadDate()
|
||||||
|
{
|
||||||
|
return IsPageStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShouldSerializeLastRead()
|
||||||
|
{
|
||||||
|
return LastRead >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShouldSerializeTitle()
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(Title);
|
||||||
|
}
|
||||||
|
|
||||||
public bool ShouldSerializeTotalPages()
|
public bool ShouldSerializeTotalPages()
|
||||||
{
|
{
|
||||||
return TotalPages > 0;
|
return TotalPages > 0;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace API.DTOs;
|
namespace API.DTOs;
|
||||||
|
|
||||||
@ -19,4 +20,6 @@ public class ProgressDto
|
|||||||
/// on pages that combine multiple "chapters".
|
/// on pages that combine multiple "chapters".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? BookScrollId { get; set; }
|
public string? BookScrollId { get; set; }
|
||||||
|
|
||||||
|
public DateTime LastModifiedUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -46,10 +46,10 @@ public class UserPreferencesDto
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string BackgroundColor { get; set; } = "#000000";
|
public string BackgroundColor { get; set; } = "#000000";
|
||||||
[Required]
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manga Reader Option: Should swiping trigger pagination
|
/// Manga Reader Option: Should swiping trigger pagination
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Required]
|
||||||
public bool SwipeToPaginate { get; set; }
|
public bool SwipeToPaginate { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
|
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
|
||||||
|
@ -33,6 +33,7 @@ public interface ICollectionTagRepository
|
|||||||
Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None);
|
Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None);
|
||||||
Task<IList<string>> GetAllCoverImagesAsync();
|
Task<IList<string>> GetAllCoverImagesAsync();
|
||||||
Task<bool> TagExists(string title);
|
Task<bool> TagExists(string title);
|
||||||
|
Task<IList<CollectionTag>> GetAllWithNonWebPCovers();
|
||||||
}
|
}
|
||||||
public class CollectionTagRepository : ICollectionTagRepository
|
public class CollectionTagRepository : ICollectionTagRepository
|
||||||
{
|
{
|
||||||
@ -106,6 +107,13 @@ public class CollectionTagRepository : ICollectionTagRepository
|
|||||||
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
|
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<CollectionTag>> GetAllWithNonWebPCovers()
|
||||||
|
{
|
||||||
|
return await _context.CollectionTag
|
||||||
|
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
||||||
{
|
{
|
||||||
|
|
||||||
|
@ -51,6 +51,7 @@ public interface ILibraryRepository
|
|||||||
Task<string?> GetLibraryCoverImageAsync(int libraryId);
|
Task<string?> GetLibraryCoverImageAsync(int libraryId);
|
||||||
Task<IList<string>> GetAllCoverImagesAsync();
|
Task<IList<string>> GetAllCoverImagesAsync();
|
||||||
Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds);
|
Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds);
|
||||||
|
Task<IList<Library>> GetAllWithNonWebPCovers();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LibraryRepository : ILibraryRepository
|
public class LibraryRepository : ILibraryRepository
|
||||||
@ -368,4 +369,11 @@ public class LibraryRepository : ILibraryRepository
|
|||||||
|
|
||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<Library>> GetAllWithNonWebPCovers()
|
||||||
|
{
|
||||||
|
return await _context.Library
|
||||||
|
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,7 @@ public interface IReadingListRepository
|
|||||||
Task<bool> ReadingListExists(string name);
|
Task<bool> ReadingListExists(string name);
|
||||||
Task<List<ReadingList>> GetAllReadingListsAsync();
|
Task<List<ReadingList>> GetAllReadingListsAsync();
|
||||||
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId);
|
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId);
|
||||||
|
Task<IList<ReadingList>> GetAllWithNonWebPCovers();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReadingListRepository : IReadingListRepository
|
public class ReadingListRepository : IReadingListRepository
|
||||||
@ -106,6 +107,13 @@ public class ReadingListRepository : IReadingListRepository
|
|||||||
.AsEnumerable();
|
.AsEnumerable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<ReadingList>> GetAllWithNonWebPCovers()
|
||||||
|
{
|
||||||
|
return await _context.ReadingList
|
||||||
|
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public void Remove(ReadingListItem item)
|
public void Remove(ReadingListItem item)
|
||||||
{
|
{
|
||||||
_context.ReadingListItem.Remove(item);
|
_context.ReadingListItem.Remove(item);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@ -130,6 +131,7 @@ public interface ISeriesRepository
|
|||||||
Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
|
Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
|
||||||
|
|
||||||
Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds);
|
Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds);
|
||||||
|
Task<IList<Series>> GetAllWithNonWebPCovers(bool customOnly = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SeriesRepository : ISeriesRepository
|
public class SeriesRepository : ISeriesRepository
|
||||||
@ -560,6 +562,21 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns custom images only
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<IList<Series>> GetAllWithNonWebPCovers(bool customOnly = true)
|
||||||
|
{
|
||||||
|
var prefix = ImageService.GetSeriesFormat(0).Replace("0", string.Empty);
|
||||||
|
return await _context.Series
|
||||||
|
.Where(c => !string.IsNullOrEmpty(c.CoverImage)
|
||||||
|
&& !c.CoverImage.EndsWith(".webp")
|
||||||
|
&& (!customOnly || c.CoverImage.StartsWith(prefix)))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task AddSeriesModifiers(int userId, List<SeriesDto> series)
|
public async Task AddSeriesModifiers(int userId, List<SeriesDto> series)
|
||||||
{
|
{
|
||||||
var userProgress = await _context.AppUserProgresses
|
var userProgress = await _context.AppUserProgresses
|
||||||
@ -1262,38 +1279,40 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
/// <param name="format"></param>
|
/// <param name="format"></param>
|
||||||
/// <param name="withFullIncludes">Defaults to true. This will query against all foreign keys (deep). If false, just the series will come back</param>
|
/// <param name="withFullIncludes">Defaults to true. This will query against all foreign keys (deep). If false, just the series will come back</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true)
|
public Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId,
|
||||||
|
MangaFormat format, bool withFullIncludes = true)
|
||||||
{
|
{
|
||||||
var normalizedSeries = seriesName.ToNormalized();
|
var normalizedSeries = seriesName.ToNormalized();
|
||||||
var normalizedLocalized = localizedName.ToNormalized();
|
var normalizedLocalized = localizedName.ToNormalized();
|
||||||
var query = _context.Series
|
var query = _context.Series
|
||||||
.Where(s => s.LibraryId == libraryId)
|
.Where(s => s.LibraryId == libraryId)
|
||||||
.Where(s => s.Format == format && format != MangaFormat.Unknown)
|
.Where(s => s.Format == format && format != MangaFormat.Unknown)
|
||||||
.Where(s => s.NormalizedName.Equals(normalizedSeries)
|
.Where(s =>
|
||||||
|| (s.NormalizedLocalizedName == normalizedSeries)
|
s.NormalizedName.Equals(normalizedSeries)
|
||||||
|| (s.OriginalName == seriesName));
|
|| s.NormalizedName.Equals(normalizedLocalized)
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(normalizedLocalized))
|
|| s.NormalizedLocalizedName.Equals(normalizedSeries)
|
||||||
{
|
|| (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized))
|
||||||
// TODO: Apply WhereIf
|
|
||||||
query = query.Where(s =>
|
|
||||||
s.NormalizedName.Equals(normalizedLocalized)
|
|
||||||
|| (s.NormalizedLocalizedName != null && s.NormalizedLocalizedName.Equals(normalizedLocalized)));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|| (s.OriginalName != null && s.OriginalName.Equals(seriesName))
|
||||||
|
);
|
||||||
if (!withFullIncludes)
|
if (!withFullIncludes)
|
||||||
{
|
{
|
||||||
return query.SingleOrDefaultAsync();
|
return query.SingleOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
return query.Include(s => s.Metadata)
|
query = query.Include(s => s.Library)
|
||||||
|
|
||||||
|
.Include(s => s.Metadata)
|
||||||
.ThenInclude(m => m.People)
|
.ThenInclude(m => m.People)
|
||||||
|
|
||||||
.Include(s => s.Metadata)
|
.Include(s => s.Metadata)
|
||||||
.ThenInclude(m => m.Genres)
|
.ThenInclude(m => m.Genres)
|
||||||
|
|
||||||
.Include(s => s.Library)
|
.Include(s => s.Metadata)
|
||||||
|
.ThenInclude(m => m.Tags)
|
||||||
|
|
||||||
.Include(s => s.Volumes)
|
.Include(s => s.Volumes)
|
||||||
.ThenInclude(v => v.Chapters)
|
.ThenInclude(v => v.Chapters)
|
||||||
.ThenInclude(cm => cm.People)
|
.ThenInclude(cm => cm.People)
|
||||||
@ -1306,15 +1325,12 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.ThenInclude(v => v.Chapters)
|
.ThenInclude(v => v.Chapters)
|
||||||
.ThenInclude(c => c.Genres)
|
.ThenInclude(c => c.Genres)
|
||||||
|
|
||||||
|
|
||||||
.Include(s => s.Metadata)
|
|
||||||
.ThenInclude(m => m.Tags)
|
|
||||||
|
|
||||||
.Include(s => s.Volumes)
|
.Include(s => s.Volumes)
|
||||||
.ThenInclude(v => v.Chapters)
|
.ThenInclude(v => v.Chapters)
|
||||||
.ThenInclude(c => c.Files)
|
.ThenInclude(c => c.Files)
|
||||||
.AsSplitQuery()
|
|
||||||
.SingleOrDefaultAsync();
|
.AsSplitQuery();
|
||||||
|
return query.SingleOrDefaultAsync();
|
||||||
#nullable enable
|
#nullable enable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
|||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
|
using API.Services;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using AutoMapper.QueryableExtensions;
|
using AutoMapper.QueryableExtensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@ -24,6 +25,7 @@ public interface IVolumeRepository
|
|||||||
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
|
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
|
||||||
Task<IEnumerable<Volume>> GetVolumes(int seriesId);
|
Task<IEnumerable<Volume>> GetVolumes(int seriesId);
|
||||||
Task<Volume?> GetVolumeByIdAsync(int volumeId);
|
Task<Volume?> GetVolumeByIdAsync(int volumeId);
|
||||||
|
Task<IList<Volume>> GetAllWithNonWebPCovers();
|
||||||
}
|
}
|
||||||
public class VolumeRepository : IVolumeRepository
|
public class VolumeRepository : IVolumeRepository
|
||||||
{
|
{
|
||||||
@ -195,6 +197,13 @@ public class VolumeRepository : IVolumeRepository
|
|||||||
return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId);
|
return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<Volume>> GetAllWithNonWebPCovers()
|
||||||
|
{
|
||||||
|
return await _context.Volume
|
||||||
|
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
|
private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
|
||||||
{
|
{
|
||||||
|
@ -83,7 +83,6 @@ public static class TagHelper
|
|||||||
/// <remarks>Used to remove before we update/add new tags</remarks>
|
/// <remarks>Used to remove before we update/add new tags</remarks>
|
||||||
/// <param name="existingTags">Existing tags on Entity</param>
|
/// <param name="existingTags">Existing tags on Entity</param>
|
||||||
/// <param name="tags">Tags from metadata</param>
|
/// <param name="tags">Tags from metadata</param>
|
||||||
/// <param name="isExternal">Remove external tags?</param>
|
|
||||||
/// <param name="action">Callback which will be executed for each tag removed</param>
|
/// <param name="action">Callback which will be executed for each tag removed</param>
|
||||||
public static void RemoveTags(ICollection<Tag> existingTags, IEnumerable<string> tags, Action<Tag>? action = null)
|
public static void RemoveTags(ICollection<Tag> existingTags, IEnumerable<string> tags, Action<Tag>? action = null)
|
||||||
{
|
{
|
||||||
|
@ -13,14 +13,12 @@ public class ExceptionMiddleware
|
|||||||
{
|
{
|
||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
private readonly ILogger<ExceptionMiddleware> _logger;
|
private readonly ILogger<ExceptionMiddleware> _logger;
|
||||||
private readonly IHostEnvironment _env;
|
|
||||||
|
|
||||||
|
|
||||||
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger, IHostEnvironment env)
|
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
|
||||||
{
|
{
|
||||||
_next = next;
|
_next = next;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_env = env;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
@ -22,8 +22,6 @@ public interface IBookmarkService
|
|||||||
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
|
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
|
||||||
Task ConvertAllBookmarkToWebP();
|
Task ConvertAllBookmarkToWebP();
|
||||||
Task ConvertAllCoverToWebP();
|
Task ConvertAllCoverToWebP();
|
||||||
Task ConvertBookmarkToWebP(int bookmarkId);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BookmarkService : IBookmarkService
|
public class BookmarkService : IBookmarkService
|
||||||
@ -74,6 +72,31 @@ public class BookmarkService : IBookmarkService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is a job that runs after a bookmark is saved
|
||||||
|
/// </summary>
|
||||||
|
private async Task ConvertBookmarkToWebP(int bookmarkId)
|
||||||
|
{
|
||||||
|
var bookmarkDirectory =
|
||||||
|
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
||||||
|
var convertBookmarkToWebP =
|
||||||
|
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP;
|
||||||
|
|
||||||
|
if (!convertBookmarkToWebP) return;
|
||||||
|
|
||||||
|
// Validate the bookmark still exists
|
||||||
|
var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId);
|
||||||
|
if (bookmark == null) return;
|
||||||
|
|
||||||
|
bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName,
|
||||||
|
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId));
|
||||||
|
_unitOfWork.UserRepository.Update(bookmark);
|
||||||
|
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new entry in the AppUserBookmarks and copies an image to BookmarkDirectory.
|
/// Creates a new entry in the AppUserBookmarks and copies an image to BookmarkDirectory.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -206,14 +229,24 @@ public class BookmarkService : IBookmarkService
|
|||||||
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
|
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
|
||||||
public async Task ConvertAllCoverToWebP()
|
public async Task ConvertAllCoverToWebP()
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("[BookmarkService] Starting conversion of all covers to webp");
|
||||||
var coverDirectory = _directoryService.CoverImageDirectory;
|
var coverDirectory = _directoryService.CoverImageDirectory;
|
||||||
|
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
MessageFactory.ConvertCoverProgressEvent(0F, ProgressEventType.Started));
|
MessageFactory.ConvertCoverProgressEvent(0F, ProgressEventType.Started));
|
||||||
var chapters = await _unitOfWork.ChapterRepository.GetAllChaptersWithNonWebPCovers();
|
var chapterCovers = await _unitOfWork.ChapterRepository.GetAllChaptersWithNonWebPCovers();
|
||||||
|
var seriesCovers = await _unitOfWork.SeriesRepository.GetAllWithNonWebPCovers();
|
||||||
|
|
||||||
|
var readingListCovers = await _unitOfWork.ReadingListRepository.GetAllWithNonWebPCovers();
|
||||||
|
var libraryCovers = await _unitOfWork.LibraryRepository.GetAllWithNonWebPCovers();
|
||||||
|
var collectionCovers = await _unitOfWork.CollectionTagRepository.GetAllWithNonWebPCovers();
|
||||||
|
|
||||||
|
var totalCount = chapterCovers.Count + seriesCovers.Count + readingListCovers.Count +
|
||||||
|
libraryCovers.Count + collectionCovers.Count;
|
||||||
|
|
||||||
var count = 1F;
|
var count = 1F;
|
||||||
foreach (var chapter in chapters)
|
_logger.LogInformation("[BookmarkService] Starting conversion of chapters");
|
||||||
|
foreach (var chapter in chapterCovers)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(chapter.CoverImage)) continue;
|
if (string.IsNullOrEmpty(chapter.CoverImage)) continue;
|
||||||
|
|
||||||
@ -222,38 +255,91 @@ public class BookmarkService : IBookmarkService
|
|||||||
_unitOfWork.ChapterRepository.Update(chapter);
|
_unitOfWork.ChapterRepository.Update(chapter);
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
MessageFactory.ConvertCoverProgressEvent(count / chapters.Count, ProgressEventType.Started));
|
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[BookmarkService] Starting conversion of series");
|
||||||
|
foreach (var series in seriesCovers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(series.CoverImage)) continue;
|
||||||
|
|
||||||
|
var newFile = await SaveAsWebP(coverDirectory, series.CoverImage, coverDirectory);
|
||||||
|
series.CoverImage = Path.GetFileName(newFile);
|
||||||
|
_unitOfWork.SeriesRepository.Update(series);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
|
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[BookmarkService] Starting conversion of libraries");
|
||||||
|
foreach (var library in libraryCovers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(library.CoverImage)) continue;
|
||||||
|
|
||||||
|
var newFile = await SaveAsWebP(coverDirectory, library.CoverImage, coverDirectory);
|
||||||
|
library.CoverImage = Path.GetFileName(newFile);
|
||||||
|
_unitOfWork.LibraryRepository.Update(library);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
|
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[BookmarkService] Starting conversion of reading lists");
|
||||||
|
foreach (var readingList in readingListCovers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(readingList.CoverImage)) continue;
|
||||||
|
|
||||||
|
var newFile = await SaveAsWebP(coverDirectory, readingList.CoverImage, coverDirectory);
|
||||||
|
readingList.CoverImage = Path.GetFileName(newFile);
|
||||||
|
_unitOfWork.ReadingListRepository.Update(readingList);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
|
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[BookmarkService] Starting conversion of collections");
|
||||||
|
foreach (var collection in collectionCovers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(collection.CoverImage)) continue;
|
||||||
|
|
||||||
|
var newFile = await SaveAsWebP(coverDirectory, collection.CoverImage, coverDirectory);
|
||||||
|
collection.CoverImage = Path.GetFileName(newFile);
|
||||||
|
_unitOfWork.CollectionTagRepository.Update(collection);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
|
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now null out all series and volumes that aren't webp or custom
|
||||||
|
var nonCustomOrConvertedVolumeCovers = await _unitOfWork.VolumeRepository.GetAllWithNonWebPCovers();
|
||||||
|
foreach (var volume in nonCustomOrConvertedVolumeCovers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(volume.CoverImage)) continue;
|
||||||
|
volume.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
|
||||||
|
_unitOfWork.VolumeRepository.Update(volume);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonCustomOrConvertedSeriesCovers = await _unitOfWork.SeriesRepository.GetAllWithNonWebPCovers(false);
|
||||||
|
foreach (var series in nonCustomOrConvertedSeriesCovers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(series.CoverImage)) continue;
|
||||||
|
series.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
|
||||||
|
_unitOfWork.SeriesRepository.Update(series);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
MessageFactory.ConvertCoverProgressEvent(1F, ProgressEventType.Ended));
|
MessageFactory.ConvertCoverProgressEvent(1F, ProgressEventType.Ended));
|
||||||
|
|
||||||
_logger.LogInformation("[BookmarkService] Converted covers to WebP");
|
_logger.LogInformation("[BookmarkService] Converted covers to WebP");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This is a job that runs after a bookmark is saved
|
|
||||||
/// </summary>
|
|
||||||
public async Task ConvertBookmarkToWebP(int bookmarkId)
|
|
||||||
{
|
|
||||||
var bookmarkDirectory =
|
|
||||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
|
||||||
var convertBookmarkToWebP =
|
|
||||||
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP;
|
|
||||||
|
|
||||||
if (!convertBookmarkToWebP) return;
|
|
||||||
|
|
||||||
// Validate the bookmark still exists
|
|
||||||
var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId);
|
|
||||||
if (bookmark == null) return;
|
|
||||||
|
|
||||||
bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName,
|
|
||||||
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId));
|
|
||||||
_unitOfWork.UserRepository.Update(bookmark);
|
|
||||||
|
|
||||||
await _unitOfWork.CommitAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts an image file, deletes original and returns the new path back
|
/// Converts an image file, deletes original and returns the new path back
|
||||||
|
@ -125,6 +125,11 @@ public class ImageService : IImageService
|
|||||||
return Task.FromResult(outputFile);
|
return Task.FromResult(outputFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs I/O to determine if the file is a valid Image
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filePath"></param>
|
||||||
|
/// <returns></returns>
|
||||||
public async Task<bool> IsImage(string filePath)
|
public async Task<bool> IsImage(string filePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
@ -418,7 +418,7 @@ public class ReadingListService : IReadingListService
|
|||||||
public async Task<CblImportSummaryDto> CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false)
|
public async Task<CblImportSummaryDto> CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false)
|
||||||
{
|
{
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.ReadingListsWithItems);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.ReadingListsWithItems);
|
||||||
_logger.LogDebug("Importing {ReadingListName} CBL for User {UserName}", cblReading.Name, user.UserName);
|
_logger.LogDebug("Importing {ReadingListName} CBL for User {UserName}", cblReading.Name, user!.UserName);
|
||||||
var importSummary = new CblImportSummaryDto()
|
var importSummary = new CblImportSummaryDto()
|
||||||
{
|
{
|
||||||
CblName = cblReading.Name,
|
CblName = cblReading.Name,
|
||||||
|
@ -30,6 +30,7 @@ public interface ITaskScheduler
|
|||||||
void CancelStatsTasks();
|
void CancelStatsTasks();
|
||||||
Task RunStatCollection();
|
Task RunStatCollection();
|
||||||
void ScanSiteThemes();
|
void ScanSiteThemes();
|
||||||
|
Task CovertAllCoversToWebP();
|
||||||
}
|
}
|
||||||
public class TaskScheduler : ITaskScheduler
|
public class TaskScheduler : ITaskScheduler
|
||||||
{
|
{
|
||||||
@ -46,6 +47,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
private readonly IThemeService _themeService;
|
private readonly IThemeService _themeService;
|
||||||
private readonly IWordCountAnalyzerService _wordCountAnalyzerService;
|
private readonly IWordCountAnalyzerService _wordCountAnalyzerService;
|
||||||
private readonly IStatisticService _statisticService;
|
private readonly IStatisticService _statisticService;
|
||||||
|
private readonly IBookmarkService _bookmarkService;
|
||||||
|
|
||||||
public static BackgroundJobServer Client => new BackgroundJobServer();
|
public static BackgroundJobServer Client => new BackgroundJobServer();
|
||||||
public const string ScanQueue = "scan";
|
public const string ScanQueue = "scan";
|
||||||
@ -66,7 +68,8 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
|
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
|
||||||
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
|
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
|
||||||
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
|
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
|
||||||
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService)
|
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
|
||||||
|
IBookmarkService bookmarkService)
|
||||||
{
|
{
|
||||||
_cacheService = cacheService;
|
_cacheService = cacheService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@ -80,6 +83,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
_themeService = themeService;
|
_themeService = themeService;
|
||||||
_wordCountAnalyzerService = wordCountAnalyzerService;
|
_wordCountAnalyzerService = wordCountAnalyzerService;
|
||||||
_statisticService = statisticService;
|
_statisticService = statisticService;
|
||||||
|
_bookmarkService = bookmarkService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ScheduleTasks()
|
public async Task ScheduleTasks()
|
||||||
@ -174,6 +178,17 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
BackgroundJob.Enqueue(() => _themeService.Scan());
|
BackgroundJob.Enqueue(() => _themeService.Scan());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CovertAllCoversToWebP()
|
||||||
|
{
|
||||||
|
await _bookmarkService.ConvertAllCoverToWebP();
|
||||||
|
_logger.LogInformation("[BookmarkService] Queuing tasks to update Series and Volume references via Cover Refresh");
|
||||||
|
var libraryIds = await _unitOfWork.LibraryRepository.GetLibrariesAsync();
|
||||||
|
foreach (var lib in libraryIds)
|
||||||
|
{
|
||||||
|
RefreshMetadata(lib.Id, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region UpdateTasks
|
#region UpdateTasks
|
||||||
|
@ -311,7 +311,7 @@ public class ParseScannedFiles
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|
||||||
MergeLocalizedSeriesWithSeries(infos!);
|
MergeLocalizedSeriesWithSeries(infos);
|
||||||
|
|
||||||
foreach (var info in infos)
|
foreach (var info in infos)
|
||||||
{
|
{
|
||||||
|
@ -907,7 +907,7 @@ public static class Parser
|
|||||||
|
|
||||||
public static bool IsImage(string filePath)
|
public static bool IsImage(string filePath)
|
||||||
{
|
{
|
||||||
return !filePath.StartsWith(".") && ImageRegex.IsMatch(Path.GetExtension(filePath));
|
return !filePath.StartsWith('.') && ImageRegex.IsMatch(Path.GetExtension(filePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsXml(string filePath)
|
public static bool IsXml(string filePath)
|
||||||
|
@ -537,12 +537,18 @@ export class ActionService implements OnDestroy {
|
|||||||
* @param chapters? Chapters, should have id
|
* @param chapters? Chapters, should have id
|
||||||
* @param callback Optional callback to perform actions after API completes
|
* @param callback Optional callback to perform actions after API completes
|
||||||
*/
|
*/
|
||||||
deleteMultipleSeries(seriesIds: Array<Series>, callback?: VoidActionCallback) {
|
async deleteMultipleSeries(seriesIds: Array<Series>, callback?: BooleanActionCallback) {
|
||||||
|
if (!await this.confirmService.confirm('Are you sure you want to delete ' + seriesIds.length + ' series? It will not modify files on disk.')) {
|
||||||
|
if (callback) {
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.seriesService.deleteMultipleSeries(seriesIds.map(s => s.id)).pipe(take(1)).subscribe(() => {
|
this.seriesService.deleteMultipleSeries(seriesIds.map(s => s.id)).pipe(take(1)).subscribe(() => {
|
||||||
this.toastr.success('Series deleted');
|
this.toastr.success('Series deleted');
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback();
|
callback(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -78,7 +78,8 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case Action.Delete:
|
case Action.Delete:
|
||||||
this.actionService.deleteMultipleSeries(selectedSeries, () => {
|
this.actionService.deleteMultipleSeries(selectedSeries, (successful) => {
|
||||||
|
if (!successful) return;
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
this.bulkSelectionService.deselectAll();
|
this.bulkSelectionService.deselectAll();
|
||||||
});
|
});
|
||||||
|
@ -103,7 +103,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case Action.Delete:
|
case Action.Delete:
|
||||||
this.actionService.deleteMultipleSeries(selectedSeries, () => {
|
this.actionService.deleteMultipleSeries(selectedSeries, successful => {
|
||||||
|
if (!successful) return;
|
||||||
this.bulkSelectionService.deselectAll();
|
this.bulkSelectionService.deselectAll();
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
@ -95,7 +95,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case Action.Delete:
|
case Action.Delete:
|
||||||
this.actionService.deleteMultipleSeries(selectedSeries, () => {
|
this.actionService.deleteMultipleSeries(selectedSeries, (successful) => {
|
||||||
|
if (!successful) return;
|
||||||
this.bulkSelectionService.deselectAll();
|
this.bulkSelectionService.deselectAll();
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
});
|
});
|
||||||
|
@ -318,7 +318,7 @@
|
|||||||
<div class="col-md-2 me-3">
|
<div class="col-md-2 me-3">
|
||||||
<form [formGroup]="seriesNameGroup">
|
<form [formGroup]="seriesNameGroup">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="series-name" class="form-label">Series Name</label> <i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="seriesNameFilterTooltip" role="button" tabindex="0"></i>
|
<label for="series-name" class="form-label me-1">Series Name</label><i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="seriesNameFilterTooltip" role="button" tabindex="0"></i>
|
||||||
<span class="visually-hidden" id="filter-series-name-help"><ng-container [ngTemplateOutlet]="seriesNameFilterTooltip"></ng-container></span>
|
<span class="visually-hidden" id="filter-series-name-help"><ng-container [ngTemplateOutlet]="seriesNameFilterTooltip"></ng-container></span>
|
||||||
<ng-template #seriesNameFilterTooltip>Series name will filter against Name, Sort Name, or Localized Name</ng-template>
|
<ng-template #seriesNameFilterTooltip>Series name will filter against Name, Sort Name, or Localized Name</ng-template>
|
||||||
<input type="text" id="series-name" formControlName="seriesNameQuery" class="form-control" aria-describedby="filter-series-name-help">
|
<input type="text" id="series-name" formControlName="seriesNameQuery" class="form-control" aria-describedby="filter-series-name-help">
|
||||||
|
@ -622,6 +622,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
|||||||
this.readProgressGroup.get('inProgress')?.setValue(true);
|
this.readProgressGroup.get('inProgress')?.setValue(true);
|
||||||
this.sortGroup.get('sortField')?.setValue(SortField.SortName);
|
this.sortGroup.get('sortField')?.setValue(SortField.SortName);
|
||||||
this.isAscendingSort = true;
|
this.isAscendingSort = true;
|
||||||
|
this.seriesNameGroup.get('seriesNameQuery')?.setValue('');
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
// Apply any presets which will trigger the apply
|
// Apply any presets which will trigger the apply
|
||||||
this.loadFromPresetsAndSetup();
|
this.loadFromPresetsAndSetup();
|
||||||
|
16
openapi.json
16
openapi.json
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.7.1.12"
|
"version": "0.7.1.13"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -3427,10 +3427,12 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Opds"
|
"Opds"
|
||||||
],
|
],
|
||||||
|
"summary": "This returns a streamed image following OPDS-PS v1.2",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "apiKey",
|
"name": "apiKey",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
|
"description": "",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -3439,6 +3441,7 @@
|
|||||||
{
|
{
|
||||||
"name": "libraryId",
|
"name": "libraryId",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
@ -3447,6 +3450,7 @@
|
|||||||
{
|
{
|
||||||
"name": "seriesId",
|
"name": "seriesId",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
@ -3455,6 +3459,7 @@
|
|||||||
{
|
{
|
||||||
"name": "volumeId",
|
"name": "volumeId",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
@ -3463,6 +3468,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chapterId",
|
"name": "chapterId",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
@ -3471,6 +3477,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pageNumber",
|
"name": "pageNumber",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
"description": "",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
@ -12236,6 +12243,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "For EPUB reader, this can be an optional string of the id of a part marker, to help resume reading position\r\non pages that combine multiple \"chapters\".",
|
"description": "For EPUB reader, this can be an optional string of the id of a part marker, to help resume reading position\r\non pages that combine multiple \"chapters\".",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"lastModifiedUtc": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@ -14993,7 +15004,8 @@
|
|||||||
"description": "Manga Reader Option: Background color of the reader"
|
"description": "Manga Reader Option: Background color of the reader"
|
||||||
},
|
},
|
||||||
"swipeToPaginate": {
|
"swipeToPaginate": {
|
||||||
"type": "boolean"
|
"type": "boolean",
|
||||||
|
"description": "Manga Reader Option: Should swiping trigger pagination"
|
||||||
},
|
},
|
||||||
"autoCloseMenu": {
|
"autoCloseMenu": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user