diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 01de1fb82..1fe2d72e8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,6 +7,10 @@ assignees: '' --- +**If this is a feature request, request [here](https://feats.kavitareader.com/) instead. Feature requests will be deleted from Github.** + + + **Describe the bug** A clear and concise description of what the bug is. diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml index 7d1bca8db..48a93318a 100644 --- a/.github/workflows/sonar-scan.yml +++ b/.github/workflows/sonar-scan.yml @@ -147,6 +147,8 @@ jobs: body=${body//'%'/'%25'} body=${body//$'\n'/'%0A'} body=${body//$'\r'/'%0D'} + body=${body//$'`'/'%60'} + body=${body//$'>'/'%3E'} echo $body echo "::set-output name=BODY::$body" @@ -249,6 +251,8 @@ jobs: body=${body//'%'/'%25'} body=${body//$'\n'/'%0A'} body=${body//$'\r'/'%0D'} + body=${body//$'`'/'%60'} + body=${body//$'>'/'%3E'} echo $body echo "::set-output name=BODY::$body" diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index 8ae63530b..043c0e027 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -58,6 +58,11 @@ namespace API.Tests.Parser [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "2000 AD")] [InlineData("Daredevil - v6 - 10 - (2019)", "Daredevil")] [InlineData("Batman - The Man Who Laughs #1 (2005)", "Batman - The Man Who Laughs")] + [InlineData("Demon 012 (Sep 1973) c2c", "Demon")] + [InlineData("Dragon Age - Until We Sleep 01 (of 03)", "Dragon Age - Until We Sleep")] + [InlineData("Green Lantern v2 017 - The Spy-Eye that doomed Green Lantern v2", "Green Lantern")] + [InlineData("Green Lantern - Circle of Fire Special - Adam Strange (2000)", "Green Lantern - Circle of Fire - Adam Strange")] + [InlineData("Identity Crisis Extra - Rags Morales Sketches (2005)", "Identity Crisis - Rags Morales Sketches")] public void ParseComicSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename)); @@ -138,6 +143,7 @@ namespace API.Tests.Parser [InlineData("Cyberpunk 2077 - Trauma Team #04.cbz", "4")] [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "366")] [InlineData("Daredevil - v6 - 10 - (2019)", "10")] + [InlineData("Batman Beyond 2016 - Chapter 001.cbz", "1")] public void ParseComicChapterTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename)); diff --git a/API/API.csproj b/API/API.csproj index 137a3a985..d6f059830 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -107,6 +107,11 @@ + + + + + @@ -115,12 +120,18 @@ - + + + + + + Always + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 86b1ac778..3c9960402 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -95,7 +95,7 @@ namespace API.Controllers var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); if (!settings.EnableAuthentication && !registerDto.IsAdmin) { - _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password.", registerDto.Username); + _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password", registerDto.Username); registerDto.Password = AccountService.DefaultPassword; } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 34f0d3132..49a70d90d 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -17,6 +17,7 @@ using API.Interfaces.Services; using API.Services; using Kavita.Common; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace API.Controllers { @@ -48,7 +49,6 @@ namespace API.Controllers _cacheService = cacheService; _readerService = readerService; - _xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); @@ -62,18 +62,18 @@ namespace API.Controllers if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); var feed = CreateFeed("Kavita", string.Empty, apiKey); - feed.Id = "root"; + SetFeedId(feed, "root"); feed.Entries.Add(new FeedEntry() { - Id = "inProgress", - Title = "In Progress", + Id = "onDeck", + Title = "On Deck", Content = new FeedEntryContent() { - Text = "Browse by In Progress" + Text = "Browse by On Deck" }, Links = new List() { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/in-progress"), + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/on-deck"), } }); feed.Entries.Add(new FeedEntry() @@ -140,9 +140,8 @@ namespace API.Controllers return BadRequest("OPDS is not enabled on this server"); var userId = await GetUser(apiKey); var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId); - var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey); - + SetFeedId(feed, "libraries"); foreach (var library in libraries) { feed.Entries.Add(new FeedEntry() @@ -181,7 +180,7 @@ namespace API.Controllers var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey); - + SetFeedId(feed, "collections"); foreach (var tag in tags) { feed.Entries.Add(new FeedEntry() @@ -198,14 +197,6 @@ namespace API.Controllers }); } - if (tags.Count == 0) - { - feed.Entries.Add(new FeedEntry() - { - Title = "Nothing here", - }); - } - return CreateXmlResult(SerializeXml(feed)); } @@ -243,6 +234,7 @@ namespace API.Controllers }); var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey); + SetFeedId(feed, $"collections-{collectionId}"); AddPagination(feed, series, $"{Prefix}{apiKey}/collections/{collectionId}"); foreach (var seriesDto in series) @@ -269,7 +261,7 @@ namespace API.Controllers var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey); - + SetFeedId(feed, "reading-list"); foreach (var readingListDto in readingLists) { feed.Entries.Add(new FeedEntry() @@ -304,6 +296,7 @@ namespace API.Controllers } var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey); + SetFeedId(feed, $"reading-list-{readingListId}"); var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList(); foreach (var item in items) @@ -320,16 +313,6 @@ namespace API.Controllers }); } - if (items.Count == 0) - { - feed.Entries.Add(new FeedEntry() - { - Title = "Nothing here", - }); - } - - - return CreateXmlResult(SerializeXml(feed)); } @@ -355,6 +338,7 @@ namespace API.Controllers }, _filterDto); var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey); + SetFeedId(feed, $"library-{library.Name}"); AddPagination(feed, series, $"{Prefix}{apiKey}/libraries/{libraryId}"); foreach (var seriesDto in series) @@ -379,6 +363,7 @@ namespace API.Controllers }, _filterDto); var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey); + SetFeedId(feed, "recently-added"); AddPagination(feed, recentlyAdded, $"{Prefix}{apiKey}/recently-added"); foreach (var seriesDto in recentlyAdded) @@ -386,21 +371,12 @@ namespace API.Controllers feed.Entries.Add(CreateSeries(seriesDto, apiKey)); } - if (recentlyAdded.Count == 0) - { - feed.Entries.Add(new FeedEntry() - { - Title = "Nothing here", - }); - } - - return CreateXmlResult(SerializeXml(feed)); } - [HttpGet("{apiKey}/in-progress")] + [HttpGet("{apiKey}/on-deck")] [Produces("application/xml")] - public async Task GetInProgress(string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1) { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); @@ -410,29 +386,22 @@ namespace API.Controllers PageNumber = pageNumber, PageSize = 20 }; - var results = await _unitOfWork.SeriesRepository.GetInProgress(userId, 0, userParams, _filterDto); + var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto); var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize) .Take(userParams.PageSize).ToList(); var pagedList = new PagedList(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize); Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); - var feed = CreateFeed("In Progress", $"{apiKey}/in-progress", apiKey); - AddPagination(feed, pagedList, $"{Prefix}{apiKey}/in-progress"); + var feed = CreateFeed("On Deck", $"{apiKey}/on-deck", apiKey); + SetFeedId(feed, "on-deck"); + AddPagination(feed, pagedList, $"{Prefix}{apiKey}/on-deck"); foreach (var seriesDto in pagedList) { feed.Entries.Add(CreateSeries(seriesDto, apiKey)); } - if (pagedList.Count == 0) - { - feed.Entries.Add(new FeedEntry() - { - Title = "Nothing here", - }); - } - return CreateXmlResult(SerializeXml(feed)); } @@ -456,7 +425,7 @@ namespace API.Controllers var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query); var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey); - + SetFeedId(feed, "search-series"); foreach (var seriesDto in series) { feed.Entries.Add(CreateSeries(seriesDto, apiKey)); @@ -465,6 +434,11 @@ namespace API.Controllers return CreateXmlResult(SerializeXml(feed)); } + private static void SetFeedId(Feed feed, string id) + { + feed.Id = id; + } + [HttpGet("{apiKey}/search")] [Produces("application/xml")] public async Task GetSearchDescriptor(string apiKey) @@ -498,6 +472,7 @@ namespace API.Controllers var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId); var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey); + SetFeedId(feed, $"series-{series.Id}"); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}")); foreach (var volumeDto in volumes) { @@ -521,6 +496,7 @@ namespace API.Controllers _chapterSortComparer); var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey); + SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapters"); foreach (var chapter in chapters) { feed.Entries.Add(new FeedEntry() @@ -551,6 +527,7 @@ namespace API.Controllers var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey); + SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapter-{chapter.Id}-files"); foreach (var mangaFile in files) { feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey)); diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index e2072955b..ba0571ec3 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -180,7 +180,7 @@ namespace API.Controllers if (series == null) return BadRequest("Series does not exist"); - if (series.Name != updateSeries.Name && await _unitOfWork.SeriesRepository.DoesSeriesNameExistInLibrary(updateSeries.Name)) + if (series.Name != updateSeries.Name && await _unitOfWork.SeriesRepository.DoesSeriesNameExistInLibrary(updateSeries.Name, series.Format)) { return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library."); } @@ -230,12 +230,19 @@ namespace API.Controllers return Ok(series); } - [HttpPost("in-progress")] - public async Task>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + /// + /// Fetches series that are on deck aka have progress on them. + /// + /// + /// + /// Default of 0 meaning all libraries + /// + [HttpPost("on-deck")] + public async Task>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { // NOTE: This has to be done manually like this due to the DistinctBy requirement var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var results = await _unitOfWork.SeriesRepository.GetInProgress(userId, libraryId, userParams, filterDto); + var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto); var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize) .Take(userParams.PageSize).ToList(); diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 104d00310..04ffa3428 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -26,10 +26,11 @@ namespace API.Controllers private readonly IArchiveService _archiveService; private readonly ICacheService _cacheService; private readonly IVersionUpdaterService _versionUpdaterService; + private readonly IStatsService _statsService; public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, IConfiguration config, IBackupService backupService, IArchiveService archiveService, ICacheService cacheService, - IVersionUpdaterService versionUpdaterService) + IVersionUpdaterService versionUpdaterService, IStatsService statsService) { _applicationLifetime = applicationLifetime; _logger = logger; @@ -38,6 +39,7 @@ namespace API.Controllers _archiveService = archiveService; _cacheService = cacheService; _versionUpdaterService = versionUpdaterService; + _statsService = statsService; } /// @@ -84,9 +86,9 @@ namespace API.Controllers /// /// [HttpGet("server-info")] - public ActionResult GetVersion() + public async Task> GetVersion() { - return Ok(StatsService.GetServerInfo()); + return Ok(await _statsService.GetServerInfo()); } [HttpGet("logs")] diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs deleted file mode 100644 index 0ce9bebed..000000000 --- a/API/Controllers/StatsController.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Threading.Tasks; -using API.DTOs.Stats; -using API.Interfaces.Services; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace API.Controllers -{ - public class StatsController : BaseApiController - { - private readonly ILogger _logger; - private readonly IStatsService _statsService; - - public StatsController(ILogger logger, IStatsService statsService) - { - _logger = logger; - _statsService = statsService; - } - - [AllowAnonymous] - [HttpPost("client-info")] - public async Task AddClientInfo([FromBody] ClientInfoDto clientInfoDto) - { - try - { - await _statsService.RecordClientInfo(clientInfoDto); - - return Ok(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating the usage statistics"); - throw; - } - } - } -} diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index d7a4beb64..8c791a395 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace API.DTOs { @@ -45,5 +46,9 @@ namespace API.DTOs /// Volume Id this Chapter belongs to /// public int VolumeId { get; init; } + /// + /// When chapter was created + /// + public DateTime Created { get; init; } } } diff --git a/API/DTOs/Stats/ClientInfoDto.cs b/API/DTOs/Stats/ClientInfoDto.cs deleted file mode 100644 index f2be3b41f..000000000 --- a/API/DTOs/Stats/ClientInfoDto.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; - -namespace API.DTOs.Stats -{ - public class ClientInfoDto - { - public ClientInfoDto() - { - CollectedAt = DateTime.UtcNow; - } - - public string KavitaUiVersion { get; set; } - public string ScreenResolution { get; set; } - public string PlatformType { get; set; } - public DetailsVersion Browser { get; set; } - public DetailsVersion Os { get; set; } - - public DateTime? CollectedAt { get; set; } - public bool UsingDarkTheme { get; set; } - - public bool IsTheSameDevice(ClientInfoDto clientInfoDto) - { - return (clientInfoDto.ScreenResolution ?? string.Empty).Equals(ScreenResolution) && - (clientInfoDto.PlatformType ?? string.Empty).Equals(PlatformType) && - (clientInfoDto.Browser?.Name ?? string.Empty).Equals(Browser?.Name) && - (clientInfoDto.Os?.Name ?? string.Empty).Equals(Os?.Name) && - clientInfoDto.CollectedAt.GetValueOrDefault().ToString("yyyy-MM-dd") - .Equals(CollectedAt.GetValueOrDefault().ToString("yyyy-MM-dd")); - } - } - - public class DetailsVersion - { - public string Name { get; set; } - public string Version { get; set; } - } -} diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs index 2aecebecc..46a8c9ae1 100644 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ b/API/DTOs/Stats/ServerInfoDto.cs @@ -2,13 +2,11 @@ { public class ServerInfoDto { + public string InstallId { get; set; } public string Os { get; set; } - public string DotNetVersion { get; set; } - public string RunTimeVersion { get; set; } - public string KavitaVersion { get; set; } - public string BuildBranch { get; set; } - public string Culture { get; set; } public bool IsDocker { get; set; } + public string DotnetVersion { get; set; } + public string KavitaVersion { get; set; } public int NumOfCores { get; set; } } } diff --git a/API/DTOs/Stats/UsageInfoDto.cs b/API/DTOs/Stats/UsageInfoDto.cs deleted file mode 100644 index 64aaeefee..000000000 --- a/API/DTOs/Stats/UsageInfoDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using API.Entities.Enums; - -namespace API.DTOs.Stats -{ - public class UsageInfoDto - { - public UsageInfoDto() - { - FileTypes = new HashSet(); - LibraryTypesCreated = new HashSet(); - } - - public int UsersCount { get; set; } - public IEnumerable FileTypes { get; set; } - public IEnumerable LibraryTypesCreated { get; set; } - } - - public class LibInfo - { - public LibraryType Type { get; set; } - public int Count { get; set; } - } -} diff --git a/API/DTOs/Stats/UsageStatisticsDto.cs b/API/DTOs/Stats/UsageStatisticsDto.cs deleted file mode 100644 index 08e15dc3b..000000000 --- a/API/DTOs/Stats/UsageStatisticsDto.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace API.DTOs.Stats -{ - public class UsageStatisticsDto - { - public UsageStatisticsDto() - { - MarkAsUpdatedNow(); - ClientsInfo = new List(); - } - - public string InstallId { get; set; } - public DateTime LastUpdate { get; set; } - public UsageInfoDto UsageInfo { get; set; } - public ServerInfoDto ServerInfo { get; set; } - public List ClientsInfo { get; set; } - - public void MarkAsUpdatedNow() - { - LastUpdate = DateTime.UtcNow; - } - - public void AddClientInfo(ClientInfoDto clientInfoDto) - { - if (ClientsInfo.Any(x => x.IsTheSameDevice(clientInfoDto))) return; - - ClientsInfo.Add(clientInfoDto); - } - } -} diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 7a16fa18e..03c56c567 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs.Update +using System; + +namespace API.DTOs.Update { /// /// Update Notification denoting a new release available for user to update to @@ -34,5 +36,9 @@ /// Is this a pre-release /// public bool IsPrerelease { get; init; } + /// + /// Date of the publish + /// + public string PublishDate { get; init; } } } diff --git a/API/Data/MigrateConfigFiles.cs b/API/Data/MigrateConfigFiles.cs index 2436e3820..752b03192 100644 --- a/API/Data/MigrateConfigFiles.cs +++ b/API/Data/MigrateConfigFiles.cs @@ -26,8 +26,6 @@ namespace API.Data "temp" }; - private static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config"); - /// /// In v0.4.8 we moved all config files to config/ to match with how docker was setup. This will move all config files from current directory @@ -66,8 +64,8 @@ namespace API.Data Console.WriteLine( "Migrating files from pre-v0.4.8. All Kavita config files are now located in config/"); - Console.WriteLine($"Creating {ConfigDirectory}"); - DirectoryService.ExistOrCreate(ConfigDirectory); + Console.WriteLine($"Creating {DirectoryService.ConfigDirectory}"); + DirectoryService.ExistOrCreate(DirectoryService.ConfigDirectory); try { @@ -116,13 +114,13 @@ namespace API.Data foreach (var folderToMove in AppFolders) { - if (new DirectoryInfo(Path.Join(ConfigDirectory, folderToMove)).Exists) continue; + if (new DirectoryInfo(Path.Join(DirectoryService.ConfigDirectory, folderToMove)).Exists) continue; try { DirectoryService.CopyDirectoryToDirectory( Path.Join(Directory.GetCurrentDirectory(), folderToMove), - Path.Join(ConfigDirectory, folderToMove)); + Path.Join(DirectoryService.ConfigDirectory, folderToMove)); } catch (Exception) { @@ -144,7 +142,7 @@ namespace API.Data { try { - fileInfo.CopyTo(Path.Join(ConfigDirectory, fileInfo.Name)); + fileInfo.CopyTo(Path.Join(DirectoryService.ConfigDirectory, fileInfo.Name)); } catch (Exception) { diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index bed73e2f5..baa55330f 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -7,6 +7,7 @@ using API.DTOs; using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Interfaces.Repositories; @@ -47,16 +48,22 @@ namespace API.Data.Repositories _context.Series.RemoveRange(series); } - public async Task DoesSeriesNameExistInLibrary(string name) + /// + /// Returns if a series name and format exists already in a library + /// + /// Name of series + /// Format of series + /// + public async Task DoesSeriesNameExistInLibrary(string name, MangaFormat format) { var libraries = _context.Series .AsNoTracking() - .Where(x => x.Name == name) + .Where(x => x.Name.Equals(name) && x.Format == format) .Select(s => s.LibraryId); return await _context.Series .AsNoTracking() - .Where(s => libraries.Contains(s.LibraryId) && s.Name == name) + .Where(s => libraries.Contains(s.LibraryId) && s.Name.Equals(name) && s.Format == format) .CountAsync() > 1; } @@ -312,14 +319,15 @@ namespace API.Data.Repositories } /// - /// Returns Series that the user has some partial progress on + /// Returns Series that the user has some partial progress on. Sorts based on activity. Sort first by User progress, but if a series + /// has been updated recently, bump it to the front. /// /// /// Library to restrict to, if 0, will apply to all libraries /// Pagination information /// Optional (default null) filter on query /// - public async Task> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter) + public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter) { var formats = filter.GetSqlFilter(); IList userLibraries; @@ -352,6 +360,7 @@ namespace API.Data.Repositories && s.PagesRead > 0 && s.PagesRead < s.Series.Pages) .OrderByDescending(s => s.LastModified) + .ThenByDescending(s => s.Series.LastModified) .Select(s => s.Series) .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index ae7c9e818..9cfbaeaa4 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -41,7 +41,7 @@ namespace API.Data IList defaultSettings = new List() { - new() {Key = ServerSettingKey.CacheDirectory, Value = DirectoryService.CacheDirectory}, + new () {Key = ServerSettingKey.CacheDirectory, Value = DirectoryService.CacheDirectory}, new () {Key = ServerSettingKey.TaskScan, Value = "daily"}, new () {Key = ServerSettingKey.LoggingLevel, Value = "Information"}, // Not used from DB, but DB is sync with appSettings.json new () {Key = ServerSettingKey.TaskBackup, Value = "weekly"}, @@ -51,6 +51,7 @@ namespace API.Data new () {Key = ServerSettingKey.EnableOpds, Value = "false"}, new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, new () {Key = ServerSettingKey.BaseUrl, Value = "/"}, + new () {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, }; foreach (var defaultSetting in defaultSettings) @@ -71,6 +72,8 @@ namespace API.Data Configuration.LogLevel + string.Empty; context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value = DirectoryService.CacheDirectory + string.Empty; + context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = + DirectoryService.BackupDirectory + string.Empty; await context.SaveChangesAsync(); diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index e78c4b015..01587431b 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -16,7 +16,7 @@ namespace API.Entities /// /// Manga Reader Option: Which side of a split image should we show first /// - public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.SplitRightToLeft; + public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.FitSplit; /// /// Manga Reader Option: How the manga reader should perform paging or reading of the file /// @@ -25,14 +25,15 @@ namespace API.Entities /// /// public ReaderMode ReaderMode { get; set; } + /// /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction /// - public bool AutoCloseMenu { get; set; } + public bool AutoCloseMenu { get; set; } = true; /// /// Book Reader Option: Should the background color be dark /// - public bool BookReaderDarkMode { get; set; } = false; + public bool BookReaderDarkMode { get; set; } = true; /// /// Book Reader Option: Override extra Margin /// @@ -62,10 +63,10 @@ namespace API.Entities /// UI Site Global Setting: Whether the UI should render in Dark mode or not. /// public bool SiteDarkMode { get; set; } = true; - - - + + + public AppUser AppUser { get; set; } public int AppUserId { get; set; } } -} \ No newline at end of file +} diff --git a/API/Entities/Enums/PageSplitOption.cs b/API/Entities/Enums/PageSplitOption.cs index ae44530c7..5234a4cce 100644 --- a/API/Entities/Enums/PageSplitOption.cs +++ b/API/Entities/Enums/PageSplitOption.cs @@ -4,6 +4,7 @@ { SplitLeftToRight = 0, SplitRightToLeft = 1, - NoSplit = 2 + NoSplit = 2, + FitSplit = 3 } -} \ No newline at end of file +} diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 997d0a33e..3f097c675 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -4,26 +4,61 @@ namespace API.Entities.Enums { public enum ServerSettingKey { + /// + /// Cron format for how often full library scans are performed. + /// [Description("TaskScan")] TaskScan = 0, + /// + /// Where files are cached. Not currently used. + /// [Description("CacheDirectory")] CacheDirectory = 1, + /// + /// Cron format for how often backups are taken. + /// [Description("TaskBackup")] TaskBackup = 2, + /// + /// Logging level for Server. Not managed in DB. Managed in appsettings.json and synced to DB. + /// [Description("LoggingLevel")] LoggingLevel = 3, + /// + /// Port server listens on. Not managed in DB. Managed in appsettings.json and synced to DB. + /// [Description("Port")] Port = 4, + /// + /// Where the backups are stored. + /// [Description("BackupDirectory")] BackupDirectory = 5, + /// + /// Allow anonymous data to be reported to KavitaStats + /// [Description("AllowStatCollection")] AllowStatCollection = 6, + /// + /// Is OPDS enabled for the server + /// [Description("EnableOpds")] EnableOpds = 7, + /// + /// Is Authentication needed for non-admin accounts + /// [Description("EnableAuthentication")] EnableAuthentication = 8, + /// + /// Base Url for the server. Not Implemented. + /// [Description("BaseUrl")] - BaseUrl = 9 + BaseUrl = 9, + /// + /// Represents this installation of Kavita. Is tied to Stat reporting but has no information about user or files. + /// + [Description("InstallId")] + InstallId = 10 } } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 5b7bc86bd..de02ad427 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -23,7 +23,7 @@ namespace API.Entities /// public string SortName { get; set; } /// - /// Name in Japanese. By default, will be same as Name. + /// Name in original language (Japanese for Manga). By default, will be same as Name. /// public string LocalizedName { get; set; } /// diff --git a/API/Interfaces/ITaskScheduler.cs b/API/Interfaces/ITaskScheduler.cs index 08a450ac2..215cccf80 100644 --- a/API/Interfaces/ITaskScheduler.cs +++ b/API/Interfaces/ITaskScheduler.cs @@ -17,6 +17,6 @@ namespace API.Interfaces void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); void CancelStatsTasks(); - void RunStatCollection(); + Task RunStatCollection(); } } diff --git a/API/Interfaces/Repositories/ISeriesRepository.cs b/API/Interfaces/Repositories/ISeriesRepository.cs index 2129c894b..4c8b2e74e 100644 --- a/API/Interfaces/Repositories/ISeriesRepository.cs +++ b/API/Interfaces/Repositories/ISeriesRepository.cs @@ -4,6 +4,7 @@ using API.Data.Scanner; using API.DTOs; using API.DTOs.Filtering; using API.Entities; +using API.Entities.Enums; using API.Helpers; namespace API.Interfaces.Repositories @@ -14,7 +15,7 @@ namespace API.Interfaces.Repositories void Update(Series series); void Remove(Series series); void Remove(IEnumerable series); - Task DoesSeriesNameExistInLibrary(string name); + Task DoesSeriesNameExistInLibrary(string name, MangaFormat format); /// /// Adds user information like progress, ratings, etc /// @@ -45,7 +46,7 @@ namespace API.Interfaces.Repositories /// Task AddSeriesModifiers(int userId, List series); Task GetSeriesCoverImageAsync(int seriesId); - Task> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter); + Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter); Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo Task GetSeriesMetadata(int seriesId); Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); diff --git a/API/Interfaces/Services/IStatsService.cs b/API/Interfaces/Services/IStatsService.cs index 9e3536e23..685c3057d 100644 --- a/API/Interfaces/Services/IStatsService.cs +++ b/API/Interfaces/Services/IStatsService.cs @@ -5,7 +5,7 @@ namespace API.Interfaces.Services { public interface IStatsService { - Task RecordClientInfo(ClientInfoDto clientInfoDto); Task Send(); + Task GetServerInfo(); } } diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 2f8b704de..02dc6894c 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -258,19 +258,19 @@ namespace API.Parser MatchOptions, RegexTimeout), // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( - @"^(?.*)(?: |_)v\d+", + @"^(?.+?)(?: |_)v\d+", MatchOptions, RegexTimeout), // Amazing Man Comics chapter 25 new Regex( - @"^(?.*)(?: |_)c(hapter) \d+", + @"^(?.+?)(?: |_)c(hapter) \d+", MatchOptions, RegexTimeout), // Amazing Man Comics issue #25 new Regex( - @"^(?.*)(?: |_)i(ssue) #\d+", + @"^(?.+?)(?: |_)i(ssue) #\d+", MatchOptions, RegexTimeout), // Batman Wayne Family Adventures - Ep. 001 - Moving In new Regex( - @"^(?.+?)(\s|_|-)?(?:Ep\.?)(\s|_|-)+\d+", + @"^(?.+?)(\s|_|-)(?:Ep\.?)(\s|_|-)+\d+", MatchOptions, RegexTimeout), // Batgirl Vol.2000 #57 (December, 2004) new Regex( @@ -286,7 +286,7 @@ namespace API.Parser MatchOptions, RegexTimeout), // Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005) new Regex( - @"^(?.*)(?: |_)(?\d+)", + @"^(?.+?)(?: |_)(?\d+)", MatchOptions, RegexTimeout), // The First Asterix Frieze (WebP by Doc MaKS) new Regex( @@ -336,9 +336,13 @@ namespace API.Parser new Regex( @"^(?.+?)(?:\s|_)#(?\d+)", MatchOptions, RegexTimeout), + // Batman 2016 - Chapter 01, Batman 2016 - Issue 01, Batman 2016 - Issue #01 + new Regex( + @"^(?.+?)((c(hapter)?)|issue)(_|\s)#?(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)", + MatchOptions, RegexTimeout), // Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr new Regex( - @"^(?.+?)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-", + @"^(?.+?)(?:\s|_)(c? ?(chapter)?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-", MatchOptions, RegexTimeout), // Batgirl Vol.2000 #57 (December, 2004) new Regex( @@ -883,7 +887,7 @@ namespace API.Parser { if (match.Success) { - title = title.Replace(match.Value, "").Trim(); + title = title.Replace(match.Value, string.Empty).Trim(); } } } @@ -900,7 +904,7 @@ namespace API.Parser { if (match.Success) { - title = title.Replace(match.Value, "").Trim(); + title = title.Replace(match.Value, string.Empty).Trim(); } } } @@ -946,7 +950,7 @@ namespace API.Parser { if (match.Success) { - title = title.Replace(match.Value, ""); + title = title.Replace(match.Value, string.Empty); } } } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 10d45f232..2eb59a9fc 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -138,6 +138,22 @@ namespace API.Services if (!string.IsNullOrEmpty(nonNestedFile)) return nonNestedFile; + // Check the first folder and sort within that to see if we can find a file, else fallback to first file with basic sort. + // Get first folder, then sort within that + var firstDirectoryFile = fullNames.OrderBy(Path.GetDirectoryName, new NaturalSortComparer()).FirstOrDefault(); + if (!string.IsNullOrEmpty(firstDirectoryFile)) + { + var firstDirectory = Path.GetDirectoryName(firstDirectoryFile); + if (!string.IsNullOrEmpty(firstDirectory)) + { + var firstDirectoryResult = fullNames.Where(f => firstDirectory.Equals(Path.GetDirectoryName(f))) + .OrderBy(Path.GetFileName, new NaturalSortComparer()) + .FirstOrDefault(); + + if (!string.IsNullOrEmpty(firstDirectoryResult)) return firstDirectoryResult; + } + } + var result = fullNames .OrderBy(Path.GetFileName, new NaturalSortComparer()) .FirstOrDefault(); @@ -159,7 +175,7 @@ namespace API.Services /// public string GetCoverImage(string archivePath, string fileName) { - if (archivePath == null || !IsValidArchive(archivePath)) return String.Empty; + if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty; try { var libraryHandler = CanOpen(archivePath); diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 71f633e4c..137f21dca 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -30,6 +30,7 @@ namespace API.Services private readonly ILogger _logger; private readonly StylesheetParser _cssParser = new (); private static readonly RecyclableMemoryStreamManager StreamManager = new (); + private const string CssScopeClass = ".book-content"; public BookService(ILogger logger) { @@ -152,22 +153,23 @@ namespace API.Services EscapeCssImageReferences(ref stylesheetHtml, apiBase, book); var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml); - styleContent = styleContent.Replace("body", ".reading-section"); + + styleContent = styleContent.Replace("body", CssScopeClass); if (string.IsNullOrEmpty(styleContent)) return string.Empty; var stylesheet = await _cssParser.ParseAsync(styleContent); foreach (var styleRule in stylesheet.StyleRules) { - if (styleRule.Selector.Text == ".reading-section") continue; + if (styleRule.Selector.Text == CssScopeClass) continue; if (styleRule.Selector.Text.Contains(",")) { styleRule.Text = styleRule.Text.Replace(styleRule.SelectorText, string.Join(", ", - styleRule.Selector.Text.Split(",").Select(s => ".reading-section " + s))); + styleRule.Selector.Text.Split(",").Select(s => $"{CssScopeClass} " + s))); continue; } - styleRule.Text = ".reading-section " + styleRule.Text; + styleRule.Text = $"{CssScopeClass} " + styleRule.Text; } return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss()); } @@ -371,7 +373,7 @@ namespace API.Services FullFilePath = filePath, IsSpecial = false, Series = series.Trim(), - Volumes = seriesIndex.Split(".")[0] + Volumes = seriesIndex }; } } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 1a067a706..04240245a 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -21,7 +21,7 @@ namespace API.Services public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "cache"); public static readonly string CoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "covers"); public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups"); - public static readonly string StatsDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "stats"); + public static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config"); public DirectoryService(ILogger logger) { @@ -173,7 +173,15 @@ namespace API.Services return true; } - + /// + /// Checks if the root path of a path exists or not. + /// + /// + /// + public static bool IsDriveMounted(string path) + { + return new DirectoryInfo(Path.GetPathRoot(path) ?? string.Empty).Exists; + } public static string[] GetFilesWithExtension(string path, string searchPatternExpression = "") { @@ -257,7 +265,7 @@ namespace API.Services /// /// An optional string to prepend to the target file's name /// - public bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = "") + public static bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = "", ILogger logger = null) { ExistOrCreate(directoryPath); string currentFile = null; @@ -273,19 +281,24 @@ namespace API.Services } else { - _logger.LogWarning("Tried to copy {File} but it doesn't exist", file); + logger?.LogWarning("Tried to copy {File} but it doesn't exist", file); } } } catch (Exception ex) { - _logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath); + logger?.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath); return false; } return true; } + public bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = "") + { + return CopyFilesToDirectory(filePaths, directoryPath, prepend, _logger); + } + public IEnumerable ListDirectory(string rootPath) { if (!Directory.Exists(rootPath)) return ImmutableList.Empty; diff --git a/API/Services/HostedServices/StartupTasksHostedService.cs b/API/Services/HostedServices/StartupTasksHostedService.cs index 58b6eec25..486b45513 100644 --- a/API/Services/HostedServices/StartupTasksHostedService.cs +++ b/API/Services/HostedServices/StartupTasksHostedService.cs @@ -29,7 +29,7 @@ namespace API.Services.HostedServices // These methods will automatically check if stat collection is disabled to prevent sending any data regardless // of when setting was changed await taskScheduler.ScheduleStatsTasks(); - taskScheduler.RunStatCollection(); + await taskScheduler.RunStatCollection(); } catch (Exception) { diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 1b5ab0d69..09161f42a 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -218,14 +218,18 @@ namespace API.Services var stopwatch = Stopwatch.StartNew(); var totalTime = 0L; _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); - for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) + var i = 0; + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++, i++) { if (chunkInfo.TotalChunks == 0) continue; totalTime += stopwatch.ElapsedMilliseconds; stopwatch.Restart(); _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); + var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, new UserParams() { @@ -233,6 +237,7 @@ namespace API.Services PageSize = chunkInfo.ChunkSize }); _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); + Parallel.ForEach(nonLibrarySeries, series => { try @@ -275,8 +280,14 @@ namespace API.Services "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); } + var progress = Math.Max(0F, Math.Min(1F, i * 1F / chunkInfo.TotalChunks)); + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, progress)); } + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F)); + _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index ee68df106..77c745535 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -23,9 +23,9 @@ namespace API.Services private readonly IStatsService _statsService; private readonly IVersionUpdaterService _versionUpdaterService; - private const string SendDataTask = "finalize-stats"; public static BackgroundJobServer Client => new BackgroundJobServer(); + private static readonly Random Rnd = new Random(); public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, @@ -73,7 +73,6 @@ namespace API.Services RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); - RecurringJob.AddOrUpdate("check-for-updates", () => _scannerService.ScanLibraries(), Cron.Daily, TimeZoneInfo.Local); } #region StatsTasks @@ -89,19 +88,27 @@ namespace API.Services } _logger.LogDebug("Scheduling stat collection daily"); - RecurringJob.AddOrUpdate(SendDataTask, () => _statsService.Send(), Cron.Daily, TimeZoneInfo.Local); + RecurringJob.AddOrUpdate("report-stats", () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local); } public void CancelStatsTasks() { _logger.LogDebug("Cancelling/Removing StatsTasks"); - RecurringJob.RemoveIfExists(SendDataTask); + RecurringJob.RemoveIfExists("report-stats"); } - public void RunStatCollection() + /// + /// First time run stat collection. Executes immediately on a background thread. Does not block. + /// + public async Task RunStatCollection() { - _logger.LogInformation("Enqueuing stat collection"); + var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; + if (!allowStatCollection) + { + _logger.LogDebug("User has opted out of stat collection, not sending stats"); + return; + } BackgroundJob.Enqueue(() => _statsService.Send()); } @@ -112,8 +119,8 @@ namespace API.Services public void ScheduleUpdaterTasks() { _logger.LogInformation("Scheduling Auto-Update tasks"); - RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Weekly, TimeZoneInfo.Local); - + // Schedule update check between noon and 6pm local time + RecurringJob.AddOrUpdate("check-updates", () => _versionUpdaterService.CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local); } #endregion diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index e71f35e9f..04cb279ec 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -8,8 +8,9 @@ using API.Entities.Enums; using API.Extensions; using API.Interfaces; using API.Interfaces.Services; +using API.SignalR; using Hangfire; -using Kavita.Common.EnvironmentInfo; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -20,45 +21,32 @@ namespace API.Services.Tasks private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IDirectoryService _directoryService; - private readonly string _tempDirectory = DirectoryService.TempDirectory; - private readonly string _logDirectory = DirectoryService.LogDirectory; + private readonly IHubContext _messageHub; private readonly IList _backupFiles; - public BackupService(IUnitOfWork unitOfWork, ILogger logger, IDirectoryService directoryService, IConfiguration config) + public BackupService(IUnitOfWork unitOfWork, ILogger logger, + IDirectoryService directoryService, IConfiguration config, IHubContext messageHub) { _unitOfWork = unitOfWork; _logger = logger; _directoryService = directoryService; + _messageHub = messageHub; var maxRollingFiles = config.GetMaxRollingFiles(); var loggingSection = config.GetLoggingFileName(); var files = LogFiles(maxRollingFiles, loggingSection); - if (new OsInfo(Array.Empty()).IsDocker) + + _backupFiles = new List() { - _backupFiles = new List() - { - "data/appsettings.json", - "data/Hangfire.db", - "data/Hangfire-log.db", - "data/kavita.db", - "data/kavita.db-shm", // This wont always be there - "data/kavita.db-wal" // This wont always be there - }; - } - else - { - _backupFiles = new List() - { - "appsettings.json", - "Hangfire.db", - "Hangfire-log.db", - "kavita.db", - "kavita.db-shm", // This wont always be there - "kavita.db-wal" // This wont always be there - }; - } + "appsettings.json", + "Hangfire.db", // This is not used atm + "Hangfire-log.db", // This is not used atm + "kavita.db", + "kavita.db-shm", // This wont always be there + "kavita.db-wal" // This wont always be there + }; foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList()) { @@ -72,7 +60,7 @@ namespace API.Services.Tasks var fi = new FileInfo(logFileName); var files = maxRollingFiles > 0 - ? DirectoryService.GetFiles(_logDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log") + ? DirectoryService.GetFiles(DirectoryService.LogDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log") : new[] {"kavita.log"}; return files; } @@ -89,11 +77,13 @@ namespace API.Services.Tasks _logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory); if (!DirectoryService.ExistOrCreate(backupDirectory)) { - _logger.LogError("Could not write to {BackupDirectory}; aborting backup", backupDirectory); + _logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory); return; } - var dateString = DateTime.Now.ToShortDateString().Replace("/", "_"); + await SendProgress(0F); + + var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip"); if (File.Exists(zipPath)) @@ -102,15 +92,19 @@ namespace API.Services.Tasks return; } - var tempDirectory = Path.Join(_tempDirectory, dateString); + var tempDirectory = Path.Join(DirectoryService.TempDirectory, dateString); DirectoryService.ExistOrCreate(tempDirectory); DirectoryService.ClearDirectory(tempDirectory); _directoryService.CopyFilesToDirectory( - _backupFiles.Select(file => Path.Join(Directory.GetCurrentDirectory(), file)).ToList(), tempDirectory); + _backupFiles.Select(file => Path.Join(DirectoryService.ConfigDirectory, file)).ToList(), tempDirectory); + + await SendProgress(0.25F); await CopyCoverImagesToBackupDirectory(tempDirectory); + await SendProgress(0.75F); + try { ZipFile.CreateFromDirectory(tempDirectory, zipPath); @@ -122,6 +116,7 @@ namespace API.Services.Tasks DirectoryService.ClearAndDeleteDirectory(tempDirectory); _logger.LogInformation("Database backup completed"); + await SendProgress(1F); } private async Task CopyCoverImagesToBackupDirectory(string tempDirectory) @@ -154,6 +149,12 @@ namespace API.Services.Tasks } } + private async Task SendProgress(float progress) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.BackupDatabaseProgress, + MessageFactory.BackupDatabaseProgressEvent(progress)); + } + /// /// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept. /// diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index a3c63c30f..1ecc9cec5 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -2,7 +2,9 @@ using System.Threading.Tasks; using API.Interfaces; using API.Interfaces.Services; +using API.SignalR; using Hangfire; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace API.Services.Tasks @@ -16,14 +18,16 @@ namespace API.Services.Tasks private readonly ILogger _logger; private readonly IBackupService _backupService; private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub; public CleanupService(ICacheService cacheService, ILogger logger, - IBackupService backupService, IUnitOfWork unitOfWork) + IBackupService backupService, IUnitOfWork unitOfWork, IHubContext messageHub) { _cacheService = cacheService; _logger = logger; _backupService = backupService; _unitOfWork = unitOfWork; + _messageHub = messageHub; } public void CleanupCacheDirectory() @@ -39,19 +43,31 @@ namespace API.Services.Tasks public async Task Cleanup() { _logger.LogInformation("Starting Cleanup"); + await SendProgress(0F); _logger.LogInformation("Cleaning temp directory"); - var tempDirectory = DirectoryService.TempDirectory; - DirectoryService.ClearDirectory(tempDirectory); + DirectoryService.ClearDirectory(DirectoryService.TempDirectory); + await SendProgress(0.1F); CleanupCacheDirectory(); + await SendProgress(0.25F); _logger.LogInformation("Cleaning old database backups"); _backupService.CleanupBackups(); + await SendProgress(0.50F); _logger.LogInformation("Cleaning deleted cover images"); await DeleteSeriesCoverImages(); + await SendProgress(0.6F); await DeleteChapterCoverImages(); + await SendProgress(0.7F); await DeleteTagCoverImages(); + await SendProgress(1F); _logger.LogInformation("Cleanup finished"); } + private async Task SendProgress(float progress) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.CleanupProgress, + MessageFactory.CleanupProgressEvent(progress)); + } + private async Task DeleteSeriesCoverImages() { var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync(); diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index d82c9579c..f1d5a2b96 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -56,6 +56,14 @@ namespace API.Services.Tasks var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); var folderPaths = library.Folders.Select(f => f.Path).ToList(); + + // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are + if (folderPaths.Any(f => !DirectoryService.IsDriveMounted(f))) + { + _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + return; + } + var dirs = DirectoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); @@ -129,8 +137,7 @@ namespace API.Services.Tasks await _unitOfWork.RollbackAsync(); } // Tell UI that this series is done - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), - cancellationToken: token); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), token); await CleanupDbEntities(); BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false)); @@ -195,6 +202,14 @@ namespace API.Services.Tasks return; } + // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are + if (library.Folders.Any(f => !DirectoryService.IsDriveMounted(f.Path))) + { + _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + return; + } + + _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, MessageFactory.ScanLibraryProgressEvent(libraryId, 0)); @@ -228,7 +243,7 @@ namespace API.Services.Tasks BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(libraryId, 100)); + MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); } /// @@ -326,7 +341,7 @@ namespace API.Services.Tasks await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); } - var progress = Math.Max(0, Math.Min(100, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); + var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); } @@ -343,15 +358,14 @@ namespace API.Services.Tasks // Key is normalized already Series existingSeries; try - {// NOTE: Maybe use .Equals() here - existingSeries = allSeries.SingleOrDefault(s => - (s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName) - && (s.Format == key.Format || s.Format == MangaFormat.Unknown)); + { + existingSeries = allSeries.SingleOrDefault(s => FindSeries(s, key)); } catch (Exception e) { + // NOTE: If I ever want to put Duplicates table, this is where it can go _logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName); - var duplicateSeries = allSeries.Where(s => s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName).ToList(); + var duplicateSeries = allSeries.Where(s => FindSeries(s, key)); foreach (var series in duplicateSeries) { _logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName); @@ -362,46 +376,38 @@ namespace API.Services.Tasks if (existingSeries != null) continue; - existingSeries = DbFactory.Series(infos[0].Series); - existingSeries.Format = key.Format; - newSeries.Add(existingSeries); + var s = DbFactory.Series(infos[0].Series); + s.Format = key.Format; + s.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series. + newSeries.Add(s); } var i = 0; foreach(var series in newSeries) { + _logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); + UpdateSeries(series, parsedSeries); + _unitOfWork.SeriesRepository.Attach(series); try { - _logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); - UpdateVolumes(series, ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray()); - series.Pages = series.Volumes.Sum(v => v.Pages); - series.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series. - _unitOfWork.SeriesRepository.Attach(series); - if (await _unitOfWork.CommitAsync()) - { - _logger.LogInformation( - "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", - newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); + await _unitOfWork.CommitAsync(); + _logger.LogInformation( + "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); - // Inform UI of new series added - await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); - var progress = Math.Max(0F, Math.Min(100F, i * 1F / newSeries.Count)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); - } - else - { - // This is probably not needed. Better to catch the exception. - _logger.LogCritical( - "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan"); - } - - i++; + // Inform UI of new series added + await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); } catch (Exception ex) { - _logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); + _logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ", + series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}"); } + + var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count)); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); + i++; } _logger.LogInformation( @@ -409,13 +415,19 @@ namespace API.Services.Tasks newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); } + private static bool FindSeries(Series series, ParsedSeries parsedInfoKey) + { + return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName)) + && (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown); + } + private void UpdateSeries(Series series, Dictionary> parsedSeries) { try { _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); - var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray(); + var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series); UpdateVolumes(series, parsedInfos); series.Pages = series.Volumes.Sum(v => v.Pages); @@ -482,7 +494,7 @@ namespace API.Services.Tasks /// Series not found on disk or can't be parsed /// /// the updated existingSeries - public static IList RemoveMissingSeries(IList existingSeries, IEnumerable missingSeries, out int removeCount) + public static IEnumerable RemoveMissingSeries(IList existingSeries, IEnumerable missingSeries, out int removeCount) { var existingCount = existingSeries.Count; var missingList = missingSeries.ToList(); @@ -496,7 +508,7 @@ namespace API.Services.Tasks return existingSeries; } - private void UpdateVolumes(Series series, ParserInfo[] parsedInfos) + private void UpdateVolumes(Series series, IList parsedInfos) { var startingVolumeCount = series.Volumes.Count; // Add new volumes and update chapters per volume @@ -550,7 +562,7 @@ namespace API.Services.Tasks /// /// /// - private void UpdateChapters(Volume volume, ParserInfo[] parsedInfos) + private void UpdateChapters(Volume volume, IList parsedInfos) { // Add new chapters foreach (var info in parsedInfos) diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 62393393e..0052e0cb4 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -1,46 +1,31 @@ using System; -using System.IO; -using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; -using System.Text.Json; -using System.Threading; using System.Threading.Tasks; -using API.Data; using API.DTOs.Stats; +using API.Entities.Enums; using API.Interfaces; using API.Interfaces.Services; using Flurl.Http; -using Hangfire; -using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Tasks { public class StatsService : IStatsService { - private const string StatFileName = "app_stats.json"; - - private readonly DataContext _dbContext; private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; + private const string ApiUrl = "https://stats.kavitareader.com"; -#pragma warning disable S1075 - private const string ApiUrl = "http://stats.kavitareader.com"; -#pragma warning restore S1075 - private static readonly string StatsFilePath = Path.Combine(DirectoryService.StatsDirectory, StatFileName); - - private static bool FileExists => File.Exists(StatsFilePath); - - public StatsService(DataContext dbContext, ILogger logger, - IUnitOfWork unitOfWork) + public StatsService(ILogger logger, IUnitOfWork unitOfWork) { - _dbContext = dbContext; _logger = logger; _unitOfWork = unitOfWork; + + FlurlHttp.ConfigureClient(ApiUrl, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); } /// @@ -55,17 +40,7 @@ namespace API.Services.Tasks return; } - var rnd = new Random(); - var offset = rnd.Next(0, 6); - if (offset == 0) - { - await SendData(); - } - else - { - _logger.LogInformation("KavitaStats upload has been schedule to run in {Offset} hours", offset); - BackgroundJob.Schedule(() => SendData(), DateTimeOffset.Now.AddHours(offset)); - } + await SendData(); } /// @@ -74,66 +49,30 @@ namespace API.Services.Tasks // ReSharper disable once MemberCanBePrivate.Global public async Task SendData() { - await CollectRelevantData(); - await FinalizeStats(); + var data = await GetServerInfo(); + await SendDataToStatsServer(data); } - public async Task RecordClientInfo(ClientInfoDto clientInfoDto) - { - var statisticsDto = await GetData(); - statisticsDto.AddClientInfo(clientInfoDto); - await SaveFile(statisticsDto); - } - - private async Task CollectRelevantData() - { - var usageInfo = await GetUsageInfo(); - var serverInfo = GetServerInfo(); - - await PathData(serverInfo, usageInfo); - } - - private async Task FinalizeStats() - { - try - { - var data = await GetExistingData(); - var successful = await SendDataToStatsServer(data); - - if (successful) - { - ResetStats(); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception while sending data to KavitaStats"); - } - } - - private async Task SendDataToStatsServer(UsageStatisticsDto data) + private async Task SendDataToStatsServer(ServerInfoDto data) { var responseContent = string.Empty; try { - var response = await (ApiUrl + "/api/InstallationStats") + var response = await (ApiUrl + "/api/v2/stats") .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") .WithTimeout(TimeSpan.FromSeconds(30)) .PostJsonAsync(data); if (response.StatusCode != StatusCodes.Status200OK) { _logger.LogError("KavitaStats did not respond successfully. {Content}", response); - return false; } - - return true; } catch (HttpRequestException e) { @@ -149,84 +88,22 @@ namespace API.Services.Tasks { _logger.LogError(e, "An error happened during the request to KavitaStats"); } - - return false; } - private static void ResetStats() - { - if (FileExists) File.Delete(StatsFilePath); - } - - private async Task PathData(ServerInfoDto serverInfoDto, UsageInfoDto usageInfoDto) - { - var data = await GetData(); - - data.ServerInfo = serverInfoDto; - data.UsageInfo = usageInfoDto; - - data.MarkAsUpdatedNow(); - - await SaveFile(data); - } - - private static async ValueTask GetData() - { - if (!FileExists) return new UsageStatisticsDto {InstallId = HashUtil.AnonymousToken()}; - - return await GetExistingData(); - } - - private async Task GetUsageInfo() - { - var usersCount = await _dbContext.Users.CountAsync(); - - var libsCountByType = await _dbContext.Library - .AsNoTracking() - .GroupBy(x => x.Type) - .Select(x => new LibInfo {Type = x.Key, Count = x.Count()}) - .ToArrayAsync(); - - var uniqueFileTypes = await _unitOfWork.FileRepository.GetFileExtensions(); - - var usageInfo = new UsageInfoDto - { - UsersCount = usersCount, - LibraryTypesCreated = libsCountByType, - FileTypes = uniqueFileTypes - }; - - return usageInfo; - } - - public static ServerInfoDto GetServerInfo() + public async Task GetServerInfo() { + var installId = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId); var serverInfo = new ServerInfoDto { + InstallId = installId.Value, Os = RuntimeInformation.OSDescription, - DotNetVersion = Environment.Version.ToString(), - RunTimeVersion = RuntimeInformation.FrameworkDescription, KavitaVersion = BuildInfo.Version.ToString(), - Culture = Thread.CurrentThread.CurrentCulture.Name, - BuildBranch = BuildInfo.Branch, + DotnetVersion = Environment.Version.ToString(), IsDocker = new OsInfo(Array.Empty()).IsDocker, - NumOfCores = Environment.ProcessorCount + NumOfCores = Math.Max(Environment.ProcessorCount, 1) }; return serverInfo; } - - private static async Task GetExistingData() - { - var json = await File.ReadAllTextAsync(StatsFilePath); - return JsonSerializer.Deserialize(json); - } - - private static async Task SaveFile(UsageStatisticsDto statisticsDto) - { - DirectoryService.ExistOrCreate(DirectoryService.StatsDirectory); - - await File.WriteAllTextAsync(StatsFilePath, JsonSerializer.Serialize(statisticsDto)); - } } } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index fbd3d4f10..64e21d39a 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -38,6 +38,11 @@ namespace API.Services.Tasks /// // ReSharper disable once InconsistentNaming public string Html_Url { get; init; } + /// + /// Date Release was Published + /// + // ReSharper disable once InconsistentNaming + public string Published_At { get; init; } } public class UntrustedCertClientFactory : DefaultHttpClientFactory @@ -109,7 +114,8 @@ namespace API.Services.Tasks UpdateBody = _markdown.Transform(update.Body.Trim()), UpdateTitle = update.Name, UpdateUrl = update.Html_Url, - IsDocker = new OsInfo(Array.Empty()).IsDocker + IsDocker = new OsInfo(Array.Empty()).IsDocker, + PublishDate = update.Published_At }; } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 0bab0b4da..3ab6c646c 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -60,6 +60,20 @@ namespace API.SignalR }; } + public static SignalRMessage RefreshMetadataProgressEvent(int libraryId, float progress) + { + return new SignalRMessage() + { + Name = SignalREvents.RefreshMetadataProgress, + Body = new + { + LibraryId = libraryId, + Progress = progress, + EventTime = DateTime.Now + } + }; + } + public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId) @@ -75,6 +89,31 @@ namespace API.SignalR }; } + public static SignalRMessage BackupDatabaseProgressEvent(float progress) + { + return new SignalRMessage() + { + Name = SignalREvents.BackupDatabaseProgress, + Body = new + { + Progress = progress + } + }; + } + public static SignalRMessage CleanupProgressEvent(float progress) + { + return new SignalRMessage() + { + Name = SignalREvents.CleanupProgress, + Body = new + { + Progress = progress + } + }; + } + + + public static SignalRMessage UpdateVersionEvent(UpdateNotificationDto update) { return new SignalRMessage diff --git a/API/SignalR/SignalREvents.cs b/API/SignalR/SignalREvents.cs index 0f97ad493..06908c2be 100644 --- a/API/SignalR/SignalREvents.cs +++ b/API/SignalR/SignalREvents.cs @@ -4,7 +4,14 @@ { public const string UpdateVersion = "UpdateVersion"; public const string ScanSeries = "ScanSeries"; + /// + /// Event during Refresh Metadata for cover image change + /// public const string RefreshMetadata = "RefreshMetadata"; + /// + /// Event sent out during Refresh Metadata for progress tracking + /// + public const string RefreshMetadataProgress = "RefreshMetadataProgress"; public const string ScanLibrary = "ScanLibrary"; public const string SeriesAdded = "SeriesAdded"; public const string SeriesRemoved = "SeriesRemoved"; @@ -12,5 +19,13 @@ public const string OnlineUsers = "OnlineUsers"; public const string SeriesAddedToCollection = "SeriesAddedToCollection"; public const string ScanLibraryError = "ScanLibraryError"; + /// + /// Event sent out during backing up the database + /// + public const string BackupDatabaseProgress = "BackupDatabaseProgress"; + /// + /// Event sent out during cleaning up temp and cache folders + /// + public const string CleanupProgress = "CleanupProgress"; } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9befd8a5..5b0417947 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ We're always looking for people to help make Kavita even better, there are a number of ways to contribute. ## Documentation ## -Setup guides, FAQ, the more information we have on the [wiki](https://github.com/Kareadita/Kavita/wiki) the better. +Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavitareader.com/) the better. ## Development ## diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index dbcc50701..a02c72765 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -4,7 +4,7 @@ net5.0 kavitareader.com Kavita - 0.4.8.1 + 0.4.9.0 en @@ -18,4 +18,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 0bf98f2c8..d32b48c26 100644 --- a/README.md +++ b/README.md @@ -80,15 +80,15 @@ services: **Note: Kavita is under heavy development and is being updated all the time, so the tag for current builds is `:nightly`. The `:latest` tag will be the latest stable release.** ## Feature Requests -Got a great idea? Throw it up on the FeatHub or vote on another idea. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects) first for a list of planned features. - -[](https://feathub.com/Kareadita/Kavita) +Got a great idea? Throw it up on our [Feature Request site](https://feats.kavitareader.com/) or vote on another idea. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects) first for a list of planned features. ## Contributors This project exists thanks to all the people who contribute. [Contribute](CONTRIBUTING.md). - + + + ## Donate @@ -99,7 +99,7 @@ expenses related to Kavita. Back us through [OpenCollective](https://opencollect Thank you to all our backers! 🙏 [Become a backer](https://opencollective.com/Kavita#backer) - + ## Sponsors @@ -116,9 +116,6 @@ Thank you to [ JetBrains](http: * [ Rider](http://www.jetbrains.com/rider/) * [ dotTrace](http://www.jetbrains.com/dottrace/) -## Sentry -Thank you to [](https://sentry.io/welcome/) for providing us with free license to their software. - ### License * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) diff --git a/UI/Web/angular.json b/UI/Web/angular.json index abcdbcca9..a78e625b0 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -71,7 +71,7 @@ { "type": "anyComponentStyle", "maximumWarning": "2kb", - "maximumError": "4kb" + "maximumError": "5kb" } ] } diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index c69cf5812..5f8160aba 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -89,6 +89,36 @@ "webpack-sources": "2.0.1", "webpack-subresource-integrity": "1.5.1", "worker-plugin": "5.0.0" + }, + "dependencies": { + "postcss": { + "version": "7.0.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz", + "integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } } }, "@angular-devkit/build-optimizer": { @@ -3252,9 +3282,9 @@ "dev": true }, "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, "ansi-styles": { "version": "3.2.1", @@ -4591,9 +4621,9 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "color-string": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz", - "integrity": "sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz", + "integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==", "dev": true, "requires": { "color-name": "^1.0.0", @@ -6820,9 +6850,9 @@ } }, "glob-parent": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", - "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "requires": { "is-glob": "^4.0.1" @@ -9620,9 +9650,9 @@ } }, "ws": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.3.tgz", - "integrity": "sha512-hr6vCR76GsossIRsr8OLR9acVVm1jyfEWvhbNjtgPOrfvAlKzvyeg/P6r8RuDjRyrcQoPQT7K0DGEPc7Ae6jzA==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", + "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", "dev": true } } @@ -9710,9 +9740,9 @@ } }, "jszip": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.5.0.tgz", - "integrity": "sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.7.1.tgz", + "integrity": "sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==", "dev": true, "requires": { "lie": "~3.3.0", @@ -11269,20 +11299,6 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true - }, - "tar": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz", - "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==", - "dev": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } } } }, @@ -11416,9 +11432,9 @@ "dev": true }, "path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "path-to-regexp": { "version": "0.1.7", @@ -11451,6 +11467,12 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "dev": true }, + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, "picomatch": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", @@ -11534,14 +11556,13 @@ "dev": true }, "postcss": { - "version": "7.0.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz", - "integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==", + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", "dev": true, "requires": { - "chalk": "^2.4.2", - "source-map": "^0.6.1", - "supports-color": "^6.1.0" + "picocolors": "^0.2.1", + "source-map": "^0.6.1" }, "dependencies": { "source-map": { @@ -11549,15 +11570,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true - }, - "supports-color": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", - "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } } } }, @@ -14752,9 +14764,9 @@ "dev": true }, "tar": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.0.5.tgz", - "integrity": "sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg==", + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", + "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", "dev": true, "requires": { "chownr": "^2.0.0", @@ -14919,9 +14931,9 @@ } }, "tmpl": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", - "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, "to-arraybuffer": { @@ -15390,9 +15402,9 @@ } }, "url-parse": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz", - "integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz", + "integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==", "requires": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -16678,45 +16690,12 @@ "dev": true }, "wide-align": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", - "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", "dev": true, "requires": { - "string-width": "^1.0.2 || 2" - }, - "dependencies": { - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true - }, - "string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "requires": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - } + "string-width": "^1.0.2 || 2 || 3 || 4" } }, "wildcard": { diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index 575ed21d6..a924b6b2e 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -46,10 +46,10 @@ export class ErrorInterceptor implements HttpInterceptor { } // If we are not on no-connection, redirect there and save current url so when we refersh, we redirect back there - if (this.router.url !== '/no-connection') { - localStorage.setItem(this.urlKey, this.router.url); - this.router.navigateByUrl('/no-connection'); - } + // if (this.router.url !== '/no-connection') { + // localStorage.setItem(this.urlKey, this.router.url); + // this.router.navigateByUrl('/no-connection'); + // } break; } return throwError(error); diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index 94dd1b2d1..4a9399489 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -15,4 +15,5 @@ export interface Chapter { pagesRead: number; // Attached for the given user when requesting from API isSpecial: boolean; title: string; + created: string; } diff --git a/UI/Web/src/app/_models/events/scan-library-progress-event.ts b/UI/Web/src/app/_models/events/scan-library-progress-event.ts index e8460fde4..7b4d9c2d0 100644 --- a/UI/Web/src/app/_models/events/scan-library-progress-event.ts +++ b/UI/Web/src/app/_models/events/scan-library-progress-event.ts @@ -1,4 +1,4 @@ -export interface ScanLibraryProgressEvent { +export interface ProgressEvent { libraryId: number; progress: number; eventTime: string; diff --git a/UI/Web/src/app/_models/events/series-removed-event.ts b/UI/Web/src/app/_models/events/series-removed-event.ts new file mode 100644 index 000000000..9901fc7e5 --- /dev/null +++ b/UI/Web/src/app/_models/events/series-removed-event.ts @@ -0,0 +1,5 @@ +export interface SeriesRemovedEvent { + libraryId: number; + seriesId: number; + seriesName: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/update-version-event.ts b/UI/Web/src/app/_models/events/update-version-event.ts index d0754d81a..d5845881c 100644 --- a/UI/Web/src/app/_models/events/update-version-event.ts +++ b/UI/Web/src/app/_models/events/update-version-event.ts @@ -5,4 +5,5 @@ export interface UpdateVersionEvent { updateTitle: string; updateUrl: string; isDocker: boolean; + publishDate: string; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/preferences/page-split-option.ts b/UI/Web/src/app/_models/preferences/page-split-option.ts index ed977a7bf..e4fc3c7e8 100644 --- a/UI/Web/src/app/_models/preferences/page-split-option.ts +++ b/UI/Web/src/app/_models/preferences/page-split-option.ts @@ -1,5 +1,18 @@ export enum PageSplitOption { + /** + * Renders the left side of the image then the right side + */ SplitLeftToRight = 0, + /** + * Renders the right side of the image then the left side + */ SplitRightToLeft = 1, - NoSplit = 2 + /** + * Don't split and show the image in original size + */ + NoSplit = 2, + /** + * Don't split and scale the image to fit screen space + */ + FitSplit = 3 } diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index db5bead01..7e44dbbee 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -26,5 +26,5 @@ export interface Preferences { export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}]; export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}]; -export const pageSplitOptions = [{text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}]; +export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.FitSplit}, {text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}]; export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}, {text: 'Webtoon', value: READER_MODE.WEBTOON}]; diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index c058e1596..85a543322 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -1,4 +1,5 @@ import { EventEmitter, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; @@ -6,7 +7,7 @@ import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { environment } from 'src/environments/environment'; import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component'; import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event'; -import { ScanLibraryProgressEvent } from '../_models/events/scan-library-progress-event'; +import { ProgressEvent } from '../_models/events/scan-library-progress-event'; import { ScanSeriesEvent } from '../_models/events/scan-series-event'; import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { User } from '../_models/user'; @@ -15,11 +16,15 @@ export enum EVENTS { UpdateAvailable = 'UpdateAvailable', ScanSeries = 'ScanSeries', RefreshMetadata = 'RefreshMetadata', + RefreshMetadataProgress = 'RefreshMetadataProgress', SeriesAdded = 'SeriesAdded', + SeriesRemoved = 'SeriesRemoved', ScanLibraryProgress = 'ScanLibraryProgress', OnlineUsers = 'OnlineUsers', SeriesAddedToCollection = 'SeriesAddedToCollection', - ScanLibraryError = 'ScanLibraryError' + ScanLibraryError = 'ScanLibraryError', + BackupDatabaseProgress = 'BackupDatabaseProgress', + CleanupProgress = 'CleanupProgress' } export interface Message { @@ -42,13 +47,13 @@ export class MessageHubService { onlineUsers$ = this.onlineUsersSource.asObservable(); public scanSeries: EventEmitter = new EventEmitter(); - public scanLibrary: EventEmitter = new EventEmitter(); + public scanLibrary: EventEmitter = new EventEmitter(); // TODO: Refactor this name to be generic public seriesAdded: EventEmitter = new EventEmitter(); public refreshMetadata: EventEmitter = new EventEmitter(); isAdmin: boolean = false; - constructor(private modalService: NgbModal, private toastr: ToastrService) { + constructor(private modalService: NgbModal, private toastr: ToastrService, private router: Router) { } @@ -87,6 +92,27 @@ export class MessageHubService { this.scanLibrary.emit(resp.body); }); + this.hubConnection.on(EVENTS.BackupDatabaseProgress, resp => { + this.messagesSource.next({ + event: EVENTS.BackupDatabaseProgress, + payload: resp.body + }); + }); + + this.hubConnection.on(EVENTS.CleanupProgress, resp => { + this.messagesSource.next({ + event: EVENTS.CleanupProgress, + payload: resp.body + }); + }); + + this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => { + this.messagesSource.next({ + event: EVENTS.RefreshMetadataProgress, + payload: resp.body + }); + }); + this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => { this.messagesSource.next({ event: EVENTS.SeriesAddedToCollection, @@ -110,11 +136,19 @@ export class MessageHubService { payload: resp.body }); this.seriesAdded.emit(resp.body); - if (this.isAdmin) { + // Don't show the toast when user has reader open + if (this.isAdmin && this.router.url.match(/\d+\/manga|book\/\d+/gi) !== null) { this.toastr.info('Series ' + (resp.body as SeriesAddedEvent).seriesName + ' added'); } }); + this.hubConnection.on(EVENTS.SeriesRemoved, resp => { + this.messagesSource.next({ + event: EVENTS.SeriesRemoved, + payload: resp.body + }); + }); + this.hubConnection.on(EVENTS.RefreshMetadata, resp => { this.messagesSource.next({ event: EVENTS.RefreshMetadata, diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 6d3693558..8ed258c45 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -6,7 +6,6 @@ import { environment } from 'src/environments/environment'; import { Chapter } from '../_models/chapter'; import { CollectionTag } from '../_models/collection-tag'; import { InProgressChapter } from '../_models/in-progress-chapter'; -import { MangaFormat } from '../_models/manga-format'; import { PaginatedResult } from '../_models/pagination'; import { Series } from '../_models/series'; import { SeriesFilter } from '../_models/series-filter'; @@ -112,13 +111,13 @@ export class SeriesService { ); } - getInProgress(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { + getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { const data = this.createSeriesFilter(filter); let params = new HttpParams(); params = this._addPaginationIfExists(params, pageNum, itemsPerPage); - return this.httpClient.post(this.baseUrl + 'series/in-progress?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( + return this.httpClient.post(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map(response => { return this._cachePaginatedResults(response, new PaginatedResult()); })); diff --git a/UI/Web/src/app/_services/stats.service.ts b/UI/Web/src/app/_services/stats.service.ts deleted file mode 100644 index 37afc1d15..000000000 --- a/UI/Web/src/app/_services/stats.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { HttpClient } from "@angular/common/http"; -import { Injectable } from "@angular/core"; -import * as Bowser from "bowser"; -import { take } from "rxjs/operators"; -import { environment } from "src/environments/environment"; -import { ClientInfo } from "../_models/stats/client-info"; -import { DetailsVersion } from "../_models/stats/details-version"; -import { NavService } from "./nav.service"; -import { version } from '../../../package.json'; - - -@Injectable({ - providedIn: 'root' -}) -export class StatsService { - - baseUrl = environment.apiUrl; - - constructor(private httpClient: HttpClient, private navService: NavService) { } - - public sendClientInfo(data: ClientInfo) { - return this.httpClient.post(this.baseUrl + 'stats/client-info', data); - } - - public async getInfo(): Promise { - const screenResolution = `${window.screen.width} x ${window.screen.height}`; - - const browser = Bowser.getParser(window.navigator.userAgent); - - const usingDarkTheme = await this.navService.darkMode$.pipe(take(1)).toPromise(); - - return { - os: browser.getOS() as DetailsVersion, - browser: browser.getBrowser() as DetailsVersion, - platformType: browser.getPlatformType(), - kavitaUiVersion: version, - screenResolution, - usingDarkTheme - }; - } -} \ No newline at end of file diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html index de4001387..e97c96179 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html @@ -6,17 +6,24 @@ - - - - - {{library.data.name}} - - - - There are no libraries setup yet. - + + + + {{selectAll ? 'Deselect' : 'Select'}} All + + + + + + {{library.name}} + + + + There are no libraries setup yet. + +