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

@ -123,7 +123,7 @@ jobs:
develop:
name: Build Nightly Docker if Develop push
needs: [ build, test, version ]
needs: [ build, version ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
steps:
@ -232,7 +232,7 @@ jobs:
stable:
name: Build Stable Docker if Main push
needs: [ build, test ]
needs: [ build ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
steps:

View File

@ -38,6 +38,7 @@ public class CleanupServiceTests
private const string CacheDirectory = "C:/kavita/config/cache/";
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string LogDirectory = "C:/kavita/config/logs/";
private const string BookmarkDirectory = "C:/kavita/config/bookmarks/";
@ -84,6 +85,9 @@ public class CleanupServiceTests
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
setting.Value = BookmarkDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync();
setting.Value = "10";
_context.ServerSetting.Update(setting);
_context.Library.Add(new Library()
@ -412,6 +416,59 @@ public class CleanupServiceTests
#endregion
#region CleanupLogs
[Fact]
public async Task CleanupLogs_LeaveOneFile_SinceAllAreExpired()
{
var filesystem = CreateFileSystem();
foreach (var i in Enumerable.Range(1, 10))
{
var day = API.Services.Tasks.Scanner.Parser.Parser.PadZeros($"{i}");
filesystem.AddFile($"{LogDirectory}kavita202009{day}.log", new MockFileData("")
{
CreationTime = DateTimeOffset.Now.Subtract(TimeSpan.FromDays(31))
});
}
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
ds);
await cleanupService.CleanupLogs();
Assert.Single(ds.GetFiles(LogDirectory, searchOption: SearchOption.AllDirectories));
}
[Fact]
public async Task CleanupLogs_LeaveLestExpired()
{
var filesystem = CreateFileSystem();
foreach (var i in Enumerable.Range(1, 9))
{
var day = API.Services.Tasks.Scanner.Parser.Parser.PadZeros($"{i}");
filesystem.AddFile($"{LogDirectory}kavita202009{day}.log", new MockFileData("")
{
CreationTime = DateTimeOffset.Now.Subtract(TimeSpan.FromDays(31 - i))
});
}
filesystem.AddFile($"{LogDirectory}kavita20200910.log", new MockFileData("")
{
CreationTime = DateTimeOffset.Now.Subtract(TimeSpan.FromDays(31 - 10))
});
filesystem.AddFile($"{LogDirectory}kavita20200911.log", new MockFileData("")
{
CreationTime = DateTimeOffset.Now.Subtract(TimeSpan.FromDays(31 - 11))
});
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
ds);
await cleanupService.CleanupLogs();
Assert.True(filesystem.File.Exists($"{LogDirectory}kavita20200911.log"));
}
#endregion
// #region CleanupBookmarks
//
// [Fact]

View File

@ -895,6 +895,60 @@ public class ReaderServiceTests
Assert.Equal("1", actualChapter.Range);
}
[Fact]
public async Task GetPrevChapterIdAsync_ShouldGetPrevVolume_2()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("40", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("50", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("60", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("Some Special Title", true, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1997", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2001", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2005", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>(), 1),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
// prevChapter should be id from ch.21 from volume 2001
var prevChapter = await readerService.GetPrevChapterIdAsync(1, 4, 7, 1);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter);
Assert.NotNull(actualChapter);
Assert.Equal("21", actualChapter.Range);
}
[Fact]
public async Task GetPrevChapterIdAsync_ShouldRollIntoPrevVolume()
{

View File

@ -85,19 +85,19 @@ public class SeriesServiceTests
_context.ServerSetting.Update(setting);
var lib = new Library()
{
Name = "Manga", Folders = new List<FolderPath>() {new FolderPath() {Path = "C:/data/"}}
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
lib
}
});
// var lib = new Library()
// {
// Name = "Manga", Folders = new List<FolderPath>() {new FolderPath() {Path = "C:/data/"}}
// };
//
// _context.AppUser.Add(new AppUser()
// {
// UserName = "majora2007",
// Libraries = new List<Library>()
// {
// lib
// }
// });
return await _context.SaveChangesAsync() > 0;
}
@ -109,6 +109,7 @@ public class SeriesServiceTests
_context.Genre.RemoveRange(_context.Genre.ToList());
_context.CollectionTag.RemoveRange(_context.CollectionTag.ToList());
_context.Person.RemoveRange(_context.Person.ToList());
_context.Library.RemoveRange(_context.Library.ToList());
await _context.SaveChangesAsync();
}
@ -135,33 +136,45 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
new AppUser()
{
EntityFactory.CreateChapter("Omake", true, new List<MangaFile>()),
EntityFactory.CreateChapter("Something SP02", true, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
}),
Name = "Test",
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("Omake", true, new List<MangaFile>()),
EntityFactory.CreateChapter("Something SP02", true, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
}),
}
}
}
});
await _context.SaveChangesAsync();
var expectedRanges = new[] {"Omake", "Something SP02"};
@ -177,30 +190,41 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
new AppUser()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>()
{
new Series()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
}),
Name = "Test",
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
}),
}
}
}
});
@ -220,28 +244,39 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
new AppUser()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>()
{
new Series()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
}),
Name = "Test",
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
}),
}
}
}
});
@ -261,28 +296,39 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
new AppUser()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>()
{
new Series()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
}),
Name = "Test",
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
}),
}
}
}
});
@ -305,26 +351,38 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Book,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
EntityFactory.CreateVolume("2", new List<Chapter>()
new AppUser()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
Name = "Test",
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
}
}
}
});
await _context.SaveChangesAsync();
var detail = await _seriesService.GetSeriesDetail(1, 1);
@ -339,26 +397,39 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Book,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
new AppUser()
{
EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", true, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 2.epub", false, new List<MangaFile>()),
}),
Name = "Test",
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", true, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 2.epub", false, new List<MangaFile>()),
}),
}
}
}
});
await _context.SaveChangesAsync();
var detail = await _seriesService.GetSeriesDetail(1, 1);
@ -379,36 +450,48 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Book,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
EntityFactory.CreateVolume("2", new List<Chapter>()
new AppUser()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("1.2", new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>()
{
new Series()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
Name = "Test",
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("1.2", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
}
}
}
});
await _context.SaveChangesAsync();
var detail = await _seriesService.GetSeriesDetail(1, 1);
Assert.Equal("1", detail.Volumes.ElementAt(0).Name);
Assert.Equal("1.2", detail.Volumes.ElementAt(1).Name);
Assert.Equal("2", detail.Volumes.ElementAt(2).Name);
Assert.Equal("Volume 1", detail.Volumes.ElementAt(0).Name);
Assert.Equal("Volume 1.2", detail.Volumes.ElementAt(1).Name);
Assert.Equal("Volume 2", detail.Volumes.ElementAt(2).Name);
}
@ -422,28 +505,34 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
new Volume()
new AppUser()
{
Chapters = new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Volumes = new List<Volume>()
{
new Chapter()
EntityFactory.CreateVolume("1", new List<Chapter>()
{
Pages = 1
}
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
}),
}
}
}
});
await _context.SaveChangesAsync();
@ -470,23 +559,28 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
new Volume()
new AppUser()
{
Chapters = new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Volumes = new List<Volume>()
{
new Chapter()
EntityFactory.CreateVolume("1", new List<Chapter>()
{
Pages = 1
}
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
}),
}
}
}
@ -536,23 +630,28 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
new Volume()
new AppUser()
{
Chapters = new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Volumes = new List<Volume>()
{
new Chapter()
EntityFactory.CreateVolume("1", new List<Chapter>()
{
Pages = 1
}
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
}),
}
}
}
@ -583,23 +682,28 @@ public class SeriesServiceTests
{
await ResetDb();
_context.Series.Add(new Series()
_context.Library.Add(new Library()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
AppUsers = new List<AppUser>()
{
new Volume()
new AppUser()
{
Chapters = new List<Chapter>()
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Manga,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Volumes = new List<Volume>()
{
new Chapter()
EntityFactory.CreateVolume("1", new List<Chapter>()
{
Pages = 1
}
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
}),
}
}
}
@ -626,18 +730,6 @@ public class SeriesServiceTests
#region UpdateSeriesMetadata
private void SetupUpdateSeriesMetadataDb()
{
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Book,
}
});
}
[Fact]
public async Task UpdateSeriesMetadata_ShouldCreateEmptyMetadata_IfDoesntExist()
{

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;
}
}

View File

@ -29,7 +29,7 @@ EXPOSE 5000
WORKDIR /kavita
HEALTHCHECK --interval=30s --timeout=15s --start-period=30s --retries=3 CMD curl --fail http://localhost:5000 || exit 1
HEALTHCHECK --interval=30s --timeout=15s --start-period=30s --retries=3 CMD curl --fail http://localhost:5000/api/health || exit 1
ENTRYPOINT [ "/bin/bash" ]
CMD ["/entrypoint.sh"]

View File

@ -11,12 +11,6 @@ public static class Configuration
{
public static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static string Branch
{
get => GetBranch(GetAppSettingFilename());
set => SetBranch(GetAppSettingFilename(), value);
}
public static int Port
{
get => GetPort(GetAppSettingFilename());
@ -146,42 +140,4 @@ public static class Configuration
}
#endregion
private static string GetBranch(string filePath)
{
const string defaultBranch = "main";
try
{
var json = File.ReadAllText(filePath);
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
const string key = "Branch";
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
{
return tokenElement.GetString();
}
}
catch (Exception ex)
{
Console.WriteLine("Error reading app settings: " + ex.Message);
}
return defaultBranch;
}
private static void SetBranch(string filePath, string updatedBranch)
{
try
{
var currentBranch = GetBranch(filePath);
var json = File.ReadAllText(filePath)
.Replace("\"Branch\": " + currentBranch, "\"Branch\": " + updatedBranch);
File.WriteAllText(filePath, json);
}
catch (Exception)
{
/* Swallow Exception */
}
}
}

View File

@ -12,5 +12,6 @@ export interface ServerSettings {
convertBookmarkToWebP: boolean;
enableSwaggerUi: boolean;
totalBackups: number;
totalLogs: number;
enableFolderWatching: boolean;
}

View File

@ -21,14 +21,14 @@
</div>
<div class="row g-0 mb-2">
<div class="col-md-4 col-sm-12 pe-2">
<div class="col-md-3 col-sm-12 pe-2">
<label for="settings-port" class="form-label">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
<span class="visually-hidden" id="settings-port-help">Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
<input id="settings-port" aria-describedby="settings-port-help" class="form-control" formControlName="port" type="number" step="1" min="1" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</div>
<div class="col-md-4 col-sm-12 pe-2">
<div class="col-md-3 col-sm-12 pe-2">
<label for="backup-tasks" class="form-label">Days of Backups</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="backupTasksTooltip" role="button" tabindex="0"></i>
<ng-template #backupTasksTooltip>The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</ng-template>
<span class="visually-hidden" id="backup-tasks-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
@ -45,8 +45,26 @@
</p>
</ng-container>
</div>
<div class="col-md-3 col-sm-12 pe-2">
<label for="log-tasks" class="form-label">Days of Logs</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="logTasksTooltip" role="button" tabindex="0"></i>
<ng-template #logTasksTooltip>The number of logs to maintain. Default is 30, minumum is 1, maximum is 30.</ng-template>
<span class="visually-hidden" id="log-tasks-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
<input id="log-tasks" aria-describedby="log-tasks-help" class="form-control" formControlName="totalLogs" type="number" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
<ng-container *ngIf="settingsForm.get('totalLogs')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.min">
You must have at least 1 log
</p>
<p class="invalid-feedback" *ngIf="errors.max">
You cannot have more than {{errors.max.max}} logs
</p>
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
<div class="col-md-4 col-sm-12">
<div class="col-md-3 col-sm-12">
<label for="logging-level-port" class="form-label">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space.</ng-template>
<span class="visually-hidden" id="logging-level-port-help">Port the server listens on.</span>

View File

@ -49,6 +49,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required]));
this.settingsForm.addControl('enableSwaggerUi', new FormControl(this.serverSettings.enableSwaggerUi, [Validators.required]));
this.settingsForm.addControl('totalBackups', new FormControl(this.serverSettings.totalBackups, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('totalLogs', new FormControl(this.serverSettings.totalLogs, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('enableFolderWatching', new FormControl(this.serverSettings.enableFolderWatching, [Validators.required]));
this.settingsForm.addControl('convertBookmarkToWebP', new FormControl(this.serverSettings.convertBookmarkToWebP, []));
});
@ -67,6 +68,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl);
this.settingsForm.get('enableSwaggerUi')?.setValue(this.serverSettings.enableSwaggerUi);
this.settingsForm.get('totalBackups')?.setValue(this.serverSettings.totalBackups);
this.settingsForm.get('totalLogs')?.setValue(this.serverSettings.totalLogs);
this.settingsForm.get('enableFolderWatching')?.setValue(this.serverSettings.enableFolderWatching);
this.settingsForm.get('convertBookmarkToWebP')?.setValue(this.serverSettings.convertBookmarkToWebP);
this.settingsForm.markAsPristine();

View File

@ -3,7 +3,7 @@ import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal, NgbNavChangeEvent, NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { catchError, forkJoin, of, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
@ -511,7 +511,11 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
}
});
this.seriesService.getSeriesDetail(this.seriesId).subscribe(detail => {
this.seriesService.getSeriesDetail(this.seriesId).pipe(catchError(err => {
this.router.navigateByUrl('/libraries');
return of(null);
})).subscribe(detail => {
if (detail == null) return;
this.hasSpecials = detail.specials.length > 0;
this.specials = detail.specials;

View File

@ -210,7 +210,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
readerMode: parseInt(modelSettings.readerMode, 10),
layoutMode: parseInt(modelSettings.layoutMode, 10),
showScreenHints: modelSettings.showScreenHints,
backgroundColor: modelSettings.backgroundColor,
backgroundColor: this.user.preferences.backgroundColor,
bookReaderFontFamily: modelSettings.bookReaderFontFamily,
bookReaderLineSpacing: modelSettings.bookReaderLineSpacing,
bookReaderFontSize: modelSettings.bookReaderFontSize,

View File

@ -3,24 +3,7 @@
if [ ! -f "/kavita/config/appsettings.json" ]; then
echo "Kavita configuration file does not exist, creating..."
echo '{
"ConnectionStrings": {
"DefaultConnection": "Data source=config//kavita.db"
},
"TokenKey": "super secret unguessable key",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Information",
"Microsoft.Hosting.Lifetime": "Error",
"Hangfire": "Information",
"Microsoft.AspNetCore.Hosting.Internal.WebHost": "Information"
},
"File": {
"Path": "config//logs/kavita.log",
"Append": "True",
"FileSizeLimitBytes": 26214400,
"MaxRollingFiles": 2
}
},
"Port": 5000
}' >> /kavita/config/appsettings.json
@ -28,4 +11,4 @@ fi
chmod +x Kavita
./Kavita
./Kavita