using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Interfaces; using API.Interfaces.Services; using API.SignalR; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace API.Services { public class MetadataService : IMetadataService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IArchiveService _archiveService; private readonly IBookService _bookService; private readonly IImageService _imageService; private readonly IHubContext _messageHub; private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); public MetadataService(IUnitOfWork unitOfWork, ILogger logger, IArchiveService archiveService, IBookService bookService, IImageService imageService, IHubContext messageHub) { _unitOfWork = unitOfWork; _logger = logger; _archiveService = archiveService; _bookService = bookService; _imageService = imageService; _messageHub = messageHub; } /// /// Determines whether an entity should regenerate cover image. /// /// If a cover image is locked but the underlying file has been deleted, this will allow regenerating. /// /// /// /// /// Directory where cover images are. Defaults to /// public static bool ShouldUpdateCoverImage(string coverImage, MangaFile firstFile, bool forceUpdate = false, bool isCoverLocked = false, string coverImageDirectory = null) { if (string.IsNullOrEmpty(coverImageDirectory)) { coverImageDirectory = DirectoryService.CoverImageDirectory; } var fileExists = File.Exists(Path.Join(coverImageDirectory, coverImage)); if (isCoverLocked && fileExists) return false; if (forceUpdate) return true; return (firstFile != null && firstFile.HasFileBeenModified()) || !HasCoverImage(coverImage, fileExists); } private static bool HasCoverImage(string coverImage) { return HasCoverImage(coverImage, File.Exists(coverImage)); } private static bool HasCoverImage(string coverImage, bool fileExists) { return !string.IsNullOrEmpty(coverImage) && fileExists; } private string GetCoverImage(MangaFile file, int volumeId, int chapterId) { file.LastModified = DateTime.Now; switch (file.Format) { case MangaFormat.Pdf: case MangaFormat.Epub: return _bookService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId)); case MangaFormat.Image: var coverImage = _imageService.GetCoverFile(file); return _imageService.GetCoverImage(coverImage, ImageService.GetChapterFormat(chapterId, volumeId)); case MangaFormat.Archive: return _archiveService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId)); default: return string.Empty; } } /// /// Updates the metadata for a Chapter /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image public bool UpdateMetadata(Chapter chapter, bool forceUpdate) { var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); if (ShouldUpdateCoverImage(chapter.CoverImage, firstFile, forceUpdate, chapter.CoverImageLocked)) { chapter.CoverImage = GetCoverImage(firstFile, chapter.VolumeId, chapter.Id); return true; } return false; } /// /// Updates the metadata for a Volume /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image public bool UpdateMetadata(Volume volume, bool forceUpdate) { // We need to check if Volume coverImage matches first chapters if forceUpdate is false if (volume == null || !ShouldUpdateCoverImage(volume.CoverImage, null, forceUpdate , false)) return false; volume.Chapters ??= new List(); var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault(); if (firstChapter == null) return false; volume.CoverImage = firstChapter.CoverImage; return true; } /// /// Updates metadata for Series /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image public bool UpdateMetadata(Series series, bool forceUpdate) { var madeUpdate = false; if (series == null) return false; if (ShouldUpdateCoverImage(series.CoverImage, null, forceUpdate, series.CoverImageLocked)) { series.Volumes ??= new List(); var firstCover = series.Volumes.GetCoverImage(series.Format); string coverImage = null; if (firstCover == null && series.Volumes.Any()) { // If firstCover is null and one volume, the whole series is Chapters under Vol 0. if (series.Volumes.Count == 1) { coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) .FirstOrDefault(c => !c.IsSpecial)?.CoverImage; madeUpdate = true; } if (!HasCoverImage(coverImage)) { coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) .FirstOrDefault()?.CoverImage; madeUpdate = true; } } series.CoverImage = firstCover?.CoverImage ?? coverImage; } return UpdateSeriesSummary(series, forceUpdate) || madeUpdate ; } private bool UpdateSeriesSummary(Series series, bool forceUpdate) { if (!string.IsNullOrEmpty(series.Summary) && !forceUpdate) return false; var isBook = series.Library.Type == LibraryType.Book; var firstVolume = series.Volumes.FirstWithChapters(isBook); var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles(); var firstFile = firstChapter?.Files.FirstOrDefault(); if (firstFile == null || (!forceUpdate && !firstFile.HasFileBeenModified())) return false; if (Parser.Parser.IsPdf(firstFile.FilePath)) return false; if (series.Format is MangaFormat.Archive or MangaFormat.Epub) { var summary = Parser.Parser.IsEpub(firstFile.FilePath) ? _bookService.GetSummaryInfo(firstFile.FilePath) : _archiveService.GetSummaryInfo(firstFile.FilePath); if (!string.IsNullOrEmpty(series.Summary)) { series.Summary = summary; firstFile.LastModified = DateTime.Now; return true; } } firstFile.LastModified = DateTime.Now; // NOTE: Should I put this here as well since it might not have actually been parsed? return false; } /// /// Refreshes Metadata for a whole library /// /// This can be heavy on memory first run /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) { var sw = Stopwatch.StartNew(); var library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId); // PERF: See if we can break this up into multiple threads that process 20 series at a time then save so we can reduce amount of memory used _logger.LogInformation("Beginning metadata refresh of {LibraryName}", library.Name); foreach (var series in library.Series) { var volumeUpdated = false; foreach (var volume in series.Volumes) { var chapterUpdated = false; foreach (var chapter in volume.Chapters) { chapterUpdated = UpdateMetadata(chapter, forceUpdate); } volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate); } UpdateMetadata(series, volumeUpdated || forceUpdate); _unitOfWork.SeriesRepository.Update(series); } if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) { _logger.LogInformation("Updated metadata for {LibraryName} in {ElapsedMilliseconds} milliseconds", library.Name, sw.ElapsedMilliseconds); } } /// /// Refreshes Metadata for a Series. Will always force updates. /// /// /// public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false) { var sw = Stopwatch.StartNew(); var library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId); var series = library.Series.SingleOrDefault(s => s.Id == seriesId); if (series == null) { _logger.LogError("Series {SeriesId} was not found on Library {LibraryName}", seriesId, libraryId); return; } _logger.LogInformation("Beginning metadata refresh of {SeriesName}", series.Name); var volumeUpdated = false; foreach (var volume in series.Volumes) { var chapterUpdated = false; foreach (var chapter in volume.Chapters) { chapterUpdated = UpdateMetadata(chapter, forceUpdate); } volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate); } UpdateMetadata(series, volumeUpdated || forceUpdate); _unitOfWork.SeriesRepository.Update(series); if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) { _logger.LogInformation("Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.RefreshMetadataEvent(libraryId, seriesId)); } } } }