Misc Polish and Fixes (#1542)

* Moved LibraryWatcher to utilize a queue for calculating the change event to ensure the Watcher doesn't get overwhelmed on large moves.

* Fixed a security vulnerability (https://huntr.dev/bounties/8a3e652f-d6bf-436e-877e-0eaf5c69ef95/). This will be disclosed in Stable release changelog.

* Tweaked the log message template

* Removed some dead code from Configuration json patcher

* Fixed a bug with the ComicInfo finding to properly handle root level.

Fixed a bug where sometimes scanner wouldn't choose the first file with ComicInfo for filling out information.

* Added new setting for managing how many logs files are allowed, just like how backups work.

* Added unit tests for new CleanupLogs code

* Fixed a bug where manga reader background color wasn't actually sending from the UI

* Added new stats for tracking to help understand usage in the app and what features are used or not.

* Fixed Stats url

* Fixed a bug where volumes that had larger than 1 difference wouldn't properly return next/prev chapter (for continuous reader)

* Remove a redundant test step in build pipeline, since it's already done at PR stage.

* Updated dockerfile to use the new Heath check endpoint

* Allow force to pass through to scan loop

* Removed some old config stuff from a safety check on config in entrypoint.sh

* Fixed broken unit tests due to new RBS check and how we setup mock data.
This commit is contained in:
Joseph Milazzo 2022-09-18 12:24:30 -05:00 committed by GitHub
parent c58c7deaf9
commit e89a06865c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 702 additions and 308 deletions

View file

@ -9,7 +9,7 @@ namespace API.Controllers;
public class HealthController : BaseApiController
{
[HttpGet()]
[HttpGet]
public ActionResult GetHealth()
{
return Ok("Ok");

View file

@ -223,6 +223,16 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.TotalLogs && updateSettingsDto.TotalLogs + string.Empty != setting.Value)
{
if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1)
{
return BadRequest("Total Logs must be between 1 and 30");
}
setting.Value = updateSettingsDto.TotalLogs + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value)
{
setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl;

View file

@ -1,4 +1,5 @@
using API.Services;
using System.ComponentModel.DataAnnotations;
using API.Services;
namespace API.DTOs.Settings;
@ -50,7 +51,6 @@ public class ServerSettingDto
/// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT.
/// </summary>
public bool EnableSwaggerUi { get; set; }
/// <summary>
/// The amount of Backups before cleanup
/// </summary>
@ -60,4 +60,9 @@ public class ServerSettingDto
/// If Kavita should watch the library folders and process changes
/// </summary>
public bool EnableFolderWatching { get; set; } = true;
/// <summary>
/// Total number of days worth of logs to keep at a given time.
/// </summary>
/// <remarks>Value should be between 1 and 30</remarks>
public int TotalLogs { get; set; }
}

View file

@ -0,0 +1,15 @@
using API.Entities.Enums;
namespace API.DTOs.Stats;
public class FileFormatDto
{
/// <summary>
/// The extension with the ., in lowercase
/// </summary>
public string Extension { get; set; }
/// <summary>
/// Format of extension
/// </summary>
public MangaFormat Format { get; set; }
}

View file

@ -1,4 +1,6 @@
using API.Entities.Enums;
using System.Collections.Generic;
using API.Entities.Enums;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace API.DTOs.Stats;
@ -118,4 +120,24 @@ public class ServerInfoDto
/// </summary>
/// <remarks>Introduced in v0.5.4</remarks>
public bool UsingSeriesRelationships { get; set; }
/// <summary>
/// A list of background colors set on the instance
/// </summary>
/// <remarks>Introduced in v0.6.0</remarks>
public IEnumerable<string> MangaReaderBackgroundColors { get; set; }
/// <summary>
/// A list of Page Split defaults being used on the instance
/// </summary>
/// <remarks>Introduced in v0.6.0</remarks>
public IEnumerable<PageSplitOption> MangaReaderPageSplittingModes { get; set; }
/// <summary>
/// A list of Layout Mode defaults being used on the instance
/// </summary>
/// <remarks>Introduced in v0.6.0</remarks>
public IEnumerable<LayoutMode> MangaReaderLayoutModes { get; set; }
/// <summary>
/// A list of file formats existing in the instance
/// </summary>
/// <remarks>Introduced in v0.6.0</remarks>
public IEnumerable<FileFormatDto> FileFormats { get; set; }
}

View file

@ -38,6 +38,7 @@ public interface ILibraryRepository
Task<IEnumerable<Library>> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None);
Task<bool> DeleteLibrary(int libraryId);
Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId);
Task<IEnumerable<int>> GetLibraryIdsForUserIdAsync(int userId);
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IEnumerable<int> libraryIds, LibraryIncludes includes = LibraryIncludes.None);
Task<int> GetTotalFiles();
@ -111,6 +112,11 @@ public class LibraryRepository : ILibraryRepository
return await _context.SaveChangesAsync() > 0;
}
/// <summary>
/// This does not track
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<IEnumerable<Library>> GetLibrariesForUserIdAsync(int userId)
{
return await _context.Library
@ -120,6 +126,14 @@ public class LibraryRepository : ILibraryRepository
.ToListAsync();
}
public async Task<IEnumerable<int>> GetLibraryIdsForUserIdAsync(int userId)
{
return await _context.Library
.Where(l => l.AppUsers.Select(ap => ap.Id).Contains(userId))
.Select(l => l.Id)
.ToListAsync();
}
public async Task<LibraryType> GetLibraryTypeAsync(int libraryId)
{
return await _context.Library

View file

@ -103,6 +103,7 @@ public static class Seed
new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"},
new() {Key = ServerSettingKey.EnableSwaggerUi, Value = "false"},
new() {Key = ServerSettingKey.TotalBackups, Value = "30"},
new() {Key = ServerSettingKey.TotalLogs, Value = "30"},
new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"},
}.ToArray());

View file

@ -96,4 +96,9 @@ public enum ServerSettingKey
/// </summary>
[Description("EnableFolderWatching")]
EnableFolderWatching = 17,
/// <summary>
/// Total number of days worth of logs to keep
/// </summary>
[Description("TotalLogs")]
TotalLogs = 18,
}

View file

@ -63,6 +63,9 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
case ServerSettingKey.EnableFolderWatching:
destination.EnableFolderWatching = bool.Parse(row.Value);
break;
case ServerSettingKey.TotalLogs:
destination.TotalLogs = int.Parse(row.Value);
break;
}
}

View file

@ -51,7 +51,7 @@ public static class LogLevelOptions
.WriteTo.File(LogFile,
shared: true,
rollingInterval: RollingInterval.Day,
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {Level}] {Message:lj}{NewLine}{Exception}");
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId}] [{Level}] {Message:lj}{NewLine}{Exception}");
}
public static void SwitchLogLevel(string level)
@ -60,26 +60,31 @@ public static class LogLevelOptions
{
case "Debug":
LogLevelSwitch.MinimumLevel = LogEventLevel.Debug;
MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Information;
MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Debug;
AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Debug;
break;
case "Information":
LogLevelSwitch.MinimumLevel = LogEventLevel.Error;
MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
break;
case "Trace":
LogLevelSwitch.MinimumLevel = LogEventLevel.Verbose;
MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Information;
MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Debug;
AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Debug;
break;
case "Warning":
LogLevelSwitch.MinimumLevel = LogEventLevel.Warning;
MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
break;
case "Critical":
LogLevelSwitch.MinimumLevel = LogEventLevel.Fatal;
MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
break;

View file

@ -332,7 +332,7 @@ public class ArchiveService : IArchiveService
{
var filenameWithoutExtension = Path.GetFileNameWithoutExtension(name).ToLower();
return !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(fullName)
&& fullName.Equals(ComicInfoFilename)
&& (fullName.Equals(ComicInfoFilename) || (string.IsNullOrEmpty(fullName) && name.Equals(ComicInfoFilename)))
&& !filenameWithoutExtension.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith);
}

View file

@ -313,6 +313,7 @@ public class ReaderService : IReaderService
if (chapterId > 0) return chapterId;
}
var next = false;
foreach (var volume in volumes)
{
if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1)
@ -322,10 +323,17 @@ public class ReaderService : IReaderService
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer),
currentChapter.Range, dto => dto.Range);
if (chapterId > 0) return chapterId;
next = true;
continue;
}
if (volume.Number != currentVolume.Number + 1) continue;
if (volume.Number == currentVolume.Number)
{
next = true;
continue;
}
if (!next) continue;
// Handle Chapters within next Volume
// ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+
@ -389,6 +397,7 @@ public class ReaderService : IReaderService
if (chapterId > 0) return chapterId;
}
var next = false;
foreach (var volume in volumes)
{
if (volume.Number == currentVolume.Number)
@ -396,8 +405,10 @@ public class ReaderService : IReaderService
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(),
currentChapter.Range, dto => dto.Range);
if (chapterId > 0) return chapterId;
next = true; // When the diff between volumes is more than 1, we need to explicitly tell that next volume is our use case
continue;
}
if (volume.Number == currentVolume.Number - 1)
if (next)
{
if (currentVolume.Number - 1 == 0) break; // If we have walked all the way to chapter volume, then we should break so logic outside can work
var lastChapter = volume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);

View file

@ -13,6 +13,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.SignalR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Services;
@ -462,6 +463,9 @@ public class SeriesService : ISeriesService
public async Task<SeriesDetailDto> GetSeriesDetail(int seriesId, int userId)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var libraryIds = (await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId));
if (!libraryIds.Contains(series.LibraryId))
throw new UnauthorizedAccessException("User does not have access to the library this series belongs to");
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))

View file

@ -53,6 +53,7 @@ public class TaskScheduler : ITaskScheduler
public const string CleanupTaskId = "cleanup";
public const string BackupTaskId = "backup";
public const string ScanLibrariesTaskId = "scan-libraries";
public const string ReportStatsTaskId = "report-stats";
private static readonly ImmutableArray<string> ScanTasks = ImmutableArray.Create("ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries");
@ -123,7 +124,7 @@ public class TaskScheduler : ITaskScheduler
}
_logger.LogDebug("Scheduling stat collection daily");
RecurringJob.AddOrUpdate("report-stats", () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local);
RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local);
}
public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false)
@ -131,11 +132,14 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanLibrary(libraryId, forceUpdate));
}
/// <summary>
/// Upon cancelling stat, we do report to the Stat service that we are no longer going to be reporting
/// </summary>
public void CancelStatsTasks()
{
_logger.LogDebug("Cancelling/Removing StatsTasks");
RecurringJob.RemoveIfExists("report-stats");
_logger.LogDebug("Stopping Stat collection as user has opted out");
RecurringJob.RemoveIfExists(ReportStatsTaskId);
_statsService.SendCancellation();
}
/// <summary>

View file

@ -25,6 +25,7 @@ public interface ICleanupService
Task DeleteChapterCoverImages();
Task DeleteTagCoverImages();
Task CleanupBackups();
Task CleanupLogs();
void CleanupTemp();
/// <summary>
/// Responsible to remove Series from Want To Read when user's have fully read the series and the series has Publication Status of Completed or Cancelled.
@ -76,6 +77,8 @@ public class CleanupService : ICleanupService
await SendProgress(0.7F, "Cleaning deleted cover images");
await DeleteTagCoverImages();
await DeleteReadingListCoverImages();
await SendProgress(0.8F, "Cleaning old logs");
await CleanupLogs();
await SendProgress(1F, "Cleanup finished");
_logger.LogInformation("Cleanup finished");
}
@ -189,6 +192,29 @@ public class CleanupService : ICleanupService
_logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now);
}
public async Task CleanupLogs()
{
_logger.LogInformation("Performing cleanup of logs directory");
var dayThreshold = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).TotalLogs;
var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(dayThreshold));
var allLogs = _directoryService.GetFiles(_directoryService.LogDirectory).ToList();
var expiredLogs = allLogs.Select(filename => _directoryService.FileSystem.FileInfo.FromFileName(filename))
.Where(f => f.CreationTime < deltaTime)
.ToList();
if (expiredLogs.Count == allLogs.Count)
{
_logger.LogInformation("All expired backups are older than {Threshold} days. Removing all but last backup", dayThreshold);
var toDelete = expiredLogs.OrderBy(f => f.CreationTime).ToList();
_directoryService.DeleteFiles(toDelete.Take(toDelete.Count - 1).Select(f => f.FullName));
}
else
{
_directoryService.DeleteFiles(expiredLogs.Select(f => f.FullName));
}
_logger.LogInformation("Finished cleanup of logs at {Time}", DateTime.Now);
}
public void CleanupTemp()
{
_logger.LogInformation("Performing cleanup of Temp directory");

View file

@ -91,8 +91,11 @@ public class LibraryWatcher : ILibraryWatcher
/// This is just here to prevent GC from Disposing our watchers
/// </summary>
private readonly IList<FileSystemWatcher> _fileWatchers = new List<FileSystemWatcher>();
private IList<string> _libraryFolders = new List<string>();
private static IList<string> _libraryFolders = new List<string>();
/// <summary>
/// The amount of time until the Schedule ScanFolder task should be executed
/// </summary>
/// <remarks>The Job will be enqueued instantly</remarks>
private readonly TimeSpan _queueWaitTime;
@ -109,7 +112,7 @@ public class LibraryWatcher : ILibraryWatcher
public async Task StartWatching()
{
_logger.LogInformation("Starting file watchers");
_logger.LogInformation("[LibraryWatcher] Starting file watchers");
_libraryFolders = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync())
.SelectMany(l => l.Folders)
@ -119,7 +122,7 @@ public class LibraryWatcher : ILibraryWatcher
.ToList();
foreach (var libraryFolder in _libraryFolders)
{
_logger.LogDebug("Watching {FolderPath}", libraryFolder);
_logger.LogDebug("[LibraryWatcher] Watching {FolderPath}", libraryFolder);
var watcher = new FileSystemWatcher(libraryFolder);
watcher.Changed += OnChanged;
@ -138,17 +141,19 @@ public class LibraryWatcher : ILibraryWatcher
_watcherDictionary[libraryFolder].Add(watcher);
}
_logger.LogInformation("[LibraryWatcher] Watching {Count} folders", _fileWatchers.Count);
}
public void StopWatching()
{
_logger.LogInformation("Stopping watching folders");
_logger.LogInformation("[LibraryWatcher] Stopping watching folders");
foreach (var fileSystemWatcher in _watcherDictionary.Values.SelectMany(watcher => watcher))
{
fileSystemWatcher.EnableRaisingEvents = false;
fileSystemWatcher.Changed -= OnChanged;
fileSystemWatcher.Created -= OnCreated;
fileSystemWatcher.Deleted -= OnDeleted;
fileSystemWatcher.Error -= OnError;
fileSystemWatcher.Dispose();
}
_fileWatchers.Clear();
@ -165,13 +170,13 @@ public class LibraryWatcher : ILibraryWatcher
{
if (e.ChangeType != WatcherChangeTypes.Changed) return;
_logger.LogDebug("[LibraryWatcher] Changed: {FullPath}, {Name}", e.FullPath, e.Name);
ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)));
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name))));
}
private void OnCreated(object sender, FileSystemEventArgs e)
{
_logger.LogDebug("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name);
ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name));
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name)));
}
/// <summary>
@ -183,7 +188,7 @@ public class LibraryWatcher : ILibraryWatcher
var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name));
if (!isDirectory) return;
_logger.LogDebug("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name);
ProcessChange(e.FullPath, true);
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, true));
}
@ -198,35 +203,42 @@ public class LibraryWatcher : ILibraryWatcher
/// Processes the file or folder change. If the change is a file change and not from a supported extension, it will be ignored.
/// </summary>
/// <remarks>This will ignore image files that are added to the system. However, they may still trigger scans due to folder changes.</remarks>
/// <remarks>This is public only because Hangfire will invoke it. Do not call external to this class.</remarks>
/// <param name="filePath">File or folder that changed</param>
/// <param name="isDirectoryChange">If the change is on a directory and not a file</param>
private void ProcessChange(string filePath, bool isDirectoryChange = false)
public void ProcessChange(string filePath, bool isDirectoryChange = false)
{
var sw = Stopwatch.StartNew();
_logger.LogDebug("[LibraryWatcher] Processing change of {FilePath}", filePath);
try
{
// We need to check if directory or not
// If not a directory change AND file is not an archive or book, ignore
if (!isDirectoryChange &&
!(Parser.Parser.IsArchive(filePath) || Parser.Parser.IsBook(filePath))) return;
!(Parser.Parser.IsArchive(filePath) || Parser.Parser.IsBook(filePath)))
{
_logger.LogDebug("[LibraryWatcher] Change from {FilePath} is not an archive or book, ignoring change", filePath);
return;
}
var parentDirectory = _directoryService.GetParentDirectoryName(filePath);
if (string.IsNullOrEmpty(parentDirectory)) return;
// var libraryFolders = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync())
// .SelectMany(l => l.Folders)
// .Distinct()
// .Select(Parser.Parser.NormalizePath)
// .Where(_directoryService.Exists)
// .ToList();
// We need to find the library this creation belongs to
// Multiple libraries can point to the same base folder. In this case, we need use FirstOrDefault
var libraryFolder = _libraryFolders.FirstOrDefault(f => parentDirectory.Contains(f));
if (string.IsNullOrEmpty(libraryFolder)) return;
var fullPath = GetFolder(filePath, _libraryFolders);
if (string.IsNullOrEmpty(fullPath))
{
_logger.LogDebug("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath);
return;
}
var rootFolder = _directoryService.GetFoldersTillRoot(libraryFolder, filePath).ToList();
if (!rootFolder.Any()) return;
// Select the first folder and join with library folder, this should give us the folder to scan.
var fullPath =
Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder.First()));
// Check if this task has already enqueued or is being processed, before enquing
var alreadyScheduled =
TaskScheduler.HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", new object[] {fullPath});
_logger.LogDebug("{FullPath} already enqueued: {Value}", fullPath, alreadyScheduled);
_logger.LogDebug("[LibraryWatcher] {FullPath} already enqueued: {Value}", fullPath, alreadyScheduled);
if (!alreadyScheduled)
{
_logger.LogDebug("[LibraryWatcher] Scheduling ScanFolder for {Folder}", fullPath);
@ -242,9 +254,27 @@ public class LibraryWatcher : ILibraryWatcher
{
_logger.LogError(ex, "[LibraryWatcher] An error occured when processing a watch event");
}
_logger.LogDebug("ProcessChange occured in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
_logger.LogDebug("[LibraryWatcher] ProcessChange ran in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
}
private string GetFolder(string filePath, IList<string> libraryFolders)
{
var parentDirectory = _directoryService.GetParentDirectoryName(filePath);
if (string.IsNullOrEmpty(parentDirectory))
{
return string.Empty;
}
if (string.IsNullOrEmpty(parentDirectory)) return string.Empty;
// We need to find the library this creation belongs to
// Multiple libraries can point to the same base folder. In this case, we need use FirstOrDefault
var libraryFolder = libraryFolders.FirstOrDefault(f => parentDirectory.Contains(f));
if (string.IsNullOrEmpty(libraryFolder)) return string.Empty;
var rootFolder = _directoryService.GetFoldersTillRoot(libraryFolder, filePath).ToList();
if (!rootFolder.Any()) return string.Empty;
// Select the first folder and join with library folder, this should give us the folder to scan.
return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder.First()));
}
}

View file

@ -116,7 +116,8 @@ public class ProcessSeries : IProcessSeries
{
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
var firstParsedInfo = parsedInfos[0];
// parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort)
var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, parsedInfos[0]);
UpdateVolumes(series, parsedInfos);
series.Pages = series.Volumes.Sum(v => v.Pages);
@ -479,10 +480,10 @@ public class ProcessSeries : IProcessSeries
var deletedVolumes = series.Volumes.Except(nonDeletedVolumes);
foreach (var volume in deletedVolumes)
{
var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? "";
var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? string.Empty;
if (!string.IsNullOrEmpty(file) && _directoryService.FileSystem.File.Exists(file))
{
_logger.LogError(
_logger.LogInformation(
"[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk. File: {File}",
file);
}

View file

@ -458,7 +458,7 @@ public class ScannerService : IScannerService
}
var scanElapsedTime = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, TrackFiles);
var scanElapsedTime = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, TrackFiles, forceUpdate);
await Task.WhenAll(processTasks);

View file

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
@ -21,6 +23,7 @@ public interface IStatsService
{
Task Send();
Task<ServerInfoDto> GetServerInfo();
Task SendCancellation();
}
public class StatsService : IStatsService
{
@ -127,6 +130,10 @@ public class StatsService : IStatsService
MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(),
MaxVolumesInASeries = await MaxVolumesInASeries(),
MaxChaptersInASeries = await MaxChaptersInASeries(),
MangaReaderBackgroundColors = await AllMangaReaderBackgroundColors(),
MangaReaderPageSplittingModes = await AllMangaReaderPageSplitting(),
MangaReaderLayoutModes = await AllMangaReaderLayoutModes(),
FileFormats = AllFormats(),
};
var usersWithPref = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences)).ToList();
@ -149,6 +156,39 @@ public class StatsService : IStatsService
return serverInfo;
}
public async Task SendCancellation()
{
_logger.LogInformation("Informing KavitaStats that this instance is no longer sending stats");
var installId = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).InstallId;
var responseContent = string.Empty;
try
{
var response = await (ApiUrl + "/api/v2/stats/opt-out?installId=" + installId)
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(30))
.PostAsync();
if (response.StatusCode != StatusCodes.Status200OK)
{
_logger.LogError("KavitaStats did not respond successfully. {Content}", response);
}
}
catch (HttpRequestException e)
{
_logger.LogError(e, "KavitaStats did not respond successfully. {Response}", responseContent);
}
catch (Exception e)
{
_logger.LogError(e, "An error happened during the request to KavitaStats");
}
}
private Task<bool> GetIfUsingSeriesRelationship()
{
return _context.SeriesRelation.AnyAsync();
@ -190,4 +230,35 @@ public class StatsService : IStatsService
.SelectMany(v => v.Chapters)
.Count());
}
private async Task<IEnumerable<string>> AllMangaReaderBackgroundColors()
{
return await _context.AppUserPreferences.Select(p => p.BackgroundColor).Distinct().ToListAsync();
}
private async Task<IEnumerable<PageSplitOption>> AllMangaReaderPageSplitting()
{
return await _context.AppUserPreferences.Select(p => p.PageSplitOption).Distinct().ToListAsync();
}
private async Task<IEnumerable<LayoutMode>> AllMangaReaderLayoutModes()
{
return await _context.AppUserPreferences.Select(p => p.LayoutMode).Distinct().ToListAsync();
}
private IEnumerable<FileFormatDto> AllFormats()
{
var results = _context.MangaFile
.AsNoTracking()
.AsEnumerable()
.Select(m => new FileFormatDto()
{
Format = m.Format,
Extension = Path.GetExtension(m.FilePath)?.ToLowerInvariant()
})
.DistinctBy(f => f.Extension)
.ToList();
return results;
}
}