diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index f59afcf74..e1419e052 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -42,7 +42,7 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService return 1; } - public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat) + public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { return string.Empty; } diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index 915e584ca..32ad8f645 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -43,7 +43,7 @@ internal class MockReadingItemService : IReadingItemService return 1; } - public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat) + public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { return string.Empty; } diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index bbb32e042..8dcd3749d 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -158,11 +158,6 @@ public class ImageController : BaseApiController private async Task GenerateReadingListCoverImage(int readingListId) { var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId); - if (covers.Count < 4) - { - return string.Empty; - } - var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, ImageService.GetReadingListFormat(readingListId)); var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); @@ -171,6 +166,7 @@ public class ImageController : BaseApiController if (_directoryService.FileSystem.File.Exists(destFile)) return destFile; ImageService.CreateMergedImage( covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(), + settings.CoverImageSize, destFile); return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; } @@ -178,11 +174,6 @@ public class ImageController : BaseApiController private async Task GenerateCollectionCoverImage(int collectionId) { var covers = await _unitOfWork.CollectionTagRepository.GetRandomCoverImagesAsync(collectionId); - if (covers.Count < 4) - { - return string.Empty; - } - var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, ImageService.GetCollectionTagFormat(collectionId)); var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); @@ -190,6 +181,7 @@ public class ImageController : BaseApiController if (_directoryService.FileSystem.File.Exists(destFile)) return destFile; ImageService.CreateMergedImage( covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(), + settings.CoverImageSize, destFile); return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index f1849a7ff..895a0b5c3 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -197,6 +197,12 @@ public class SettingsController : BaseApiController _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.CoverImageSize && updateSettingsDto.CoverImageSize + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.CoverImageSize + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) { setting.Value = updateSettingsDto.TaskScan; diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 15dd9177b..e405758bc 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -84,4 +84,8 @@ public class ServerSettingDto /// How many Days since today in the past for chapter updates, should content be considered for On Deck, before it gets removed automatically /// public int OnDeckUpdateDays { get; set; } + /// + /// How large the cover images should be + /// + public CoverImageSize CoverImageSize { get; set; } } diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index 5bc490e35..4e35ef613 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -127,7 +127,6 @@ public class CollectionTagRepository : ICollectionTagRepository .Select(sm => sm.Series.CoverImage) .Where(t => !string.IsNullOrEmpty(t)) .ToListAsync(); - if (data.Count < 4) return new List(); return data .OrderBy(_ => random.Next()) .Take(4) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index bd0a5bafc..9c3d40011 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -101,7 +101,6 @@ public class ReadingListRepository : IReadingListRepository .SelectMany(r => r.Items.Select(ri => ri.Chapter.CoverImage)) .Where(t => !string.IsNullOrEmpty(t)) .ToListAsync(); - if (data.Count < 4) return new List(); return data .OrderBy(_ => random.Next()) .Take(4) diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 076f086cd..a1941d17f 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -108,8 +108,9 @@ public static class Seed new() {Key = ServerSettingKey.HostName, Value = string.Empty}, new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()}, new() {Key = ServerSettingKey.LicenseKey, Value = string.Empty}, - new() {Key = ServerSettingKey.OnDeckProgressDays, Value = $"{30}"}, - new() {Key = ServerSettingKey.OnDeckUpdateDays, Value = $"{7}"}, + new() {Key = ServerSettingKey.OnDeckProgressDays, Value = "30"}, + new() {Key = ServerSettingKey.OnDeckUpdateDays, Value = "7"}, + new() {Key = ServerSettingKey.CoverImageSize, Value = CoverImageSize.Default.ToString()}, new() { Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty }, // Not used from DB, but DB is sync with appSettings.json diff --git a/API/Entities/Enums/CoverImageSize.cs b/API/Entities/Enums/CoverImageSize.cs new file mode 100644 index 000000000..d2d0eebb6 --- /dev/null +++ b/API/Entities/Enums/CoverImageSize.cs @@ -0,0 +1,36 @@ +namespace API.Entities.Enums; + +public enum CoverImageSize +{ + /// + /// Default Size: 320x455 (wxh) + /// + Default = 1, + /// + /// 640x909 + /// + Medium = 2, + /// + /// 900x1277 + /// + Large = 3, + /// + /// 1265x1795 + /// + XLarge = 4 +} + +public static class CoverImageSizeExtensions +{ + public static (int Width, int Height) GetDimensions(this CoverImageSize size) + { + return size switch + { + CoverImageSize.Default => (320, 455), + CoverImageSize.Medium => (640, 909), + CoverImageSize.Large => (900, 1277), + CoverImageSize.XLarge => (1265, 1795), + _ => (320, 455) + }; + } +} diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index c8d9c12be..af699a3d9 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -143,5 +143,10 @@ public enum ServerSettingKey /// [Description("OnDeckUpdateDays")] OnDeckUpdateDays = 26, + /// + /// The size of the cover image thumbnail. Defaults to .Default + /// + [Description("CoverImageSize")] + CoverImageSize = 27 } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 9163a027f..a55e104a7 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -79,6 +79,9 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.OnDeckUpdateDays: destination.OnDeckUpdateDays = int.Parse(row.Value); break; + case ServerSettingKey.CoverImageSize: + destination.CoverImageSize = Enum.Parse(row.Value); + break; } } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index a4a9d3ccb..fd4349c90 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -22,7 +22,7 @@ public interface IArchiveService { void ExtractArchive(string archivePath, string extractPath); int GetNumberOfPagesFromArchive(string archivePath); - string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format); + string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format, CoverImageSize size = CoverImageSize.Default); bool IsValidArchive(string archivePath); ComicInfo? GetComicInfo(string archivePath); ArchiveLibrary CanOpen(string archivePath); @@ -205,7 +205,7 @@ public class ArchiveService : IArchiveService /// Where to output the file, defaults to covers directory /// When saving the file, use encoding /// - public string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format) + public string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format, CoverImageSize size = CoverImageSize.Default) { if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty; try @@ -221,7 +221,7 @@ public class ArchiveService : IArchiveService var entry = archive.Entries.Single(e => e.FullName == entryName); using var stream = entry.Open(); - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, format); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, format, size); } case ArchiveLibrary.SharpCompress: { @@ -232,7 +232,7 @@ public class ArchiveService : IArchiveService var entry = archive.Entries.Single(e => e.Key == entryName); using var stream = entry.OpenEntryStream(); - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, format); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, format, size); } case ArchiveLibrary.NotSupported: _logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); @@ -246,7 +246,7 @@ public class ArchiveService : IArchiveService { _logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); _mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService, - "This archive cannot be read or not supported", ex); + "This archive cannot be read or not supported", ex); // TODO: Localize this } return string.Empty; diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 54d2257ab..06424c1a3 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -33,7 +33,7 @@ namespace API.Services; public interface IBookService { int GetNumberOfPages(string filePath); - string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat); + string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); ComicInfo? GetComicInfo(string filePath); ParserInfo? ParseInfo(string filePath); /// @@ -1196,13 +1196,13 @@ public class BookService : IBookService /// Where to output the file, defaults to covers directory /// When saving the file, use encoding /// - public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat) + public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { if (!IsValidFile(fileFilePath)) return string.Empty; if (Parser.IsPdf(fileFilePath)) { - return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat); + return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat, size); } using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); @@ -1217,20 +1217,20 @@ public class BookService : IBookService if (coverImageContent == null) return string.Empty; using var stream = coverImageContent.GetContentStream(); - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat, size); } catch (Exception ex) { _logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); _mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService, - "There was a critical error and prevented thumbnail generation", ex); + "There was a critical error and prevented thumbnail generation", ex); // TODO: Localize this } return string.Empty; } - private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat) + private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size) { try { @@ -1240,7 +1240,7 @@ public class BookService : IBookService using var stream = StreamManager.GetStream("BookService.GetPdfPage"); GetPdfPage(docReader, 0, stream); - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat, size); } catch (Exception ex) diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 54ea5ec38..6996eff43 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -21,7 +21,7 @@ namespace API.Services; public interface IImageService { void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1); - string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat); + string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size); /// /// Creates a Thumbnail version of a base64 image @@ -40,7 +40,7 @@ public interface IImageService /// /// /// - string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat); + string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); /// /// Writes out a thumbnail by file path input /// @@ -49,7 +49,7 @@ public interface IImageService /// /// /// - string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat); + string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); /// /// Converts the passed image to encoding and outputs it in the same directory /// @@ -87,6 +87,7 @@ public class ImageService : IImageService /// public const int LibraryThumbnailWidth = 32; + private static readonly string[] ValidIconRelations = { "icon", "apple-touch-icon", @@ -124,13 +125,14 @@ public class ImageService : IImageService } } - public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat) + public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size) { if (string.IsNullOrEmpty(path)) return string.Empty; try { - using var thumbnail = Image.Thumbnail(path, ThumbnailWidth, height: ThumbnailHeight, size: Enums.Size.Force); + var dims = size.GetDimensions(); + using var thumbnail = Image.Thumbnail(path, dims.Width, height: dims.Height, size: Enums.Size.Force); var filename = fileName + encodeFormat.GetExtension(); thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); return filename; @@ -152,9 +154,10 @@ public class ImageService : IImageService /// Where to output the file, defaults to covers directory /// Export the file as the passed encoding /// File name with extension of the file. This will always write to - public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat) + public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { - using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth, height: ThumbnailHeight, size: Enums.Size.Force); + var dims = size.GetDimensions(); + using var thumbnail = Image.ThumbnailStream(stream, dims.Width, height: dims.Height, size: Enums.Size.Force); var filename = fileName + encodeFormat.GetExtension(); _directoryService.ExistOrCreate(outputDirectory); try @@ -165,9 +168,10 @@ public class ImageService : IImageService return filename; } - public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat) + public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { - using var thumbnail = Image.Thumbnail(sourceFile, ThumbnailWidth, height: ThumbnailHeight, size: Enums.Size.Force); + var dims = size.GetDimensions(); + using var thumbnail = Image.Thumbnail(sourceFile, dims.Width, height: dims.Height, size: Enums.Size.Force); var filename = fileName + encodeFormat.GetExtension(); _directoryService.ExistOrCreate(outputDirectory); try @@ -420,27 +424,58 @@ public class ImageService : IImageService } - public static string CreateMergedImage(IList coverImages, string dest) + public static void CreateMergedImage(IList coverImages, CoverImageSize size, string dest) { - var image = Image.Black(ThumbnailWidth, ThumbnailHeight); // 320x455 + var dims = size.GetDimensions(); + int rows, cols; - var thumbnailWidth = image.Width / 2; - var thumbnailHeight = image.Height / 2; + if (coverImages.Count == 1) + { + rows = 1; + cols = 1; + } + else if (coverImages.Count == 2) + { + rows = 1; + cols = 2; + } + else if (coverImages.Count == 3) + { + rows = 2; + cols = 2; + } + else + { + // Default to 2x2 layout for more than 3 images + rows = 2; + cols = 2; + } + + var image = Image.Black(dims.Width, dims.Height); + + var thumbnailWidth = image.Width / cols; + var thumbnailHeight = image.Height / rows; for (var i = 0; i < coverImages.Count; i++) { var tile = Image.NewFromFile(coverImages[i], access: Enums.Access.Sequential); - - // Resize the tile to fit the thumbnail size tile = tile.ThumbnailImage(thumbnailWidth, height: thumbnailHeight); - var x = (i % 2) * thumbnailWidth; - var y = (i / 2) * thumbnailHeight; + var row = i / cols; + var col = i % cols; + + var x = col * thumbnailWidth; + var y = row * thumbnailHeight; + + if (coverImages.Count == 3 && i == 2) + { + x = (image.Width - thumbnailWidth) / 2; + y = thumbnailHeight; + } image = image.Insert(tile, x, y); } image.WriteToFile(dest); - return dest; } } diff --git a/API/Services/MediaErrorService.cs b/API/Services/MediaErrorService.cs index 6615dab7a..30c51e61d 100644 --- a/API/Services/MediaErrorService.cs +++ b/API/Services/MediaErrorService.cs @@ -37,6 +37,7 @@ public class MediaErrorService : IMediaErrorService public void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, Exception ex) { + // TODO: Localize all these messages // To avoid overhead on commits, do async. We don't need to wait. BackgroundJob.Enqueue(() => ReportMediaIssueAsync(filename, producer, errorMessage, ex.Message)); } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index ff5a18df2..f6fb06063 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -33,7 +33,7 @@ public interface IMetadataService /// Overrides any cache logic and forces execution Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true); - Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, bool forceUpdate = false); + Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false); Task RemoveAbandonedMetadataKeys(); } @@ -65,7 +65,7 @@ public class MetadataService : IMetadataService /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image /// Convert image to Encoding Format when extracting the cover - private Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat) + private Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize) { var firstFile = chapter.Files.MinBy(x => x.Chapter); if (firstFile == null) return Task.FromResult(false); @@ -79,7 +79,7 @@ public class MetadataService : IMetadataService _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, - ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat); + ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat, coverImageSize); _unitOfWork.ChapterRepository.Update(chapter); _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); return Task.FromResult(true); @@ -143,7 +143,7 @@ public class MetadataService : IMetadataService /// /// /// - private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat) + private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize) { _logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName); try @@ -156,7 +156,7 @@ public class MetadataService : IMetadataService var index = 0; foreach (var chapter in volume.Chapters) { - var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat); + var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize); // If cover was update, either the file has changed or first scan and we should force a metadata update UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated); if (index == 0 && chapterUpdated) @@ -208,7 +208,9 @@ public class MetadataService : IMetadataService await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.CoverUpdateProgressEvent(library.Id, 0F, ProgressEventType.Started, $"Starting {library.Name}")); - var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs; + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var encodeFormat = settings.EncodeMediaAs; + var coverImageSize = settings.CoverImageSize; for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) { @@ -238,7 +240,7 @@ public class MetadataService : IMetadataService try { - await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat); + await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize); } catch (Exception ex) { @@ -288,8 +290,10 @@ public class MetadataService : IMetadataService return; } - var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs; - await GenerateCoversForSeries(series, encodeFormat, forceUpdate); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var encodeFormat = settings.EncodeMediaAs; + var coverImageSize = settings.CoverImageSize; + await GenerateCoversForSeries(series, encodeFormat, coverImageSize, forceUpdate); } /// @@ -298,13 +302,13 @@ public class MetadataService : IMetadataService /// A full Series, with metadata, chapters, etc /// When saving the file, what encoding should be used /// - public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, bool forceUpdate = false) + public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false) { var sw = Stopwatch.StartNew(); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name)); - await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat); + await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize); if (_unitOfWork.HasChanges()) diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index c163d45f0..86deed393 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -10,7 +10,7 @@ public interface IReadingItemService { ComicInfo? GetComicInfo(string filePath); int GetNumberOfPages(string filePath, MangaFormat format); - string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat); + string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); ParserInfo? ParseFile(string path, string rootPath, LibraryType type); } @@ -162,7 +162,7 @@ public class ReadingItemService : IReadingItemService } } - public string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat) + public string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(fileName)) { @@ -172,10 +172,10 @@ public class ReadingItemService : IReadingItemService return format switch { - MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat), - MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat), - MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat), - MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat), + MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat, size), + MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat, size), + MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat, size), + MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat, size), _ => string.Empty }; } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index f30da8a47..55dba51b5 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -231,7 +231,8 @@ public class ProcessSeries : IProcessSeries _logger.LogError(ex, "[ScannerService] There was an exception updating series for {SeriesName}", series.Name); } - await _metadataService.GenerateCoversForSeries(series, (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + await _metadataService.GenerateCoversForSeries(series, settings.EncodeMediaAs, settings.CoverImageSize); EnqueuePostSeriesProcessTasks(series.LibraryId, series.Id); } diff --git a/UI/Web/src/app/admin/_models/cover-image-size.ts b/UI/Web/src/app/admin/_models/cover-image-size.ts new file mode 100644 index 000000000..6a58de8ba --- /dev/null +++ b/UI/Web/src/app/admin/_models/cover-image-size.ts @@ -0,0 +1,14 @@ +export enum CoverImageSize { + Default = 1, + Medium = 2, + Large = 3, + XLarge = 4 +} + +export const CoverImageSizes = + [ + {value: CoverImageSize.Default, title: 'cover-image-size.default'}, + {value: CoverImageSize.Medium, title: 'cover-image-size.medium'}, + {value: CoverImageSize.Large, title: 'cover-image-size.large'}, + {value: CoverImageSize.XLarge, title: 'cover-image-size.xlarge'} + ]; diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index b72396a8c..e58aa5190 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -1,4 +1,5 @@ import { EncodeFormat } from "./encode-format"; +import {CoverImageSize} from "./cover-image-size"; export interface ServerSettings { cacheDirectory: string; @@ -20,4 +21,5 @@ export interface ServerSettings { cacheSize: number; onDeckProgressDays: number; onDeckUpdateDays: number; + coverImageSize: CoverImageSize; } diff --git a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html index 7d78644d3..4b01b5b11 100644 --- a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html +++ b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html @@ -7,7 +7,7 @@
{{t('encode-as-warning')}}

-
+
{{t('encode-as-tooltip')}} @@ -16,6 +16,16 @@
+ +
+ + + {{t('cover-image-size-tooltip')}} + + +
diff --git a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.ts b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.ts index 62ff9094b..f1de0ed37 100644 --- a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.ts +++ b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.ts @@ -21,7 +21,8 @@ import {EncodeFormats} from '../_models/encode-format'; import {ManageScrobbleErrorsComponent} from '../manage-scrobble-errors/manage-scrobble-errors.component'; import {ManageAlertsComponent} from '../manage-alerts/manage-alerts.component'; import {NgFor, NgIf, NgTemplateOutlet} from '@angular/common'; -import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; +import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco"; +import { CoverImageSizes } from '../_models/cover-image-size'; @Component({ selector: 'app-manage-media-settings', @@ -38,6 +39,11 @@ export class ManageMediaSettingsComponent implements OnInit { alertCount: number = 0; scrobbleCount: number = 0; + coverImageSizes = CoverImageSizes.map(o => { + const newObj = {...o}; + newObj.title = translate(o.title); + return newObj; + }) private readonly translocoService = inject(TranslocoService); private readonly cdRef = inject(ChangeDetectorRef); @@ -51,6 +57,7 @@ export class ManageMediaSettingsComponent implements OnInit { this.serverSettings = settings; this.settingsForm.addControl('encodeMediaAs', new FormControl(this.serverSettings.encodeMediaAs, [Validators.required])); this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required])); + this.settingsForm.addControl('coverImageSize', new FormControl(this.serverSettings.coverImageSize, [Validators.required])); this.cdRef.markForCheck(); }); } @@ -58,6 +65,7 @@ export class ManageMediaSettingsComponent implements OnInit { resetForm() { this.settingsForm.get('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs); this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory); + this.settingsForm.get('coverImageSize')?.setValue(this.serverSettings.coverImageSize); this.settingsForm.markAsPristine(); this.cdRef.markForCheck(); } @@ -66,6 +74,7 @@ export class ManageMediaSettingsComponent implements OnInit { const modelSettings = Object.assign({}, this.serverSettings); modelSettings.encodeMediaAs = parseInt(this.settingsForm.get('encodeMediaAs')?.value, 10); modelSettings.bookmarksDirectory = this.settingsForm.get('bookmarksDirectory')?.value; + modelSettings.coverImageSize = parseInt(this.settingsForm.get('coverImageSize')?.value, 10); this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => { this.serverSettings = settings; diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index ab0de261d..365120ac3 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -11,7 +11,9 @@ [trackByIdentity]="trackByIdentity" > - + diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html index 75fd01fb8..fd5c5d564 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html @@ -11,9 +11,9 @@
-
-
- +
+
+
diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts index 10a1224ff..cade37ff9 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts @@ -70,7 +70,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { translocoService = inject(TranslocoService); collectionTag!: CollectionTag; - tagImage: string = ''; isLoading: boolean = true; series: Array = []; pagination!: Pagination; @@ -229,9 +228,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { this.collectionTag = matchingTags[0]; this.summary = (this.collectionTag.summary === null ? '' : this.collectionTag.summary).replace(/\n/g, '
'); - // TODO: This can be changed now that we have app-image and collection cover merge code (can it? Because we still have the case where there is no image) - // I can always tweak merge to allow blank slots and if just one item, just show that item image - this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(this.collectionTag.id)); this.titleService.setTitle(this.translocoService.translate('collection-detail.title-alt', {collectionName: this.collectionTag.title})); this.cdRef.markForCheck(); }); @@ -285,11 +281,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { modalRef.closed.subscribe((results: {success: boolean, coverImageUpdated: boolean}) => { this.updateTag(this.collectionTag.id); this.loadPage(); - if (results.coverImageUpdated) { - this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id)); - this.collectionTag.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id)); - this.cdRef.markForCheck(); - } }); } diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index c9552e8c6..52af401e7 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -38,8 +38,8 @@
-
- +
+
diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index a637e00df..24924139d 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -62,7 +62,6 @@ export class ReadingListDetailComponent implements OnInit { downloadInProgress: boolean = false; readingListSummary: string = ''; - readingListImage: string = ''; libraryTypes: {[key: number]: LibraryType} = {}; characters$!: Observable; @@ -90,7 +89,6 @@ export class ReadingListDetailComponent implements OnInit { } this.listId = parseInt(listId, 10); this.characters$ = this.readingListService.getCharacters(this.listId); - this.readingListImage = this.imageService.randomize(this.imageService.getReadingListCoverImage(this.listId)); forkJoin([ this.libraryService.getLibraries(), @@ -162,7 +160,6 @@ export class ReadingListDetailComponent implements OnInit { this.readingListService.getReadingList(this.listId).subscribe(rl => { this.readingList = rl; this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '
'); - this.readingListImage = this.imageService.randomize(this.imageService.getReadingListCoverImage(this.listId)); this.cdRef.markForCheck(); }) }); diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html index 812fc44e6..eb82370d5 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html @@ -2,7 +2,7 @@

- Reading Lists + {{t('title')}}

{{t('item-count', {num: pagination.totalItems | number})}}
@@ -13,6 +13,7 @@ [pagination]="pagination" [jumpBarKeys]="jumpbarKeys" [filteringDisabled]="true" + [trackByIdentity]="trackByIdentity" > = []; actions: {[key: number]: Array>} = {}; globalActions: Array> = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}]; + trackByIdentity = (index: number, item: ReadingList) => `${item.id}_${item.title}`; translocoService = inject(TranslocoService); constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index ba5a5952c..39adb5fff 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1069,7 +1069,16 @@ "save": "{{common.save}}", "media-issue-title": "Media Issues", - "scrobble-issue-title": "Scrobble Issues" + "scrobble-issue-title": "Scrobble Issues", + "cover-image-size-label": "Cover Image Size", + "cover-image-size-tooltip": "How large should cover images generate as. Note: Anything larger than the default will incur longer page load times." + }, + + "cover-image-size": { + "default": "Default (320x455)", + "medium": "Medium (640x909)", + "large": "Large (900x1277)", + "xlarge": "Extra Large (1265x1795)" }, "manage-scrobble-errors": { diff --git a/openapi.json b/openapi.json index 1cf089711..c14895509 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.7.7" + "version": "0.7.7.9" }, "servers": [ { @@ -16658,6 +16658,17 @@ "type": "integer", "description": "How many Days since today in the past for chapter updates, should content be considered for On Deck, before it gets removed automatically", "format": "int32" + }, + "coverImageSize": { + "enum": [ + 1, + 2, + 3, + 4 + ], + "type": "integer", + "description": "How large the cover images should be", + "format": "int32" } }, "additionalProperties": false