First PR of the new year (#1717)

* Fixed a bug on bookmark mode not finding correct image for prefetcher.

* Fixed up the edit series relationship modal on tablet viewports.

* On double page mode, only bookmark 1 page if only 1 pages is renderered on screen.

* Added percentage read of a given library and average hours read per week to user stats.

* Fixed a bug in the reader with paging in bookmark mode

* Added a "This Week" option to top readers history

* Added date ranges for reading time. Added dates that don't have anything, but might remove.

* On phone, when applying a metadata filter, when clicking apply, collapse the filter automatically.

* Disable jump bar and the resuming from last spot when a custom sort is applied.

* Ensure all Regex.Replace or Matches have timeouts set

* Fixed a long standing bug where fit to height on tablets wouldn't center the image

* Streamlined url parsing to be more reliable

* Reduced an additional db query in chapter info.

* Added a missing task to convert covers to webP and added messaging to help the user understand to run it after modifying the setting.

* Changed OPDS to be enabled by default for new installs. This should reduce issues with users being confused about it before it's enabled.

* When there are multiple files for a chapter, show a count card on the series detail to help user understand duplicates exist. Made the unread badge smaller to avoid collision.

* Added Word Count to user stats and wired up average reading per week.

* Fixed word count failing on some epubs

* Removed some debug code

* Don't give more information than is necessary about file paths for page dimensions.

* Fixed a bug where pagination area would be too small when the book's content was less that height on default mode.

* Updated Default layout mode to Scroll for books.

* Added bytes in the UI and at an API layer for CDisplayEx

* Don't log health checks to logs at all.

* Changed Word Count to Length to match the way pages work

* Made reading time more clear when min hours is 0

* Apply more aggressive coalescing when remapping bad metadata keys for epubs.

* Changed the amount of padding between icon and text for side nav item.

* Fixed a NPE on book reader (harmless)

* Fixed an ordering issue where Volume 1 was a single file but also tagged as Chapter 1 and Volume 2 was Chapter 0. Thus Volume 2 was being selected for continue point when Volume 1 should have been.

* When clicking on an activity stream header from dashboard, show the title on the resulting page.

* Removed a property that can't be animated

* Fixed a typeahead typescript issue

* Added Size into Series Info and Added some tooltip and spacing changes to better explain some fields.

* Added size for volume drawers and cleaned up some date edge case handling

* Fixed an annoying bug where when on mobile opening a view with a metadata filter, Kavita would open the filter automatically.
This commit is contained in:
Joe Milazzo 2023-01-02 15:44:29 -07:00 committed by GitHub
parent 8eb5b466ef
commit a545f96a05
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 410 additions and 187 deletions

View file

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@ -8,7 +7,6 @@ using API.DTOs.Reader;
using API.Entities.Enums;
using API.Services;
using Kavita.Common;
using HtmlAgilityPack;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using VersOne.Epub;
@ -97,7 +95,7 @@ public class BookController : BaseApiController
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions);
var key = BookService.CleanContentKeys(file);
var key = BookService.CoalesceKeyForAnyFile(book, file);
if (!book.Content.AllFiles.ContainsKey(key)) return BadRequest("File was not found in book");
var bookFile = book.Content.AllFiles[key];

View file

@ -187,7 +187,7 @@ public class ReaderController : BaseApiController
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
if (dto == null) return BadRequest("Please perform a scan on this series or library and try again");
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
var mangaFile = chapter.Files.First();
var info = new ChapterInfoDto()
{

View file

@ -143,6 +143,19 @@ public class ServerController : BaseApiController
return Ok();
}
/// <summary>
/// Triggers the scheduling of the convert covers job. Only one job will run at a time.
/// </summary>
/// <returns></returns>
[HttpPost("convert-covers")]
public ActionResult ScheduleConvertCovers()
{
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) return Ok();
BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllCoverToWebP());
return Ok();
}
[HttpGet("logs")]
public ActionResult GetLogs()
{

View file

@ -8,6 +8,7 @@ public class MangaFileDto
public int Id { get; init; }
public string FilePath { get; init; }
public int Pages { get; init; }
public long Bytes { get; init; }
public MangaFormat Format { get; init; }
public DateTime Created { get; init; }

View file

@ -10,6 +10,10 @@ public class UserReadStatistics
/// </summary>
public long TotalPagesRead { get; set; }
/// <summary>
/// Total number of words read
/// </summary>
public long TotalWordsRead { get; set; }
/// <summary>
/// Total time spent reading based on estimates
/// </summary>
public long TimeSpentReading { get; set; }

View file

@ -18,6 +18,7 @@ public enum ChapterIncludes
{
None = 1,
Volumes = 2,
Files = 4
}
public interface IChapterRepository
@ -26,7 +27,7 @@ public interface IChapterRepository
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None);
Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId);
Task<int> GetChapterTotalPagesAsync(int chapterId);
Task<Chapter> GetChapterAsync(int chapterId);
Task<Chapter> GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
Task<ChapterMetadataDto> GetChapterMetadataDtoAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
@ -34,6 +35,7 @@ public interface IChapterRepository
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
Task<string> GetChapterCoverImageAsync(int chapterId);
Task<IList<string>> GetAllCoverImagesAsync();
Task<IList<Chapter>> GetAllChaptersWithNonWebPCovers();
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
}
public class ChapterRepository : IChapterRepository
@ -162,12 +164,17 @@ public class ChapterRepository : IChapterRepository
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
/// </summary>
/// <param name="chapterId"></param>
/// <param name="includes"></param>
/// <returns></returns>
public async Task<Chapter> GetChapterAsync(int chapterId)
public async Task<Chapter> GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files)
{
return await _context.Chapter
.Include(c => c.Files)
.AsSplitQuery()
var query = _context.Chapter
.AsSplitQuery();
if (includes.HasFlag(ChapterIncludes.Files)) query = query.Include(c => c.Files);
if (includes.HasFlag(ChapterIncludes.Volumes)) query = query.Include(c => c.Volume);
return await query
.SingleOrDefaultAsync(c => c.Id == chapterId);
}
@ -207,6 +214,13 @@ public class ChapterRepository : IChapterRepository
.ToListAsync();
}
public async Task<IList<Chapter>> GetAllChaptersWithNonWebPCovers()
{
return await _context.Chapter
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
.ToListAsync();
}
/// <summary>
/// Returns cover images for locked chapters
/// </summary>

View file

@ -90,7 +90,7 @@ public static class Seed
Key = ServerSettingKey.Port, Value = "5000"
}, // Not used from DB, but DB is sync with appSettings.json
new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
new() {Key = ServerSettingKey.EnableOpds, Value = "false"},
new() {Key = ServerSettingKey.EnableOpds, Value = "true"},
new() {Key = ServerSettingKey.EnableAuthentication, Value = "true"},
new() {Key = ServerSettingKey.BaseUrl, Value = "/"},
new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},

View file

@ -15,5 +15,6 @@ public static class LogEnricher
{
diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString());
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].FirstOrDefault());
diagnosticContext.Set("Path", httpContext.Request.Path);
}
}

View file

@ -1,6 +1,9 @@
using System.IO;
using System;
using System.IO;
using System.Net;
using API.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Serilog;
using Serilog.Core;
@ -57,7 +60,18 @@ public static class LogLevelOptions
.WriteTo.File(LogFile,
shared: true,
rollingInterval: RollingInterval.Day,
outputTemplate: outputTemplate);
outputTemplate: outputTemplate)
.Filter.ByIncludingOnly(ShouldIncludeLogStatement);
}
private static bool ShouldIncludeLogStatement(LogEvent e)
{
if (e.Properties.ContainsKey("SourceContext") &&
e.Properties["SourceContext"].ToString().Replace("\"", string.Empty) == "Serilog.AspNetCore.RequestLoggingMiddleware")
{
if (e.Properties.ContainsKey("Path") && e.Properties["Path"].ToString().Replace("\"", string.Empty) == "/api/health") return false;
}
return true;
}
public static void SwitchLogLevel(string level)

View file

@ -33,17 +33,6 @@ public interface IBookService
{
int GetNumberOfPages(string filePath);
string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false);
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
/// <summary>
/// Scopes styles to .reading-section and replaces img src to the passed apiBase
/// </summary>
/// <param name="stylesheetHtml"></param>
/// <param name="apiBase"></param>
/// <param name="filename">If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath.</param>
/// <param name="book">Book Reference, needed for if you expect Import statements</param>
/// <returns></returns>
Task<string> ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book);
ComicInfo GetComicInfo(string filePath);
ParserInfo ParseInfo(string filePath);
/// <summary>
@ -53,11 +42,9 @@ public interface IBookService
/// <param name="fileFilePath"></param>
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
void ExtractPdfImages(string fileFilePath, string targetDirectory);
Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page);
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl);
Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book);
}
public class BookService : IBookService
@ -163,6 +150,14 @@ public class BookService : IBookService
anchor.Attributes.Add("href", "javascript:void(0)");
}
/// <summary>
/// Scopes styles to .reading-section and replaces img src to the passed apiBase
/// </summary>
/// <param name="stylesheetHtml"></param>
/// <param name="apiBase"></param>
/// <param name="filename">If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath.</param>
/// <param name="book">Book Reference, needed for if you expect Import statements</param>
/// <returns></returns>
public async Task<string> ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book)
{
// @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be Scoped
@ -717,6 +712,13 @@ public class BookService : IBookService
return PrepareFinalHtml(doc, body);
}
/// <summary>
/// Tries to find the correct key by applying cleaning and remapping if the epub has bad data. Only works for HTML files.
/// </summary>
/// <param name="book"></param>
/// <param name="mappings"></param>
/// <param name="key"></param>
/// <returns></returns>
private static string CoalesceKey(EpubBookRef book, IDictionary<string, int> mappings, string key)
{
if (mappings.ContainsKey(CleanContentKeys(key))) return key;
@ -731,6 +733,23 @@ public class BookService : IBookService
return key;
}
public static string CoalesceKeyForAnyFile(EpubBookRef book, string key)
{
if (book.Content.AllFiles.ContainsKey(key)) return key;
var cleanedKey = CleanContentKeys(key);
if (book.Content.AllFiles.ContainsKey(cleanedKey)) return cleanedKey;
// Fallback to searching for key (bad epub metadata)
var correctedKey = book.Content.AllFiles.Keys.SingleOrDefault(s => s.EndsWith(key));
if (!string.IsNullOrEmpty(correctedKey))
{
key = correctedKey;
}
return key;
}
/// <summary>
/// This will return a list of mappings from ID -> page num. ID will be the xhtml key and page num will be the reading order
/// this is used to rewrite anchors in the book text so that we always load properly in our reader.
@ -844,7 +863,7 @@ public class BookService : IBookService
if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return content;
// In more cases than not, due to this being XML not HTML, we need to escape the script tags.
content = BookService.EscapeTags(content);
content = EscapeTags(content);
doc.LoadHtml(content);
var body = doc.DocumentNode.SelectSingleNode("//body");

View file

@ -22,6 +22,7 @@ public interface IBookmarkService
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllBookmarkToWebP();
Task ConvertAllCoverToWebP();
}
@ -183,7 +184,9 @@ public class BookmarkService : IBookmarkService
var count = 1F;
foreach (var bookmark in bookmarks)
{
await SaveBookmarkAsWebP(bookmarkDirectory, bookmark);
bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName,
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId));
_unitOfWork.UserRepository.Update(bookmark);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(count / bookmarks.Count, ProgressEventType.Started));
@ -196,10 +199,40 @@ public class BookmarkService : IBookmarkService
_logger.LogInformation("[BookmarkService] Converted bookmarks to WebP");
}
/// <summary>
/// This is a long-running job that will convert all covers into WebP. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllCoverToWebP()
{
var coverDirectory = _directoryService.CoverImageDirectory;
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(0F, ProgressEventType.Started));
var chapters = await _unitOfWork.ChapterRepository.GetAllChaptersWithNonWebPCovers();
var count = 1F;
foreach (var chapter in chapters)
{
var newFile = await SaveAsWebP(coverDirectory, chapter.CoverImage, coverDirectory);
chapter.CoverImage = newFile;
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / chapters.Count, ProgressEventType.Started));
count++;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(1F, ProgressEventType.Ended));
_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)
private async Task ConvertBookmarkToWebP(int bookmarkId)
{
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
@ -212,46 +245,52 @@ public class BookmarkService : IBookmarkService
var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId);
if (bookmark == null) return;
await SaveBookmarkAsWebP(bookmarkDirectory, bookmark);
bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName,
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId));
_unitOfWork.UserRepository.Update(bookmark);
await _unitOfWork.CommitAsync();
}
/// <summary>
/// Converts bookmark file, deletes original, marks bookmark as dirty. Does not commit.
/// Converts an image file, deletes original and returns the new path back
/// </summary>
/// <param name="bookmarkDirectory"></param>
/// <param name="bookmark"></param>
private async Task SaveBookmarkAsWebP(string bookmarkDirectory, AppUserBookmark bookmark)
/// <param name="imageDirectory">Full Path to where files are stored</param>
/// <param name="filename">The file to convert</param>
/// <param name="targetFolder">Full path to where files should be stored or any stem</param>
/// <returns></returns>
private async Task<string> SaveAsWebP(string imageDirectory, string filename, string targetFolder)
{
var fullSourcePath = _directoryService.FileSystem.Path.Join(bookmarkDirectory, bookmark.FileName);
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(bookmark.FileName).Name, string.Empty);
var targetFolderStem = BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId);
var fullSourcePath = _directoryService.FileSystem.Path.Join(imageDirectory, filename);
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(filename).Name, string.Empty);
_logger.LogDebug("Converting {Source} bookmark into WebP at {Target}", fullSourcePath, fullTargetDirectory);
var newFilename = string.Empty;
_logger.LogDebug("Converting {Source} image into WebP at {Target}", fullSourcePath, fullTargetDirectory);
try
{
// Convert target file to webp then delete original target file and update bookmark
var originalFile = bookmark.FileName;
var originalFile = filename;
try
{
var targetFile = await _imageService.ConvertToWebP(fullSourcePath, fullTargetDirectory);
var targetName = new FileInfo(targetFile).Name;
bookmark.FileName = Path.Join(targetFolderStem, targetName);
newFilename = Path.Join(targetFolder, targetName);
_directoryService.DeleteFiles(new[] {fullSourcePath});
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert file {FilePath}", bookmark.FileName);
bookmark.FileName = originalFile;
_logger.LogError(ex, "Could not convert image {FilePath}", filename);
newFilename = originalFile;
}
_unitOfWork.UserRepository.Update(bookmark);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert bookmark to WebP");
_logger.LogError(ex, "Could not convert image to WebP");
}
return newFilename;
}
private static string BookmarkStem(int userId, int seriesId, int chapterId)

View file

@ -82,7 +82,7 @@ public class CacheService : ICacheService
PageNumber = i,
Height = image.Height,
Width = image.Width,
FileName = file
FileName = file.Replace(path, string.Empty)
});
}

View file

@ -3,7 +3,6 @@ using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NetVips;
using SixLabors.ImageSharp;
using Image = NetVips.Image;
namespace API.Services;

View file

@ -479,11 +479,14 @@ public class ReaderService : IReaderService
var volumeChapters = volumes
.Where(v => v.Number != 0)
.SelectMany(v => v.Chapters)
.OrderBy(c => float.Parse(c.Number))
//.OrderBy(c => float.Parse(c.Number))
.ToList();
// NOTE: If volume 1 has chapter 1 and volume 2 is just chapter 0 due to being a full volume file, then this fails
// If there are any volumes that have progress, return those. If not, move on.
var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages);
var currentlyReadingChapter = volumeChapters
.OrderBy(c => double.Parse(c.Range), _chapterSortComparer)
.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages);
if (currentlyReadingChapter != null) return currentlyReadingChapter;
// Order with volume 0 last so we prefer the natural order

View file

@ -70,6 +70,10 @@ public class StatisticService : IStatisticService
.Where(c => chapterIds.Contains(c.Id))
.SumAsync(c => c.AvgHoursToRead);
var totalWordsRead = await _context.Chapter
.Where(c => chapterIds.Contains(c.Id))
.SumAsync(c => c.WordCount);
var chaptersRead = await _context.AppUserProgresses
.Where(p => p.AppUserId == userId)
.Where(p => libraryIds.Contains(p.LibraryId))
@ -90,8 +94,7 @@ public class StatisticService : IStatisticService
.AsEnumerable()
.GroupBy(g => g.series.LibraryId)
.ToDictionary(g => g.Key, g => g.Sum(c => c.chapter.Pages));
//
//
var totalProgressByLibrary = await _context.AppUserProgresses
.Where(p => p.AppUserId == userId)
.Where(p => p.LibraryId > 0)
@ -108,11 +111,12 @@ public class StatisticService : IStatisticService
.Where(p => p.AppUserId == userId)
.Join(_context.Chapter, p => p.ChapterId, c => c.Id,
(p, c) => (p.PagesRead / (float) c.Pages) * c.AvgHoursToRead)
.Average() / 7;
.Average() / 7.0;
return new UserReadStatistics()
{
TotalPagesRead = totalPagesRead,
TotalWordsRead = totalWordsRead,
TimeSpentReading = timeSpentReading,
ChaptersRead = chaptersRead,
LastActive = lastActive,
@ -314,7 +318,7 @@ public class StatisticService : IStatisticService
.Select(u => new ReadHistoryEvent
{
UserId = u.AppUserId,
UserName = _context.AppUser.Single(u => u.Id == userId).UserName,
UserName = _context.AppUser.Single(u2 => u2.Id == userId).UserName,
SeriesName = _context.Series.Single(s => s.Id == u.SeriesId).Name,
SeriesId = u.SeriesId,
LibraryId = u.LibraryId,

View file

@ -243,8 +243,9 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
.DefaultIfEmpty()
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
if (textNodes == null) return 0;
return textNodes
.Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Sum(words => words.Count());

View file

@ -106,6 +106,10 @@ public static class MessageFactory
/// </summary>
private const string ConvertBookmarksProgress = "ConvertBookmarksProgress";
/// <summary>
/// When bulk covers are being converted
/// </summary>
private const string ConvertCoversProgress = "ConvertBookmarksProgress";
/// <summary>
/// When files are being scanned to calculate word count
/// </summary>
private const string WordCountAnalyzerProgress = "WordCountAnalyzerProgress";
@ -495,4 +499,21 @@ public static class MessageFactory
}
};
}
public static SignalRMessage ConvertCoverProgressEvent(float progress, string eventType)
{
return new SignalRMessage()
{
Name = ConvertCoversProgress,
Title = "Converting Covers to WebP",
SubTitle = string.Empty,
EventType = eventType,
Progress = ProgressType.Determinate,
Body = new
{
Progress = progress,
EventTime = DateTime.Now
}
};
}
}