mirror of
				https://github.com/Kareadita/Kavita.git
				synced 2025-11-03 19:17:05 -05:00 
			
		
		
		
	* Refactored ResponseCache profiles into consts * Refactored code to use an extension method for getting user library ids. * Started server statistics, added a charting library, and added a table sort column (not finished) * Refactored code and have a fully working example of sortable headers. Still doesn't work with default sorting state, will work on that later. * Implemented file size, but it's too expensive, so commented out. * Added a migration to provide extension and length/size information in the DB to allow for faster stat apis. * Added the ability to force a library scan from library settings. * Refactored some apis to provide more of a file breakdown rather than just file size. * Working on visualization of file breakdown * Fixed the file breakdown visual * Fixed up 2 visualizations * Added back an api for member names, started work on top reads * Hooked up the other library types and username/days. * Preparing to remove top reads and refactor into Top users * Added LibraryId to AppUserProgress to help with complex lookups. * Added the new libraryId hook into some stats methods * Updated api methods to use libraryId for progress * More places where LibraryId is needed * Added some high level server stats * Got a ton done on server stats * Updated default theme (dark) to be the default root variables. This will allow user themes to override just what they want, rather than maintain their own css variables. * Implemented a monster query for top users by reading time. It's very slow and can be cleaned up likely. * Hooked up top reads. Code needs a big refactor. Handing off for Robbie treatment and I'll switch to User stats. * Implemented last 5 recently read series (broken) and added some basic css * Fixed recently read query * Cleanup the css a bit, Robbie we need you * More css love * Cleaned up DTOs that aren't needed anymore * Fixed top readers query * When calculating top readers, don't include read events where nothing is read (0 pages) * Hooked up the date into GetTopUsers * Hooked top readers up with days and refactored and cleaned up componets not used * Fixed up query * Started on a day by day breakdown, but going to take a break from stats. * Added a temp task to run some migration manually for stats to work * Ensure OPDS-PS uses new libraryId for progress reporting * Fixed a code smell * Adding some styling * adding more styles * Removed some debug stuff from user stats * Bump qs from 6.5.2 to 6.5.3 in /UI/Web Bumps [qs](https://github.com/ljharb/qs) from 6.5.2 to 6.5.3. - [Release notes](https://github.com/ljharb/qs/releases) - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.5.2...v6.5.3) --- updated-dependencies: - dependency-name: qs dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> * Tweaked some code for bad data cases * Refactored a chapter lookup to remove un-needed Volume join in 5 places across the code. * API push Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
		
			
				
	
	
		
			631 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			631 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System;
 | 
						|
using System.Collections.Generic;
 | 
						|
using System.Diagnostics;
 | 
						|
using System.Globalization;
 | 
						|
using System.IO;
 | 
						|
using System.Linq;
 | 
						|
using System.Threading.Tasks;
 | 
						|
using API.Data;
 | 
						|
using API.Data.Repositories;
 | 
						|
using API.Entities;
 | 
						|
using API.Entities.Enums;
 | 
						|
using API.Helpers;
 | 
						|
using API.Parser;
 | 
						|
using API.Services.Tasks.Metadata;
 | 
						|
using API.Services.Tasks.Scanner;
 | 
						|
using API.SignalR;
 | 
						|
using Hangfire;
 | 
						|
using Microsoft.Extensions.Logging;
 | 
						|
 | 
						|
namespace API.Services.Tasks;
 | 
						|
public interface IScannerService
 | 
						|
{
 | 
						|
    /// <summary>
 | 
						|
    /// Given a library id, scans folders for said library. Parses files and generates DB updates. Will overwrite
 | 
						|
    /// cover images if forceUpdate is true.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryId">Library to scan against</param>
 | 
						|
    /// <param name="forceUpdate">Don't perform optimization checks, defaults to false</param>
 | 
						|
    [Queue(TaskScheduler.ScanQueue)]
 | 
						|
    [DisableConcurrentExecution(60 * 60 * 60)]
 | 
						|
    [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
 | 
						|
    Task ScanLibrary(int libraryId, bool forceUpdate = false);
 | 
						|
 | 
						|
    [Queue(TaskScheduler.ScanQueue)]
 | 
						|
    [DisableConcurrentExecution(60 * 60 * 60)]
 | 
						|
    [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
 | 
						|
    Task ScanLibraries();
 | 
						|
 | 
						|
    [Queue(TaskScheduler.ScanQueue)]
 | 
						|
    [DisableConcurrentExecution(60 * 60 * 60)]
 | 
						|
    [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
 | 
						|
    Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true);
 | 
						|
 | 
						|
    Task ScanFolder(string folder);
 | 
						|
    Task AnalyzeFiles();
 | 
						|
 | 
						|
}
 | 
						|
 | 
						|
public enum ScanCancelReason
 | 
						|
{
 | 
						|
    /// <summary>
 | 
						|
    /// Don't cancel, everything is good
 | 
						|
    /// </summary>
 | 
						|
    NoCancel = 0,
 | 
						|
    /// <summary>
 | 
						|
    /// A folder is completely empty or missing
 | 
						|
    /// </summary>
 | 
						|
    FolderMount = 1,
 | 
						|
    /// <summary>
 | 
						|
    /// There has been no change to the filesystem since last scan
 | 
						|
    /// </summary>
 | 
						|
    NoChange = 2,
 | 
						|
    /// <summary>
 | 
						|
    /// The underlying folder is missing
 | 
						|
    /// </summary>
 | 
						|
    FolderMissing = 3
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Responsible for Scanning the disk and importing/updating/deleting files -> DB entities.
 | 
						|
 */
 | 
						|
public class ScannerService : IScannerService
 | 
						|
{
 | 
						|
    public const string Name = "ScannerService";
 | 
						|
    private readonly IUnitOfWork _unitOfWork;
 | 
						|
    private readonly ILogger<ScannerService> _logger;
 | 
						|
    private readonly IMetadataService _metadataService;
 | 
						|
    private readonly ICacheService _cacheService;
 | 
						|
    private readonly IEventHub _eventHub;
 | 
						|
    private readonly IDirectoryService _directoryService;
 | 
						|
    private readonly IReadingItemService _readingItemService;
 | 
						|
    private readonly IProcessSeries _processSeries;
 | 
						|
    private readonly IWordCountAnalyzerService _wordCountAnalyzerService;
 | 
						|
 | 
						|
    public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger,
 | 
						|
        IMetadataService metadataService, ICacheService cacheService, IEventHub eventHub,
 | 
						|
        IDirectoryService directoryService, IReadingItemService readingItemService,
 | 
						|
        IProcessSeries processSeries, IWordCountAnalyzerService wordCountAnalyzerService)
 | 
						|
    {
 | 
						|
        _unitOfWork = unitOfWork;
 | 
						|
        _logger = logger;
 | 
						|
        _metadataService = metadataService;
 | 
						|
        _cacheService = cacheService;
 | 
						|
        _eventHub = eventHub;
 | 
						|
        _directoryService = directoryService;
 | 
						|
        _readingItemService = readingItemService;
 | 
						|
        _processSeries = processSeries;
 | 
						|
        _wordCountAnalyzerService = wordCountAnalyzerService;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// This is only used for v0.7 to get files analyzed
 | 
						|
    /// </summary>
 | 
						|
    public async Task AnalyzeFiles()
 | 
						|
    {
 | 
						|
        _logger.LogInformation("Starting Analyze Files task");
 | 
						|
        var missingExtensions = await _unitOfWork.MangaFileRepository.GetAllWithMissingExtension();
 | 
						|
        if (missingExtensions.Count == 0)
 | 
						|
        {
 | 
						|
            _logger.LogInformation("Nothing to do");
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        var sw = Stopwatch.StartNew();
 | 
						|
 | 
						|
        foreach (var file in missingExtensions)
 | 
						|
        {
 | 
						|
            var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(file.FilePath);
 | 
						|
            if (!fileInfo.Exists)continue;
 | 
						|
            file.Extension = fileInfo.Extension.ToLowerInvariant();
 | 
						|
            file.Bytes = fileInfo.Length;
 | 
						|
            _unitOfWork.MangaFileRepository.Update(file);
 | 
						|
        }
 | 
						|
 | 
						|
        await _unitOfWork.CommitAsync();
 | 
						|
 | 
						|
        _logger.LogInformation("Completed Analyze Files task in {ElapsedTime}", sw.Elapsed);
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Given a generic folder path, will invoke a Series scan or Library scan.
 | 
						|
    /// </summary>
 | 
						|
    /// <remarks>This will Schedule the job to run 1 minute in the future to allow for any close-by duplicate requests to be dropped</remarks>
 | 
						|
    /// <param name="folder"></param>
 | 
						|
    public async Task ScanFolder(string folder)
 | 
						|
    {
 | 
						|
        Series series = null;
 | 
						|
        try
 | 
						|
        {
 | 
						|
            series = await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(folder, SeriesIncludes.Library);
 | 
						|
        }
 | 
						|
        catch (InvalidOperationException ex)
 | 
						|
        {
 | 
						|
            if (ex.Message.Equals("Sequence contains more than one element."))
 | 
						|
            {
 | 
						|
                _logger.LogCritical("[ScannerService] Multiple series map to this folder. Library scan will be used for ScanFolder");
 | 
						|
            }
 | 
						|
        }
 | 
						|
        if (series != null && series.Library.Type != LibraryType.Book)
 | 
						|
        {
 | 
						|
            if (TaskScheduler.HasScanTaskRunningForSeries(series.Id))
 | 
						|
            {
 | 
						|
                _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
 | 
						|
                return;
 | 
						|
            }
 | 
						|
            BackgroundJob.Schedule(() => ScanSeries(series.Id, true), TimeSpan.FromMinutes(1));
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        // This is basically rework of what's already done in Library Watcher but is needed if invoked via API
 | 
						|
        var parentDirectory = _directoryService.GetParentDirectoryName(folder);
 | 
						|
        if (string.IsNullOrEmpty(parentDirectory)) return;
 | 
						|
 | 
						|
        var libraries = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()).ToList();
 | 
						|
        var libraryFolders = libraries.SelectMany(l => l.Folders);
 | 
						|
        var libraryFolder = libraryFolders.Select(Scanner.Parser.Parser.NormalizePath).SingleOrDefault(f => f.Contains(parentDirectory));
 | 
						|
 | 
						|
        if (string.IsNullOrEmpty(libraryFolder)) return;
 | 
						|
 | 
						|
        var library = libraries.FirstOrDefault(l => l.Folders.Select(Scanner.Parser.Parser.NormalizePath).Contains(libraryFolder));
 | 
						|
        if (library != null)
 | 
						|
        {
 | 
						|
            if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id))
 | 
						|
            {
 | 
						|
                _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder);
 | 
						|
                return;
 | 
						|
            }
 | 
						|
            BackgroundJob.Schedule(() => ScanLibrary(library.Id, false), TimeSpan.FromMinutes(1));
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Scans just an existing Series for changes. If the series doesn't exist, will delete it.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <param name="bypassFolderOptimizationChecks">Not Used. Scan series will always force</param>
 | 
						|
    [Queue(TaskScheduler.ScanQueue)]
 | 
						|
    public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true)
 | 
						|
    {
 | 
						|
        var sw = Stopwatch.StartNew();
 | 
						|
        var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
 | 
						|
        var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId);
 | 
						|
        if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update
 | 
						|
        var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
 | 
						|
        var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders);
 | 
						|
        var libraryPaths = library.Folders.Select(f => f.Path).ToList();
 | 
						|
        if (await ShouldScanSeries(seriesId, library, libraryPaths, series, true) != ScanCancelReason.NoCancel)
 | 
						|
        {
 | 
						|
            BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(series.LibraryId, seriesId, false));
 | 
						|
            BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(library.Id, seriesId, false));
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        var folderPath = series.FolderPath;
 | 
						|
        if (string.IsNullOrEmpty(folderPath) || !_directoryService.Exists(folderPath))
 | 
						|
        {
 | 
						|
            // We don't care if it's multiple due to new scan loop enforcing all in one root directory
 | 
						|
            var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(libraryPaths, files.Select(f => f.FilePath).ToList());
 | 
						|
            if (seriesDirs.Keys.Count == 0)
 | 
						|
            {
 | 
						|
                _logger.LogCritical("Scan Series has files spread outside a main series folder. Defaulting to library folder (this is expensive)");
 | 
						|
                await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"{series.Name} is not organized well and scan series will be expensive!", "Scan Series has files spread outside a main series folder. Defaulting to library folder (this is expensive)"));
 | 
						|
                seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(libraryPaths, files.Select(f => f.FilePath).ToList());
 | 
						|
            }
 | 
						|
 | 
						|
            folderPath = seriesDirs.Keys.FirstOrDefault();
 | 
						|
 | 
						|
            // We should check if folderPath is a library folder path and if so, return early and tell user to correct their setup.
 | 
						|
            if (libraryPaths.Contains(folderPath))
 | 
						|
            {
 | 
						|
                _logger.LogCritical("[ScannerSeries] {SeriesName} scan aborted. Files for series are not in a nested folder under library path. Correct this and rescan", series.Name);
 | 
						|
                await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent($"{series.Name} scan aborted", "Files for series are not in a nested folder under library path. Correct this and rescan."));
 | 
						|
                return;
 | 
						|
            }
 | 
						|
 | 
						|
        }
 | 
						|
 | 
						|
        if (string.IsNullOrEmpty(folderPath))
 | 
						|
        {
 | 
						|
            _logger.LogCritical("[ScannerSeries] Scan Series could not find a single, valid folder root for files");
 | 
						|
            await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent($"{series.Name} scan aborted", "Scan Series could not find a single, valid folder root for files"));
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        // If the series path doesn't exist anymore, it was either moved or renamed. We need to essentially delete it
 | 
						|
        var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
 | 
						|
 | 
						|
        await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name));
 | 
						|
 | 
						|
        await _processSeries.Prime();
 | 
						|
        async Task TrackFiles(Tuple<bool, IList<ParserInfo>> parsedInfo)
 | 
						|
        {
 | 
						|
            var parsedFiles = parsedInfo.Item2;
 | 
						|
            if (parsedFiles.Count == 0) return;
 | 
						|
 | 
						|
            var foundParsedSeries = new ParsedSeries()
 | 
						|
            {
 | 
						|
                Name = parsedFiles.First().Series,
 | 
						|
                NormalizedName = Scanner.Parser.Parser.Normalize(parsedFiles.First().Series),
 | 
						|
                Format = parsedFiles.First().Format
 | 
						|
            };
 | 
						|
 | 
						|
            // For Scan Series, we need to filter out anything that isn't our Series
 | 
						|
            if (!foundParsedSeries.NormalizedName.Equals(series.NormalizedName) && !foundParsedSeries.NormalizedName.Equals(Scanner.Parser.Parser.Normalize(series.OriginalName)))
 | 
						|
            {
 | 
						|
                return;
 | 
						|
            }
 | 
						|
 | 
						|
            await _processSeries.ProcessSeriesAsync(parsedFiles, library);
 | 
						|
            parsedSeries.Add(foundParsedSeries, parsedFiles);
 | 
						|
        }
 | 
						|
 | 
						|
        _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name);
 | 
						|
        var scanElapsedTime = await ScanFiles(library, new []{ folderPath }, false, TrackFiles, true);
 | 
						|
        _logger.LogInformation("ScanFiles for {Series} took {Time}", series.Name, scanElapsedTime);
 | 
						|
 | 
						|
        await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name));
 | 
						|
 | 
						|
 | 
						|
 | 
						|
        // Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder
 | 
						|
        RemoveParsedInfosNotForSeries(parsedSeries, series);
 | 
						|
 | 
						|
         // If nothing was found, first validate any of the files still exist. If they don't then we have a deletion and can skip the rest of the logic flow
 | 
						|
         if (parsedSeries.Count == 0)
 | 
						|
         {
 | 
						|
             var seriesFiles = (await _unitOfWork.SeriesRepository.GetFilesForSeries(series.Id));
 | 
						|
             var anyFilesExist = seriesFiles.Where(f => f.FilePath.Contains(series.FolderPath)).Any(m => File.Exists(m.FilePath));
 | 
						|
 | 
						|
             if (!anyFilesExist)
 | 
						|
             {
 | 
						|
                 try
 | 
						|
                 {
 | 
						|
                     _unitOfWork.SeriesRepository.Remove(series);
 | 
						|
                     await CommitAndSend(1, sw, scanElapsedTime, series);
 | 
						|
                     await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved,
 | 
						|
                         MessageFactory.SeriesRemovedEvent(seriesId, string.Empty, series.LibraryId), false);
 | 
						|
                 }
 | 
						|
                 catch (Exception ex)
 | 
						|
                 {
 | 
						|
                     _logger.LogCritical(ex, "There was an error during ScanSeries to delete the series as no files could be found. Aborting scan");
 | 
						|
                     await _unitOfWork.RollbackAsync();
 | 
						|
                     return;
 | 
						|
                 }
 | 
						|
             }
 | 
						|
             else
 | 
						|
             {
 | 
						|
                 // I think we should just fail and tell user to fix their setup. This is extremely expensive for an edge case
 | 
						|
                 _logger.LogCritical("We weren't able to find any files in the series scan, but there should be. Please correct your naming convention or put Series in a dedicated folder. Aborting scan");
 | 
						|
                 await _eventHub.SendMessageAsync(MessageFactory.Error,
 | 
						|
                     MessageFactory.ErrorEvent($"Error scanning {series.Name}", "We weren't able to find any files in the series scan, but there should be. Please correct your naming convention or put Series in a dedicated folder. Aborting scan"));
 | 
						|
                 await _unitOfWork.RollbackAsync();
 | 
						|
                 return;
 | 
						|
             }
 | 
						|
             // At this point, parsedSeries will have at least one key and we can perform the update. If it still doesn't, just return and don't do anything
 | 
						|
             if (parsedSeries.Count == 0) return;
 | 
						|
         }
 | 
						|
 | 
						|
 | 
						|
         await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name));
 | 
						|
        // Tell UI that this series is done
 | 
						|
        await _eventHub.SendMessageAsync(MessageFactory.ScanSeries,
 | 
						|
            MessageFactory.ScanSeriesEvent(library.Id, seriesId, series.Name));
 | 
						|
 | 
						|
        await _metadataService.RemoveAbandonedMetadataKeys();
 | 
						|
        BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
 | 
						|
        BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.TempDirectory));
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<ScanCancelReason> ShouldScanSeries(int seriesId, Library library, IList<string> libraryPaths, Series series, bool bypassFolderChecks = false)
 | 
						|
    {
 | 
						|
        var seriesFolderPaths = (await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId))
 | 
						|
            .Select(f => _directoryService.FileSystem.FileInfo.FromFileName(f.FilePath).Directory.FullName)
 | 
						|
            .Distinct()
 | 
						|
            .ToList();
 | 
						|
 | 
						|
        if (!await CheckMounts(library.Name, seriesFolderPaths))
 | 
						|
        {
 | 
						|
            _logger.LogCritical(
 | 
						|
                "Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
 | 
						|
            return ScanCancelReason.FolderMount;
 | 
						|
        }
 | 
						|
 | 
						|
        if (!await CheckMounts(library.Name, libraryPaths))
 | 
						|
        {
 | 
						|
            _logger.LogCritical(
 | 
						|
                "Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
 | 
						|
            return ScanCancelReason.FolderMount;
 | 
						|
        }
 | 
						|
 | 
						|
        // If all series Folder paths haven't been modified since last scan, abort (NOTE: This flow never happens as ScanSeries will always bypass)
 | 
						|
        if (!bypassFolderChecks)
 | 
						|
        {
 | 
						|
 | 
						|
            var allFolders = seriesFolderPaths.SelectMany(path => _directoryService.GetDirectories(path)).ToList();
 | 
						|
            allFolders.AddRange(seriesFolderPaths);
 | 
						|
 | 
						|
            try
 | 
						|
            {
 | 
						|
                if (allFolders.All(folder => _directoryService.GetLastWriteTime(folder) <= series.LastFolderScanned))
 | 
						|
                {
 | 
						|
                    _logger.LogInformation(
 | 
						|
                        "[ScannerService] {SeriesName} scan has no work to do. All folders have not been changed since last scan",
 | 
						|
                        series.Name);
 | 
						|
                    await _eventHub.SendMessageAsync(MessageFactory.Info,
 | 
						|
                        MessageFactory.InfoEvent($"{series.Name} scan has no work to do",
 | 
						|
                            $"All folders have not been changed since last scan ({series.LastFolderScanned.ToString(CultureInfo.CurrentCulture)}). Scan will be aborted."));
 | 
						|
                    return ScanCancelReason.NoChange;
 | 
						|
                }
 | 
						|
            }
 | 
						|
            catch (IOException ex)
 | 
						|
            {
 | 
						|
                // If there is an exception it means that the folder doesn't exist. So we should delete the series
 | 
						|
                _logger.LogError(ex, "[ScannerService] Scan series for {SeriesName} found the folder path no longer exists",
 | 
						|
                    series.Name);
 | 
						|
                await _eventHub.SendMessageAsync(MessageFactory.Info,
 | 
						|
                    MessageFactory.ErrorEvent($"{series.Name} scan has no work to do",
 | 
						|
                        "The folder the series was in is missing. Delete series manually or perform a library scan."));
 | 
						|
                return ScanCancelReason.NoCancel;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        return ScanCancelReason.NoCancel;
 | 
						|
    }
 | 
						|
 | 
						|
    private static void RemoveParsedInfosNotForSeries(Dictionary<ParsedSeries, IList<ParserInfo>> parsedSeries, Series series)
 | 
						|
    {
 | 
						|
        var keys = parsedSeries.Keys;
 | 
						|
        foreach (var key in keys.Where(key => !SeriesHelper.FindSeries(series, key)))
 | 
						|
        {
 | 
						|
            parsedSeries.Remove(key);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task CommitAndSend(int seriesCount, Stopwatch sw, long scanElapsedTime, Series series)
 | 
						|
    {
 | 
						|
        if (_unitOfWork.HasChanges())
 | 
						|
        {
 | 
						|
            await _unitOfWork.CommitAsync();
 | 
						|
            _logger.LogInformation(
 | 
						|
                "Processed files and {SeriesCount} series in {ElapsedScanTime} milliseconds for {SeriesName}",
 | 
						|
                seriesCount, sw.ElapsedMilliseconds + scanElapsedTime, series.Name);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Ensure that all library folders are mounted. In the case that any are empty or non-existent, emit an event to the UI via EventHub and return false
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryName"></param>
 | 
						|
    /// <param name="folders"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    private async Task<bool> CheckMounts(string libraryName, IList<string> folders)
 | 
						|
    {
 | 
						|
        // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
 | 
						|
        if (folders.Any(f => !_directoryService.IsDriveMounted(f)))
 | 
						|
        {
 | 
						|
            _logger.LogCritical("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName);
 | 
						|
 | 
						|
            await _eventHub.SendMessageAsync(MessageFactory.Error,
 | 
						|
                MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted",
 | 
						|
                    string.Join(", ", folders.Where(f => !_directoryService.IsDriveMounted(f)))));
 | 
						|
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        // For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are
 | 
						|
        if (folders.Any(f => _directoryService.IsDirectoryEmpty(f)))
 | 
						|
        {
 | 
						|
            // That way logging and UI informing is all in one place with full context
 | 
						|
            _logger.LogError("Some of the root folders for the library are empty. " +
 | 
						|
                             "Either your mount has been disconnected or you are trying to delete all series in the library. " +
 | 
						|
                             "Scan has be aborted. " +
 | 
						|
                             "Check that your mount is connected or change the library's root folder and rescan");
 | 
						|
 | 
						|
            await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.",
 | 
						|
                "Either your mount has been disconnected or you are trying to delete all series in the library. " +
 | 
						|
                "Scan has be aborted. " +
 | 
						|
                "Check that your mount is connected or change the library's root folder and rescan"));
 | 
						|
 | 
						|
            return false;
 | 
						|
        }
 | 
						|
 | 
						|
        return true;
 | 
						|
    }
 | 
						|
 | 
						|
    [Queue(TaskScheduler.ScanQueue)]
 | 
						|
    [DisableConcurrentExecution(60 * 60 * 60)]
 | 
						|
    [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
 | 
						|
    public async Task ScanLibraries()
 | 
						|
    {
 | 
						|
        _logger.LogInformation("Starting Scan of All Libraries");
 | 
						|
        foreach (var lib in await _unitOfWork.LibraryRepository.GetLibrariesAsync())
 | 
						|
        {
 | 
						|
            await ScanLibrary(lib.Id);
 | 
						|
        }
 | 
						|
        _logger.LogInformation("Scan of All Libraries Finished");
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Scans a library for file changes.
 | 
						|
    /// Will kick off a scheduled background task to refresh metadata,
 | 
						|
    /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryId"></param>
 | 
						|
    /// <param name="forceUpdate">Defaults to false</param>
 | 
						|
    [Queue(TaskScheduler.ScanQueue)]
 | 
						|
    [DisableConcurrentExecution(60 * 60 * 60)]
 | 
						|
    [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
 | 
						|
    public async Task ScanLibrary(int libraryId, bool forceUpdate = false)
 | 
						|
    {
 | 
						|
        var sw = Stopwatch.StartNew();
 | 
						|
        var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders);
 | 
						|
        var libraryFolderPaths = library.Folders.Select(fp => fp.Path).ToList();
 | 
						|
        if (!await CheckMounts(library.Name, libraryFolderPaths)) return;
 | 
						|
 | 
						|
 | 
						|
        // Validations are done, now we can start actual scan
 | 
						|
        _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name);
 | 
						|
 | 
						|
        // This doesn't work for something like M:/Manga/ and a series has library folder as root
 | 
						|
        var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths));
 | 
						|
        if (!shouldUseLibraryScan)
 | 
						|
        {
 | 
						|
            _logger.LogError("Library {LibraryName} consists of one or more Series folders, using series scan", library.Name);
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        var totalFiles = 0;
 | 
						|
        var seenSeries = new List<ParsedSeries>();
 | 
						|
 | 
						|
 | 
						|
        await _processSeries.Prime();
 | 
						|
        var processTasks = new List<Func<Task>>();
 | 
						|
 | 
						|
        Task TrackFiles(Tuple<bool, IList<ParserInfo>> parsedInfo)
 | 
						|
        {
 | 
						|
            var skippedScan = parsedInfo.Item1;
 | 
						|
            var parsedFiles = parsedInfo.Item2;
 | 
						|
            if (parsedFiles.Count == 0) return Task.CompletedTask;
 | 
						|
 | 
						|
            var foundParsedSeries = new ParsedSeries()
 | 
						|
            {
 | 
						|
                Name = parsedFiles.First().Series,
 | 
						|
                NormalizedName = Scanner.Parser.Parser.Normalize(parsedFiles.First().Series),
 | 
						|
                Format = parsedFiles.First().Format
 | 
						|
            };
 | 
						|
 | 
						|
            if (skippedScan)
 | 
						|
            {
 | 
						|
                seenSeries.AddRange(parsedFiles.Select(pf => new ParsedSeries()
 | 
						|
                {
 | 
						|
                    Name = pf.Series,
 | 
						|
                    NormalizedName = Scanner.Parser.Parser.Normalize(pf.Series),
 | 
						|
                    Format = pf.Format
 | 
						|
                }));
 | 
						|
                return Task.CompletedTask;
 | 
						|
            }
 | 
						|
 | 
						|
            totalFiles += parsedFiles.Count;
 | 
						|
 | 
						|
 | 
						|
            seenSeries.Add(foundParsedSeries);
 | 
						|
            processTasks.Add(async () => await _processSeries.ProcessSeriesAsync(parsedFiles, library, forceUpdate));
 | 
						|
            return Task.CompletedTask;
 | 
						|
        }
 | 
						|
 | 
						|
        var scanElapsedTime = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, TrackFiles, forceUpdate);
 | 
						|
 | 
						|
        foreach (var task in processTasks)
 | 
						|
        {
 | 
						|
            await task();
 | 
						|
        }
 | 
						|
 | 
						|
        await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(string.Empty, library.Name, ProgressEventType.Ended));
 | 
						|
 | 
						|
        _logger.LogInformation("[ScannerService] Finished file scan in {ScanAndUpdateTime} milliseconds. Updating database", scanElapsedTime);
 | 
						|
 | 
						|
        var time = DateTime.Now;
 | 
						|
        foreach (var folderPath in library.Folders)
 | 
						|
        {
 | 
						|
            folderPath.LastScanned = time;
 | 
						|
        }
 | 
						|
 | 
						|
        library.LastScanned = time;
 | 
						|
 | 
						|
 | 
						|
        _unitOfWork.LibraryRepository.Update(library);
 | 
						|
        if (await _unitOfWork.CommitAsync())
 | 
						|
        {
 | 
						|
            if (totalFiles == 0)
 | 
						|
            {
 | 
						|
                _logger.LogInformation(
 | 
						|
                    "[ScannerService] Finished library scan of {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}. There were no changes",
 | 
						|
                    seenSeries.Count, sw.ElapsedMilliseconds, library.Name);
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                _logger.LogInformation(
 | 
						|
                    "[ScannerService] Finished library scan of {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}",
 | 
						|
                    totalFiles, seenSeries.Count, sw.ElapsedMilliseconds, library.Name);
 | 
						|
            }
 | 
						|
 | 
						|
            try
 | 
						|
            {
 | 
						|
                // Could I delete anything in a Library's Series where the LastScan date is before scanStart?
 | 
						|
                // NOTE: This implementation is expensive
 | 
						|
                _logger.LogDebug("[ScannerService] Removing Series that were not found during the scan");
 | 
						|
                var removedSeries = await _unitOfWork.SeriesRepository.RemoveSeriesNotInList(seenSeries, library.Id);
 | 
						|
                _logger.LogDebug("[ScannerService] Found {Count} series that needs to be removed: {SeriesList}",
 | 
						|
                    removedSeries.Count, removedSeries.Select(s => s.Name));
 | 
						|
                _logger.LogDebug("[ScannerService] Removing Series that were not found during the scan - complete");
 | 
						|
 | 
						|
                await _unitOfWork.CommitAsync();
 | 
						|
 | 
						|
                foreach (var s in removedSeries)
 | 
						|
                {
 | 
						|
                    await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved,
 | 
						|
                        MessageFactory.SeriesRemovedEvent(s.Id, s.Name, s.LibraryId), false);
 | 
						|
                }
 | 
						|
            }
 | 
						|
            catch (Exception ex)
 | 
						|
            {
 | 
						|
                _logger.LogCritical(ex, "[ScannerService] There was an issue deleting series for cleanup. Please check logs and rescan");
 | 
						|
            }
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            _logger.LogCritical(
 | 
						|
                "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan");
 | 
						|
        }
 | 
						|
 | 
						|
        await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, string.Empty));
 | 
						|
        await _metadataService.RemoveAbandonedMetadataKeys();
 | 
						|
 | 
						|
        BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.TempDirectory));
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<long> ScanFiles(Library library, IEnumerable<string> dirs,
 | 
						|
        bool isLibraryScan, Func<Tuple<bool, IList<ParserInfo>>, Task> processSeriesInfos = null, bool forceChecks = false)
 | 
						|
    {
 | 
						|
        var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService, _eventHub);
 | 
						|
        var scanWatch = Stopwatch.StartNew();
 | 
						|
 | 
						|
        await scanner.ScanLibrariesForSeries(library.Type, dirs, library.Name,
 | 
						|
            isLibraryScan, await _unitOfWork.SeriesRepository.GetFolderPathMap(library.Id), processSeriesInfos, forceChecks);
 | 
						|
 | 
						|
        var scanElapsedTime = scanWatch.ElapsedMilliseconds;
 | 
						|
 | 
						|
        return scanElapsedTime;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Remove any user progress rows that no longer exist since scan library ran and deleted series/volumes/chapters
 | 
						|
    /// </summary>
 | 
						|
    private async Task CleanupAbandonedChapters()
 | 
						|
    {
 | 
						|
        var cleanedUp = await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
 | 
						|
        _logger.LogInformation("Removed {Count} abandoned progress rows", cleanedUp);
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Cleans up any abandoned rows due to removals from Scan loop
 | 
						|
    /// </summary>
 | 
						|
    private async Task CleanupDbEntities()
 | 
						|
    {
 | 
						|
        await CleanupAbandonedChapters();
 | 
						|
        var cleanedUp = await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
 | 
						|
        _logger.LogInformation("Removed {Count} abandoned collection tags", cleanedUp);
 | 
						|
    }
 | 
						|
 | 
						|
    public static IEnumerable<Series> FindSeriesNotOnDisk(IEnumerable<Series> existingSeries, Dictionary<ParsedSeries, IList<ParserInfo>> parsedSeries)
 | 
						|
    {
 | 
						|
        return existingSeries.Where(es => !ParserInfoHelpers.SeriesHasMatchingParserInfoFormat(es, parsedSeries));
 | 
						|
    }
 | 
						|
 | 
						|
}
 |