mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Merged develop into main
This commit is contained in:
commit
8a19c1da9e
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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**
|
**Describe the bug**
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
4
.github/workflows/sonar-scan.yml
vendored
4
.github/workflows/sonar-scan.yml
vendored
@ -147,6 +147,8 @@ jobs:
|
|||||||
body=${body//'%'/'%25'}
|
body=${body//'%'/'%25'}
|
||||||
body=${body//$'\n'/'%0A'}
|
body=${body//$'\n'/'%0A'}
|
||||||
body=${body//$'\r'/'%0D'}
|
body=${body//$'\r'/'%0D'}
|
||||||
|
body=${body//$'`'/'%60'}
|
||||||
|
body=${body//$'>'/'%3E'}
|
||||||
echo $body
|
echo $body
|
||||||
echo "::set-output name=BODY::$body"
|
echo "::set-output name=BODY::$body"
|
||||||
|
|
||||||
@ -249,6 +251,8 @@ jobs:
|
|||||||
body=${body//'%'/'%25'}
|
body=${body//'%'/'%25'}
|
||||||
body=${body//$'\n'/'%0A'}
|
body=${body//$'\n'/'%0A'}
|
||||||
body=${body//$'\r'/'%0D'}
|
body=${body//$'\r'/'%0D'}
|
||||||
|
body=${body//$'`'/'%60'}
|
||||||
|
body=${body//$'>'/'%3E'}
|
||||||
echo $body
|
echo $body
|
||||||
echo "::set-output name=BODY::$body"
|
echo "::set-output name=BODY::$body"
|
||||||
|
|
||||||
|
@ -58,6 +58,11 @@ namespace API.Tests.Parser
|
|||||||
[InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "2000 AD")]
|
[InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "2000 AD")]
|
||||||
[InlineData("Daredevil - v6 - 10 - (2019)", "Daredevil")]
|
[InlineData("Daredevil - v6 - 10 - (2019)", "Daredevil")]
|
||||||
[InlineData("Batman - The Man Who Laughs #1 (2005)", "Batman - The Man Who Laughs")]
|
[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)
|
public void ParseComicSeriesTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename));
|
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("Cyberpunk 2077 - Trauma Team #04.cbz", "4")]
|
||||||
[InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "366")]
|
[InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "366")]
|
||||||
[InlineData("Daredevil - v6 - 10 - (2019)", "10")]
|
[InlineData("Daredevil - v6 - 10 - (2019)", "10")]
|
||||||
|
[InlineData("Batman Beyond 2016 - Chapter 001.cbz", "1")]
|
||||||
public void ParseComicChapterTest(string filename, string expected)
|
public void ParseComicChapterTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename));
|
Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename));
|
||||||
|
@ -107,6 +107,11 @@
|
|||||||
<EmbeddedResource Remove="logs\**" />
|
<EmbeddedResource Remove="logs\**" />
|
||||||
<EmbeddedResource Remove="temp\**" />
|
<EmbeddedResource Remove="temp\**" />
|
||||||
<EmbeddedResource Remove="covers\**" />
|
<EmbeddedResource Remove="covers\**" />
|
||||||
|
<EmbeddedResource Remove="config\covers\**" />
|
||||||
|
<EmbeddedResource Remove="config\backups\**" />
|
||||||
|
<EmbeddedResource Remove="config\logs\**" />
|
||||||
|
<EmbeddedResource Remove="config\temp\**" />
|
||||||
|
<EmbeddedResource Remove="config\stats\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -115,12 +120,18 @@
|
|||||||
<Content Remove="backups\**" />
|
<Content Remove="backups\**" />
|
||||||
<Content Remove="logs\**" />
|
<Content Remove="logs\**" />
|
||||||
<Content Remove="temp\**" />
|
<Content Remove="temp\**" />
|
||||||
<Content Remove="stats\**" />
|
<Content Remove="config\stats\**" />
|
||||||
|
<Content Remove="config\cache\**" />
|
||||||
|
<Content Remove="config\backups\**" />
|
||||||
|
<Content Remove="config\logs\**" />
|
||||||
|
<Content Remove="config\temp\**" />
|
||||||
|
<Content Remove="config\stats\**" />
|
||||||
<Content Condition=" '$(Configuration)' == 'Release' " Remove="appsettings.Development.json" />
|
<Content Condition=" '$(Configuration)' == 'Release' " Remove="appsettings.Development.json" />
|
||||||
<Content Update="appsettings.json">
|
<Content Update="appsettings.json">
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
<Content Remove="covers\**" />
|
<Content Remove="covers\**" />
|
||||||
|
<Content Remove="config\covers\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
@ -95,7 +95,7 @@ namespace API.Controllers
|
|||||||
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||||
if (!settings.EnableAuthentication && !registerDto.IsAdmin)
|
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;
|
registerDto.Password = AccountService.DefaultPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ using API.Interfaces.Services;
|
|||||||
using API.Services;
|
using API.Services;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace API.Controllers
|
namespace API.Controllers
|
||||||
{
|
{
|
||||||
@ -48,7 +49,6 @@ namespace API.Controllers
|
|||||||
_cacheService = cacheService;
|
_cacheService = cacheService;
|
||||||
_readerService = readerService;
|
_readerService = readerService;
|
||||||
|
|
||||||
|
|
||||||
_xmlSerializer = new XmlSerializer(typeof(Feed));
|
_xmlSerializer = new XmlSerializer(typeof(Feed));
|
||||||
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
|
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
|
||||||
|
|
||||||
@ -62,18 +62,18 @@ namespace API.Controllers
|
|||||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||||
return BadRequest("OPDS is not enabled on this server");
|
return BadRequest("OPDS is not enabled on this server");
|
||||||
var feed = CreateFeed("Kavita", string.Empty, apiKey);
|
var feed = CreateFeed("Kavita", string.Empty, apiKey);
|
||||||
feed.Id = "root";
|
SetFeedId(feed, "root");
|
||||||
feed.Entries.Add(new FeedEntry()
|
feed.Entries.Add(new FeedEntry()
|
||||||
{
|
{
|
||||||
Id = "inProgress",
|
Id = "onDeck",
|
||||||
Title = "In Progress",
|
Title = "On Deck",
|
||||||
Content = new FeedEntryContent()
|
Content = new FeedEntryContent()
|
||||||
{
|
{
|
||||||
Text = "Browse by In Progress"
|
Text = "Browse by On Deck"
|
||||||
},
|
},
|
||||||
Links = new List<FeedLink>()
|
Links = new List<FeedLink>()
|
||||||
{
|
{
|
||||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/in-progress"),
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/on-deck"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
feed.Entries.Add(new FeedEntry()
|
feed.Entries.Add(new FeedEntry()
|
||||||
@ -140,9 +140,8 @@ namespace API.Controllers
|
|||||||
return BadRequest("OPDS is not enabled on this server");
|
return BadRequest("OPDS is not enabled on this server");
|
||||||
var userId = await GetUser(apiKey);
|
var userId = await GetUser(apiKey);
|
||||||
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
|
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
|
||||||
|
|
||||||
var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey);
|
var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey);
|
||||||
|
SetFeedId(feed, "libraries");
|
||||||
foreach (var library in libraries)
|
foreach (var library in libraries)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(new FeedEntry()
|
feed.Entries.Add(new FeedEntry()
|
||||||
@ -181,7 +180,7 @@ namespace API.Controllers
|
|||||||
|
|
||||||
|
|
||||||
var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey);
|
var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey);
|
||||||
|
SetFeedId(feed, "collections");
|
||||||
foreach (var tag in tags)
|
foreach (var tag in tags)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(new FeedEntry()
|
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));
|
return CreateXmlResult(SerializeXml(feed));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -243,6 +234,7 @@ namespace API.Controllers
|
|||||||
});
|
});
|
||||||
|
|
||||||
var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey);
|
var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey);
|
||||||
|
SetFeedId(feed, $"collections-{collectionId}");
|
||||||
AddPagination(feed, series, $"{Prefix}{apiKey}/collections/{collectionId}");
|
AddPagination(feed, series, $"{Prefix}{apiKey}/collections/{collectionId}");
|
||||||
|
|
||||||
foreach (var seriesDto in series)
|
foreach (var seriesDto in series)
|
||||||
@ -269,7 +261,7 @@ namespace API.Controllers
|
|||||||
|
|
||||||
|
|
||||||
var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey);
|
var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey);
|
||||||
|
SetFeedId(feed, "reading-list");
|
||||||
foreach (var readingListDto in readingLists)
|
foreach (var readingListDto in readingLists)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(new FeedEntry()
|
feed.Entries.Add(new FeedEntry()
|
||||||
@ -304,6 +296,7 @@ namespace API.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey);
|
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();
|
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
|
||||||
foreach (var item in items)
|
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));
|
return CreateXmlResult(SerializeXml(feed));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -355,6 +338,7 @@ namespace API.Controllers
|
|||||||
}, _filterDto);
|
}, _filterDto);
|
||||||
|
|
||||||
var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey);
|
var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey);
|
||||||
|
SetFeedId(feed, $"library-{library.Name}");
|
||||||
AddPagination(feed, series, $"{Prefix}{apiKey}/libraries/{libraryId}");
|
AddPagination(feed, series, $"{Prefix}{apiKey}/libraries/{libraryId}");
|
||||||
|
|
||||||
foreach (var seriesDto in series)
|
foreach (var seriesDto in series)
|
||||||
@ -379,6 +363,7 @@ namespace API.Controllers
|
|||||||
}, _filterDto);
|
}, _filterDto);
|
||||||
|
|
||||||
var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey);
|
var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey);
|
||||||
|
SetFeedId(feed, "recently-added");
|
||||||
AddPagination(feed, recentlyAdded, $"{Prefix}{apiKey}/recently-added");
|
AddPagination(feed, recentlyAdded, $"{Prefix}{apiKey}/recently-added");
|
||||||
|
|
||||||
foreach (var seriesDto in recentlyAdded)
|
foreach (var seriesDto in recentlyAdded)
|
||||||
@ -386,21 +371,12 @@ namespace API.Controllers
|
|||||||
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recentlyAdded.Count == 0)
|
|
||||||
{
|
|
||||||
feed.Entries.Add(new FeedEntry()
|
|
||||||
{
|
|
||||||
Title = "Nothing here",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return CreateXmlResult(SerializeXml(feed));
|
return CreateXmlResult(SerializeXml(feed));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{apiKey}/in-progress")]
|
[HttpGet("{apiKey}/on-deck")]
|
||||||
[Produces("application/xml")]
|
[Produces("application/xml")]
|
||||||
public async Task<IActionResult> GetInProgress(string apiKey, [FromQuery] int pageNumber = 1)
|
public async Task<IActionResult> GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1)
|
||||||
{
|
{
|
||||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||||
return BadRequest("OPDS is not enabled on this server");
|
return BadRequest("OPDS is not enabled on this server");
|
||||||
@ -410,29 +386,22 @@ namespace API.Controllers
|
|||||||
PageNumber = pageNumber,
|
PageNumber = pageNumber,
|
||||||
PageSize = 20
|
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)
|
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
|
||||||
.Take(userParams.PageSize).ToList();
|
.Take(userParams.PageSize).ToList();
|
||||||
var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
|
var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
|
||||||
|
|
||||||
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
|
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
|
||||||
|
|
||||||
var feed = CreateFeed("In Progress", $"{apiKey}/in-progress", apiKey);
|
var feed = CreateFeed("On Deck", $"{apiKey}/on-deck", apiKey);
|
||||||
AddPagination(feed, pagedList, $"{Prefix}{apiKey}/in-progress");
|
SetFeedId(feed, "on-deck");
|
||||||
|
AddPagination(feed, pagedList, $"{Prefix}{apiKey}/on-deck");
|
||||||
|
|
||||||
foreach (var seriesDto in pagedList)
|
foreach (var seriesDto in pagedList)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pagedList.Count == 0)
|
|
||||||
{
|
|
||||||
feed.Entries.Add(new FeedEntry()
|
|
||||||
{
|
|
||||||
Title = "Nothing here",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return CreateXmlResult(SerializeXml(feed));
|
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 series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query);
|
||||||
|
|
||||||
var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey);
|
var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey);
|
||||||
|
SetFeedId(feed, "search-series");
|
||||||
foreach (var seriesDto in series)
|
foreach (var seriesDto in series)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
|
||||||
@ -465,6 +434,11 @@ namespace API.Controllers
|
|||||||
return CreateXmlResult(SerializeXml(feed));
|
return CreateXmlResult(SerializeXml(feed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void SetFeedId(Feed feed, string id)
|
||||||
|
{
|
||||||
|
feed.Id = id;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{apiKey}/search")]
|
[HttpGet("{apiKey}/search")]
|
||||||
[Produces("application/xml")]
|
[Produces("application/xml")]
|
||||||
public async Task<IActionResult> GetSearchDescriptor(string apiKey)
|
public async Task<IActionResult> GetSearchDescriptor(string apiKey)
|
||||||
@ -498,6 +472,7 @@ namespace API.Controllers
|
|||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId);
|
var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId);
|
||||||
var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey);
|
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}"));
|
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}"));
|
||||||
foreach (var volumeDto in volumes)
|
foreach (var volumeDto in volumes)
|
||||||
{
|
{
|
||||||
@ -521,6 +496,7 @@ namespace API.Controllers
|
|||||||
_chapterSortComparer);
|
_chapterSortComparer);
|
||||||
|
|
||||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
|
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)
|
foreach (var chapter in chapters)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(new FeedEntry()
|
feed.Entries.Add(new FeedEntry()
|
||||||
@ -551,6 +527,7 @@ namespace API.Controllers
|
|||||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||||
|
|
||||||
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
|
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)
|
foreach (var mangaFile in files)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey));
|
feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey));
|
||||||
|
@ -180,7 +180,7 @@ namespace API.Controllers
|
|||||||
|
|
||||||
if (series == null) return BadRequest("Series does not exist");
|
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.");
|
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);
|
return Ok(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("in-progress")]
|
/// <summary>
|
||||||
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
|
/// Fetches series that are on deck aka have progress on them.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filterDto"></param>
|
||||||
|
/// <param name="userParams"></param>
|
||||||
|
/// <param name="libraryId">Default of 0 meaning all libraries</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpPost("on-deck")]
|
||||||
|
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
|
||||||
{
|
{
|
||||||
// NOTE: This has to be done manually like this due to the DistinctBy requirement
|
// NOTE: This has to be done manually like this due to the DistinctBy requirement
|
||||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
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)
|
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
|
||||||
.Take(userParams.PageSize).ToList();
|
.Take(userParams.PageSize).ToList();
|
||||||
|
@ -26,10 +26,11 @@ namespace API.Controllers
|
|||||||
private readonly IArchiveService _archiveService;
|
private readonly IArchiveService _archiveService;
|
||||||
private readonly ICacheService _cacheService;
|
private readonly ICacheService _cacheService;
|
||||||
private readonly IVersionUpdaterService _versionUpdaterService;
|
private readonly IVersionUpdaterService _versionUpdaterService;
|
||||||
|
private readonly IStatsService _statsService;
|
||||||
|
|
||||||
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
|
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
|
||||||
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService,
|
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService,
|
||||||
IVersionUpdaterService versionUpdaterService)
|
IVersionUpdaterService versionUpdaterService, IStatsService statsService)
|
||||||
{
|
{
|
||||||
_applicationLifetime = applicationLifetime;
|
_applicationLifetime = applicationLifetime;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@ -38,6 +39,7 @@ namespace API.Controllers
|
|||||||
_archiveService = archiveService;
|
_archiveService = archiveService;
|
||||||
_cacheService = cacheService;
|
_cacheService = cacheService;
|
||||||
_versionUpdaterService = versionUpdaterService;
|
_versionUpdaterService = versionUpdaterService;
|
||||||
|
_statsService = statsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -84,9 +86,9 @@ namespace API.Controllers
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("server-info")]
|
[HttpGet("server-info")]
|
||||||
public ActionResult<ServerInfoDto> GetVersion()
|
public async Task<ActionResult<ServerInfoDto>> GetVersion()
|
||||||
{
|
{
|
||||||
return Ok(StatsService.GetServerInfo());
|
return Ok(await _statsService.GetServerInfo());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("logs")]
|
[HttpGet("logs")]
|
||||||
|
@ -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<StatsController> _logger;
|
|
||||||
private readonly IStatsService _statsService;
|
|
||||||
|
|
||||||
public StatsController(ILogger<StatsController> logger, IStatsService statsService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_statsService = statsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpPost("client-info")]
|
|
||||||
public async Task<IActionResult> AddClientInfo([FromBody] ClientInfoDto clientInfoDto)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _statsService.RecordClientInfo(clientInfoDto);
|
|
||||||
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error updating the usage statistics");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace API.DTOs
|
namespace API.DTOs
|
||||||
{
|
{
|
||||||
@ -45,5 +46,9 @@ namespace API.DTOs
|
|||||||
/// Volume Id this Chapter belongs to
|
/// Volume Id this Chapter belongs to
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int VolumeId { get; init; }
|
public int VolumeId { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// When chapter was created
|
||||||
|
/// </summary>
|
||||||
|
public DateTime Created { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,13 +2,11 @@
|
|||||||
{
|
{
|
||||||
public class ServerInfoDto
|
public class ServerInfoDto
|
||||||
{
|
{
|
||||||
|
public string InstallId { get; set; }
|
||||||
public string Os { 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 bool IsDocker { get; set; }
|
||||||
|
public string DotnetVersion { get; set; }
|
||||||
|
public string KavitaVersion { get; set; }
|
||||||
public int NumOfCores { get; set; }
|
public int NumOfCores { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using API.Entities.Enums;
|
|
||||||
|
|
||||||
namespace API.DTOs.Stats
|
|
||||||
{
|
|
||||||
public class UsageInfoDto
|
|
||||||
{
|
|
||||||
public UsageInfoDto()
|
|
||||||
{
|
|
||||||
FileTypes = new HashSet<string>();
|
|
||||||
LibraryTypesCreated = new HashSet<LibInfo>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public int UsersCount { get; set; }
|
|
||||||
public IEnumerable<string> FileTypes { get; set; }
|
|
||||||
public IEnumerable<LibInfo> LibraryTypesCreated { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class LibInfo
|
|
||||||
{
|
|
||||||
public LibraryType Type { get; set; }
|
|
||||||
public int Count { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
@ -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<ClientInfoDto>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public string InstallId { get; set; }
|
|
||||||
public DateTime LastUpdate { get; set; }
|
|
||||||
public UsageInfoDto UsageInfo { get; set; }
|
|
||||||
public ServerInfoDto ServerInfo { get; set; }
|
|
||||||
public List<ClientInfoDto> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,6 @@
|
|||||||
namespace API.DTOs.Update
|
using System;
|
||||||
|
|
||||||
|
namespace API.DTOs.Update
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update Notification denoting a new release available for user to update to
|
/// Update Notification denoting a new release available for user to update to
|
||||||
@ -34,5 +36,9 @@
|
|||||||
/// Is this a pre-release
|
/// Is this a pre-release
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsPrerelease { get; init; }
|
public bool IsPrerelease { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Date of the publish
|
||||||
|
/// </summary>
|
||||||
|
public string PublishDate { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,8 +26,6 @@ namespace API.Data
|
|||||||
"temp"
|
"temp"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config");
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
/// 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(
|
Console.WriteLine(
|
||||||
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
|
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
|
||||||
|
|
||||||
Console.WriteLine($"Creating {ConfigDirectory}");
|
Console.WriteLine($"Creating {DirectoryService.ConfigDirectory}");
|
||||||
DirectoryService.ExistOrCreate(ConfigDirectory);
|
DirectoryService.ExistOrCreate(DirectoryService.ConfigDirectory);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -116,13 +114,13 @@ namespace API.Data
|
|||||||
|
|
||||||
foreach (var folderToMove in AppFolders)
|
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
|
try
|
||||||
{
|
{
|
||||||
DirectoryService.CopyDirectoryToDirectory(
|
DirectoryService.CopyDirectoryToDirectory(
|
||||||
Path.Join(Directory.GetCurrentDirectory(), folderToMove),
|
Path.Join(Directory.GetCurrentDirectory(), folderToMove),
|
||||||
Path.Join(ConfigDirectory, folderToMove));
|
Path.Join(DirectoryService.ConfigDirectory, folderToMove));
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
@ -144,7 +142,7 @@ namespace API.Data
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
fileInfo.CopyTo(Path.Join(ConfigDirectory, fileInfo.Name));
|
fileInfo.CopyTo(Path.Join(DirectoryService.ConfigDirectory, fileInfo.Name));
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
|
@ -7,6 +7,7 @@ using API.DTOs;
|
|||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Interfaces.Repositories;
|
using API.Interfaces.Repositories;
|
||||||
@ -47,16 +48,22 @@ namespace API.Data.Repositories
|
|||||||
_context.Series.RemoveRange(series);
|
_context.Series.RemoveRange(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> DoesSeriesNameExistInLibrary(string name)
|
/// <summary>
|
||||||
|
/// Returns if a series name and format exists already in a library
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Name of series</param>
|
||||||
|
/// <param name="format">Format of series</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<bool> DoesSeriesNameExistInLibrary(string name, MangaFormat format)
|
||||||
{
|
{
|
||||||
var libraries = _context.Series
|
var libraries = _context.Series
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.Name == name)
|
.Where(x => x.Name.Equals(name) && x.Format == format)
|
||||||
.Select(s => s.LibraryId);
|
.Select(s => s.LibraryId);
|
||||||
|
|
||||||
return await _context.Series
|
return await _context.Series
|
||||||
.AsNoTracking()
|
.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;
|
.CountAsync() > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,14 +319,15 @@ namespace API.Data.Repositories
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId"></param>
|
/// <param name="userId"></param>
|
||||||
/// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
|
/// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
|
||||||
/// <param name="userParams">Pagination information</param>
|
/// <param name="userParams">Pagination information</param>
|
||||||
/// <param name="filter">Optional (default null) filter on query</param>
|
/// <param name="filter">Optional (default null) filter on query</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter)
|
public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
|
||||||
{
|
{
|
||||||
var formats = filter.GetSqlFilter();
|
var formats = filter.GetSqlFilter();
|
||||||
IList<int> userLibraries;
|
IList<int> userLibraries;
|
||||||
@ -352,6 +360,7 @@ namespace API.Data.Repositories
|
|||||||
&& s.PagesRead > 0
|
&& s.PagesRead > 0
|
||||||
&& s.PagesRead < s.Series.Pages)
|
&& s.PagesRead < s.Series.Pages)
|
||||||
.OrderByDescending(s => s.LastModified)
|
.OrderByDescending(s => s.LastModified)
|
||||||
|
.ThenByDescending(s => s.Series.LastModified)
|
||||||
.Select(s => s.Series)
|
.Select(s => s.Series)
|
||||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
|
@ -41,7 +41,7 @@ namespace API.Data
|
|||||||
|
|
||||||
IList<ServerSetting> defaultSettings = new List<ServerSetting>()
|
IList<ServerSetting> defaultSettings = new List<ServerSetting>()
|
||||||
{
|
{
|
||||||
new() {Key = ServerSettingKey.CacheDirectory, Value = DirectoryService.CacheDirectory},
|
new () {Key = ServerSettingKey.CacheDirectory, Value = DirectoryService.CacheDirectory},
|
||||||
new () {Key = ServerSettingKey.TaskScan, Value = "daily"},
|
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.LoggingLevel, Value = "Information"}, // Not used from DB, but DB is sync with appSettings.json
|
||||||
new () {Key = ServerSettingKey.TaskBackup, Value = "weekly"},
|
new () {Key = ServerSettingKey.TaskBackup, Value = "weekly"},
|
||||||
@ -51,6 +51,7 @@ namespace API.Data
|
|||||||
new () {Key = ServerSettingKey.EnableOpds, Value = "false"},
|
new () {Key = ServerSettingKey.EnableOpds, Value = "false"},
|
||||||
new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"},
|
new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"},
|
||||||
new () {Key = ServerSettingKey.BaseUrl, Value = "/"},
|
new () {Key = ServerSettingKey.BaseUrl, Value = "/"},
|
||||||
|
new () {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var defaultSetting in defaultSettings)
|
foreach (var defaultSetting in defaultSettings)
|
||||||
@ -71,6 +72,8 @@ namespace API.Data
|
|||||||
Configuration.LogLevel + string.Empty;
|
Configuration.LogLevel + string.Empty;
|
||||||
context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value =
|
context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value =
|
||||||
DirectoryService.CacheDirectory + string.Empty;
|
DirectoryService.CacheDirectory + string.Empty;
|
||||||
|
context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value =
|
||||||
|
DirectoryService.BackupDirectory + string.Empty;
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ namespace API.Entities
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manga Reader Option: Which side of a split image should we show first
|
/// Manga Reader Option: Which side of a split image should we show first
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.SplitRightToLeft;
|
public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.FitSplit;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manga Reader Option: How the manga reader should perform paging or reading of the file
|
/// Manga Reader Option: How the manga reader should perform paging or reading of the file
|
||||||
/// <example>
|
/// <example>
|
||||||
@ -25,14 +25,15 @@ namespace API.Entities
|
|||||||
/// </example>
|
/// </example>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ReaderMode ReaderMode { get; set; }
|
public ReaderMode ReaderMode { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
|
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AutoCloseMenu { get; set; }
|
public bool AutoCloseMenu { get; set; } = true;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Book Reader Option: Should the background color be dark
|
/// Book Reader Option: Should the background color be dark
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool BookReaderDarkMode { get; set; } = false;
|
public bool BookReaderDarkMode { get; set; } = true;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Book Reader Option: Override extra Margin
|
/// Book Reader Option: Override extra Margin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
{
|
{
|
||||||
SplitLeftToRight = 0,
|
SplitLeftToRight = 0,
|
||||||
SplitRightToLeft = 1,
|
SplitRightToLeft = 1,
|
||||||
NoSplit = 2
|
NoSplit = 2,
|
||||||
|
FitSplit = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -4,26 +4,61 @@ namespace API.Entities.Enums
|
|||||||
{
|
{
|
||||||
public enum ServerSettingKey
|
public enum ServerSettingKey
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cron format for how often full library scans are performed.
|
||||||
|
/// </summary>
|
||||||
[Description("TaskScan")]
|
[Description("TaskScan")]
|
||||||
TaskScan = 0,
|
TaskScan = 0,
|
||||||
|
/// <summary>
|
||||||
|
/// Where files are cached. Not currently used.
|
||||||
|
/// </summary>
|
||||||
[Description("CacheDirectory")]
|
[Description("CacheDirectory")]
|
||||||
CacheDirectory = 1,
|
CacheDirectory = 1,
|
||||||
|
/// <summary>
|
||||||
|
/// Cron format for how often backups are taken.
|
||||||
|
/// </summary>
|
||||||
[Description("TaskBackup")]
|
[Description("TaskBackup")]
|
||||||
TaskBackup = 2,
|
TaskBackup = 2,
|
||||||
|
/// <summary>
|
||||||
|
/// Logging level for Server. Not managed in DB. Managed in appsettings.json and synced to DB.
|
||||||
|
/// </summary>
|
||||||
[Description("LoggingLevel")]
|
[Description("LoggingLevel")]
|
||||||
LoggingLevel = 3,
|
LoggingLevel = 3,
|
||||||
|
/// <summary>
|
||||||
|
/// Port server listens on. Not managed in DB. Managed in appsettings.json and synced to DB.
|
||||||
|
/// </summary>
|
||||||
[Description("Port")]
|
[Description("Port")]
|
||||||
Port = 4,
|
Port = 4,
|
||||||
|
/// <summary>
|
||||||
|
/// Where the backups are stored.
|
||||||
|
/// </summary>
|
||||||
[Description("BackupDirectory")]
|
[Description("BackupDirectory")]
|
||||||
BackupDirectory = 5,
|
BackupDirectory = 5,
|
||||||
|
/// <summary>
|
||||||
|
/// Allow anonymous data to be reported to KavitaStats
|
||||||
|
/// </summary>
|
||||||
[Description("AllowStatCollection")]
|
[Description("AllowStatCollection")]
|
||||||
AllowStatCollection = 6,
|
AllowStatCollection = 6,
|
||||||
|
/// <summary>
|
||||||
|
/// Is OPDS enabled for the server
|
||||||
|
/// </summary>
|
||||||
[Description("EnableOpds")]
|
[Description("EnableOpds")]
|
||||||
EnableOpds = 7,
|
EnableOpds = 7,
|
||||||
|
/// <summary>
|
||||||
|
/// Is Authentication needed for non-admin accounts
|
||||||
|
/// </summary>
|
||||||
[Description("EnableAuthentication")]
|
[Description("EnableAuthentication")]
|
||||||
EnableAuthentication = 8,
|
EnableAuthentication = 8,
|
||||||
|
/// <summary>
|
||||||
|
/// Base Url for the server. Not Implemented.
|
||||||
|
/// </summary>
|
||||||
[Description("BaseUrl")]
|
[Description("BaseUrl")]
|
||||||
BaseUrl = 9
|
BaseUrl = 9,
|
||||||
|
/// <summary>
|
||||||
|
/// Represents this installation of Kavita. Is tied to Stat reporting but has no information about user or files.
|
||||||
|
/// </summary>
|
||||||
|
[Description("InstallId")]
|
||||||
|
InstallId = 10
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ namespace API.Entities
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string SortName { get; set; }
|
public string SortName { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Name in Japanese. By default, will be same as Name.
|
/// Name in original language (Japanese for Manga). By default, will be same as Name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string LocalizedName { get; set; }
|
public string LocalizedName { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -17,6 +17,6 @@ namespace API.Interfaces
|
|||||||
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
|
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
|
||||||
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||||
void CancelStatsTasks();
|
void CancelStatsTasks();
|
||||||
void RunStatCollection();
|
Task RunStatCollection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ using API.Data.Scanner;
|
|||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
|
using API.Entities.Enums;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
|
|
||||||
namespace API.Interfaces.Repositories
|
namespace API.Interfaces.Repositories
|
||||||
@ -14,7 +15,7 @@ namespace API.Interfaces.Repositories
|
|||||||
void Update(Series series);
|
void Update(Series series);
|
||||||
void Remove(Series series);
|
void Remove(Series series);
|
||||||
void Remove(IEnumerable<Series> series);
|
void Remove(IEnumerable<Series> series);
|
||||||
Task<bool> DoesSeriesNameExistInLibrary(string name);
|
Task<bool> DoesSeriesNameExistInLibrary(string name, MangaFormat format);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds user information like progress, ratings, etc
|
/// Adds user information like progress, ratings, etc
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -45,7 +46,7 @@ namespace API.Interfaces.Repositories
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Task AddSeriesModifiers(int userId, List<SeriesDto> series);
|
Task AddSeriesModifiers(int userId, List<SeriesDto> series);
|
||||||
Task<string> GetSeriesCoverImageAsync(int seriesId);
|
Task<string> GetSeriesCoverImageAsync(int seriesId);
|
||||||
Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter);
|
Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
|
||||||
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo
|
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo
|
||||||
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
|
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
|
||||||
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
|
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
|
||||||
|
@ -5,7 +5,7 @@ namespace API.Interfaces.Services
|
|||||||
{
|
{
|
||||||
public interface IStatsService
|
public interface IStatsService
|
||||||
{
|
{
|
||||||
Task RecordClientInfo(ClientInfoDto clientInfoDto);
|
|
||||||
Task Send();
|
Task Send();
|
||||||
|
Task<ServerInfoDto> GetServerInfo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -258,19 +258,19 @@ namespace API.Parser
|
|||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout),
|
||||||
// Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
|
// Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
|
||||||
new Regex(
|
new Regex(
|
||||||
@"^(?<Series>.*)(?: |_)v\d+",
|
@"^(?<Series>.+?)(?: |_)v\d+",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout),
|
||||||
// Amazing Man Comics chapter 25
|
// Amazing Man Comics chapter 25
|
||||||
new Regex(
|
new Regex(
|
||||||
@"^(?<Series>.*)(?: |_)c(hapter) \d+",
|
@"^(?<Series>.+?)(?: |_)c(hapter) \d+",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout),
|
||||||
// Amazing Man Comics issue #25
|
// Amazing Man Comics issue #25
|
||||||
new Regex(
|
new Regex(
|
||||||
@"^(?<Series>.*)(?: |_)i(ssue) #\d+",
|
@"^(?<Series>.+?)(?: |_)i(ssue) #\d+",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout),
|
||||||
// Batman Wayne Family Adventures - Ep. 001 - Moving In
|
// Batman Wayne Family Adventures - Ep. 001 - Moving In
|
||||||
new Regex(
|
new Regex(
|
||||||
@"^(?<Series>.+?)(\s|_|-)?(?:Ep\.?)(\s|_|-)+\d+",
|
@"^(?<Series>.+?)(\s|_|-)(?:Ep\.?)(\s|_|-)+\d+",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout),
|
||||||
// Batgirl Vol.2000 #57 (December, 2004)
|
// Batgirl Vol.2000 #57 (December, 2004)
|
||||||
new Regex(
|
new Regex(
|
||||||
@ -286,7 +286,7 @@ namespace API.Parser
|
|||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout),
|
||||||
// Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)
|
// Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)
|
||||||
new Regex(
|
new Regex(
|
||||||
@"^(?<Series>.*)(?: |_)(?<Volume>\d+)",
|
@"^(?<Series>.+?)(?: |_)(?<Chapter>\d+)",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout),
|
||||||
// The First Asterix Frieze (WebP by Doc MaKS)
|
// The First Asterix Frieze (WebP by Doc MaKS)
|
||||||
new Regex(
|
new Regex(
|
||||||
@ -336,9 +336,13 @@ namespace API.Parser
|
|||||||
new Regex(
|
new Regex(
|
||||||
@"^(?<Series>.+?)(?:\s|_)#(?<Chapter>\d+)",
|
@"^(?<Series>.+?)(?:\s|_)#(?<Chapter>\d+)",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout),
|
||||||
|
// Batman 2016 - Chapter 01, Batman 2016 - Issue 01, Batman 2016 - Issue #01
|
||||||
|
new Regex(
|
||||||
|
@"^(?<Series>.+?)((c(hapter)?)|issue)(_|\s)#?(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)",
|
||||||
|
MatchOptions, RegexTimeout),
|
||||||
// Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr
|
// Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr
|
||||||
new Regex(
|
new Regex(
|
||||||
@"^(?<Series>.+?)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-",
|
@"^(?<Series>.+?)(?:\s|_)(c? ?(chapter)?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-",
|
||||||
MatchOptions, RegexTimeout),
|
MatchOptions, RegexTimeout),
|
||||||
// Batgirl Vol.2000 #57 (December, 2004)
|
// Batgirl Vol.2000 #57 (December, 2004)
|
||||||
new Regex(
|
new Regex(
|
||||||
@ -883,7 +887,7 @@ namespace API.Parser
|
|||||||
{
|
{
|
||||||
if (match.Success)
|
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)
|
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)
|
if (match.Success)
|
||||||
{
|
{
|
||||||
title = title.Replace(match.Value, "");
|
title = title.Replace(match.Value, string.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -138,6 +138,22 @@ namespace API.Services
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(nonNestedFile)) return nonNestedFile;
|
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
|
var result = fullNames
|
||||||
.OrderBy(Path.GetFileName, new NaturalSortComparer())
|
.OrderBy(Path.GetFileName, new NaturalSortComparer())
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
@ -159,7 +175,7 @@ namespace API.Services
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public string GetCoverImage(string archivePath, string fileName)
|
public string GetCoverImage(string archivePath, string fileName)
|
||||||
{
|
{
|
||||||
if (archivePath == null || !IsValidArchive(archivePath)) return String.Empty;
|
if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var libraryHandler = CanOpen(archivePath);
|
var libraryHandler = CanOpen(archivePath);
|
||||||
|
@ -30,6 +30,7 @@ namespace API.Services
|
|||||||
private readonly ILogger<BookService> _logger;
|
private readonly ILogger<BookService> _logger;
|
||||||
private readonly StylesheetParser _cssParser = new ();
|
private readonly StylesheetParser _cssParser = new ();
|
||||||
private static readonly RecyclableMemoryStreamManager StreamManager = new ();
|
private static readonly RecyclableMemoryStreamManager StreamManager = new ();
|
||||||
|
private const string CssScopeClass = ".book-content";
|
||||||
|
|
||||||
public BookService(ILogger<BookService> logger)
|
public BookService(ILogger<BookService> logger)
|
||||||
{
|
{
|
||||||
@ -152,22 +153,23 @@ namespace API.Services
|
|||||||
EscapeCssImageReferences(ref stylesheetHtml, apiBase, book);
|
EscapeCssImageReferences(ref stylesheetHtml, apiBase, book);
|
||||||
|
|
||||||
var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml);
|
var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml);
|
||||||
styleContent = styleContent.Replace("body", ".reading-section");
|
|
||||||
|
styleContent = styleContent.Replace("body", CssScopeClass);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(styleContent)) return string.Empty;
|
if (string.IsNullOrEmpty(styleContent)) return string.Empty;
|
||||||
|
|
||||||
var stylesheet = await _cssParser.ParseAsync(styleContent);
|
var stylesheet = await _cssParser.ParseAsync(styleContent);
|
||||||
foreach (var styleRule in stylesheet.StyleRules)
|
foreach (var styleRule in stylesheet.StyleRules)
|
||||||
{
|
{
|
||||||
if (styleRule.Selector.Text == ".reading-section") continue;
|
if (styleRule.Selector.Text == CssScopeClass) continue;
|
||||||
if (styleRule.Selector.Text.Contains(","))
|
if (styleRule.Selector.Text.Contains(","))
|
||||||
{
|
{
|
||||||
styleRule.Text = styleRule.Text.Replace(styleRule.SelectorText,
|
styleRule.Text = styleRule.Text.Replace(styleRule.SelectorText,
|
||||||
string.Join(", ",
|
string.Join(", ",
|
||||||
styleRule.Selector.Text.Split(",").Select(s => ".reading-section " + s)));
|
styleRule.Selector.Text.Split(",").Select(s => $"{CssScopeClass} " + s)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
styleRule.Text = ".reading-section " + styleRule.Text;
|
styleRule.Text = $"{CssScopeClass} " + styleRule.Text;
|
||||||
}
|
}
|
||||||
return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss());
|
return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss());
|
||||||
}
|
}
|
||||||
@ -371,7 +373,7 @@ namespace API.Services
|
|||||||
FullFilePath = filePath,
|
FullFilePath = filePath,
|
||||||
IsSpecial = false,
|
IsSpecial = false,
|
||||||
Series = series.Trim(),
|
Series = series.Trim(),
|
||||||
Volumes = seriesIndex.Split(".")[0]
|
Volumes = seriesIndex
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@ namespace API.Services
|
|||||||
public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "cache");
|
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 CoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "covers");
|
||||||
public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups");
|
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<DirectoryService> logger)
|
public DirectoryService(ILogger<DirectoryService> logger)
|
||||||
{
|
{
|
||||||
@ -173,7 +173,15 @@ namespace API.Services
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the root path of a path exists or not.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static bool IsDriveMounted(string path)
|
||||||
|
{
|
||||||
|
return new DirectoryInfo(Path.GetPathRoot(path) ?? string.Empty).Exists;
|
||||||
|
}
|
||||||
|
|
||||||
public static string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
|
public static string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
|
||||||
{
|
{
|
||||||
@ -257,7 +265,7 @@ namespace API.Services
|
|||||||
/// <param name="directoryPath"></param>
|
/// <param name="directoryPath"></param>
|
||||||
/// <param name="prepend">An optional string to prepend to the target file's name</param>
|
/// <param name="prepend">An optional string to prepend to the target file's name</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
|
public static bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "", ILogger logger = null)
|
||||||
{
|
{
|
||||||
ExistOrCreate(directoryPath);
|
ExistOrCreate(directoryPath);
|
||||||
string currentFile = null;
|
string currentFile = null;
|
||||||
@ -273,19 +281,24 @@ namespace API.Services
|
|||||||
}
|
}
|
||||||
else
|
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)
|
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 false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
|
||||||
|
{
|
||||||
|
return CopyFilesToDirectory(filePaths, directoryPath, prepend, _logger);
|
||||||
|
}
|
||||||
|
|
||||||
public IEnumerable<string> ListDirectory(string rootPath)
|
public IEnumerable<string> ListDirectory(string rootPath)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
|
if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
|
||||||
|
@ -29,7 +29,7 @@ namespace API.Services.HostedServices
|
|||||||
// These methods will automatically check if stat collection is disabled to prevent sending any data regardless
|
// These methods will automatically check if stat collection is disabled to prevent sending any data regardless
|
||||||
// of when setting was changed
|
// of when setting was changed
|
||||||
await taskScheduler.ScheduleStatsTasks();
|
await taskScheduler.ScheduleStatsTasks();
|
||||||
taskScheduler.RunStatCollection();
|
await taskScheduler.RunStatCollection();
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
|
@ -218,14 +218,18 @@ namespace API.Services
|
|||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
var totalTime = 0L;
|
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);
|
_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;
|
if (chunkInfo.TotalChunks == 0) continue;
|
||||||
totalTime += stopwatch.ElapsedMilliseconds;
|
totalTime += stopwatch.ElapsedMilliseconds;
|
||||||
stopwatch.Restart();
|
stopwatch.Restart();
|
||||||
_logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}",
|
_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);
|
chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize);
|
||||||
|
|
||||||
var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id,
|
var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id,
|
||||||
new UserParams()
|
new UserParams()
|
||||||
{
|
{
|
||||||
@ -233,6 +237,7 @@ namespace API.Services
|
|||||||
PageSize = chunkInfo.ChunkSize
|
PageSize = chunkInfo.ChunkSize
|
||||||
});
|
});
|
||||||
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
|
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
|
||||||
|
|
||||||
Parallel.ForEach(nonLibrarySeries, series =>
|
Parallel.ForEach(nonLibrarySeries, series =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -275,8 +280,14 @@ namespace API.Services
|
|||||||
"[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
|
"[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);
|
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);
|
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,9 +23,9 @@ namespace API.Services
|
|||||||
|
|
||||||
private readonly IStatsService _statsService;
|
private readonly IStatsService _statsService;
|
||||||
private readonly IVersionUpdaterService _versionUpdaterService;
|
private readonly IVersionUpdaterService _versionUpdaterService;
|
||||||
private const string SendDataTask = "finalize-stats";
|
|
||||||
|
|
||||||
public static BackgroundJobServer Client => new BackgroundJobServer();
|
public static BackgroundJobServer Client => new BackgroundJobServer();
|
||||||
|
private static readonly Random Rnd = new Random();
|
||||||
|
|
||||||
|
|
||||||
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
|
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
|
||||||
@ -73,7 +73,6 @@ namespace API.Services
|
|||||||
|
|
||||||
RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local);
|
RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local);
|
||||||
|
|
||||||
RecurringJob.AddOrUpdate("check-for-updates", () => _scannerService.ScanLibraries(), Cron.Daily, TimeZoneInfo.Local);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region StatsTasks
|
#region StatsTasks
|
||||||
@ -89,19 +88,27 @@ namespace API.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Scheduling stat collection daily");
|
_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()
|
public void CancelStatsTasks()
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Cancelling/Removing StatsTasks");
|
_logger.LogDebug("Cancelling/Removing StatsTasks");
|
||||||
|
|
||||||
RecurringJob.RemoveIfExists(SendDataTask);
|
RecurringJob.RemoveIfExists("report-stats");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RunStatCollection()
|
/// <summary>
|
||||||
|
/// First time run stat collection. Executes immediately on a background thread. Does not block.
|
||||||
|
/// </summary>
|
||||||
|
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());
|
BackgroundJob.Enqueue(() => _statsService.Send());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,8 +119,8 @@ namespace API.Services
|
|||||||
public void ScheduleUpdaterTasks()
|
public void ScheduleUpdaterTasks()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Scheduling Auto-Update tasks");
|
_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
|
#endregion
|
||||||
|
|
||||||
|
@ -8,8 +8,9 @@ using API.Entities.Enums;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Interfaces;
|
using API.Interfaces;
|
||||||
using API.Interfaces.Services;
|
using API.Interfaces.Services;
|
||||||
|
using API.SignalR;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Kavita.Common.EnvironmentInfo;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@ -20,45 +21,32 @@ namespace API.Services.Tasks
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ILogger<BackupService> _logger;
|
private readonly ILogger<BackupService> _logger;
|
||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
private readonly string _tempDirectory = DirectoryService.TempDirectory;
|
private readonly IHubContext<MessageHub> _messageHub;
|
||||||
private readonly string _logDirectory = DirectoryService.LogDirectory;
|
|
||||||
|
|
||||||
private readonly IList<string> _backupFiles;
|
private readonly IList<string> _backupFiles;
|
||||||
|
|
||||||
public BackupService(IUnitOfWork unitOfWork, ILogger<BackupService> logger, IDirectoryService directoryService, IConfiguration config)
|
public BackupService(IUnitOfWork unitOfWork, ILogger<BackupService> logger,
|
||||||
|
IDirectoryService directoryService, IConfiguration config, IHubContext<MessageHub> messageHub)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_directoryService = directoryService;
|
_directoryService = directoryService;
|
||||||
|
_messageHub = messageHub;
|
||||||
|
|
||||||
var maxRollingFiles = config.GetMaxRollingFiles();
|
var maxRollingFiles = config.GetMaxRollingFiles();
|
||||||
var loggingSection = config.GetLoggingFileName();
|
var loggingSection = config.GetLoggingFileName();
|
||||||
var files = LogFiles(maxRollingFiles, loggingSection);
|
var files = LogFiles(maxRollingFiles, loggingSection);
|
||||||
|
|
||||||
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
|
||||||
|
_backupFiles = new List<string>()
|
||||||
{
|
{
|
||||||
_backupFiles = new List<string>()
|
"appsettings.json",
|
||||||
{
|
"Hangfire.db", // This is not used atm
|
||||||
"data/appsettings.json",
|
"Hangfire-log.db", // This is not used atm
|
||||||
"data/Hangfire.db",
|
"kavita.db",
|
||||||
"data/Hangfire-log.db",
|
"kavita.db-shm", // This wont always be there
|
||||||
"data/kavita.db",
|
"kavita.db-wal" // This wont always be there
|
||||||
"data/kavita.db-shm", // This wont always be there
|
};
|
||||||
"data/kavita.db-wal" // This wont always be there
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_backupFiles = new List<string>()
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList())
|
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 fi = new FileInfo(logFileName);
|
||||||
|
|
||||||
var files = maxRollingFiles > 0
|
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"};
|
: new[] {"kavita.log"};
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
@ -89,11 +77,13 @@ namespace API.Services.Tasks
|
|||||||
_logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory);
|
_logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory);
|
||||||
if (!DirectoryService.ExistOrCreate(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;
|
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");
|
var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip");
|
||||||
|
|
||||||
if (File.Exists(zipPath))
|
if (File.Exists(zipPath))
|
||||||
@ -102,15 +92,19 @@ namespace API.Services.Tasks
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tempDirectory = Path.Join(_tempDirectory, dateString);
|
var tempDirectory = Path.Join(DirectoryService.TempDirectory, dateString);
|
||||||
DirectoryService.ExistOrCreate(tempDirectory);
|
DirectoryService.ExistOrCreate(tempDirectory);
|
||||||
DirectoryService.ClearDirectory(tempDirectory);
|
DirectoryService.ClearDirectory(tempDirectory);
|
||||||
|
|
||||||
_directoryService.CopyFilesToDirectory(
|
_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 CopyCoverImagesToBackupDirectory(tempDirectory);
|
||||||
|
|
||||||
|
await SendProgress(0.75F);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ZipFile.CreateFromDirectory(tempDirectory, zipPath);
|
ZipFile.CreateFromDirectory(tempDirectory, zipPath);
|
||||||
@ -122,6 +116,7 @@ namespace API.Services.Tasks
|
|||||||
|
|
||||||
DirectoryService.ClearAndDeleteDirectory(tempDirectory);
|
DirectoryService.ClearAndDeleteDirectory(tempDirectory);
|
||||||
_logger.LogInformation("Database backup completed");
|
_logger.LogInformation("Database backup completed");
|
||||||
|
await SendProgress(1F);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept.
|
/// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Interfaces;
|
using API.Interfaces;
|
||||||
using API.Interfaces.Services;
|
using API.Interfaces.Services;
|
||||||
|
using API.SignalR;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace API.Services.Tasks
|
namespace API.Services.Tasks
|
||||||
@ -16,14 +18,16 @@ namespace API.Services.Tasks
|
|||||||
private readonly ILogger<CleanupService> _logger;
|
private readonly ILogger<CleanupService> _logger;
|
||||||
private readonly IBackupService _backupService;
|
private readonly IBackupService _backupService;
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly IHubContext<MessageHub> _messageHub;
|
||||||
|
|
||||||
public CleanupService(ICacheService cacheService, ILogger<CleanupService> logger,
|
public CleanupService(ICacheService cacheService, ILogger<CleanupService> logger,
|
||||||
IBackupService backupService, IUnitOfWork unitOfWork)
|
IBackupService backupService, IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub)
|
||||||
{
|
{
|
||||||
_cacheService = cacheService;
|
_cacheService = cacheService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_backupService = backupService;
|
_backupService = backupService;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
|
_messageHub = messageHub;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CleanupCacheDirectory()
|
public void CleanupCacheDirectory()
|
||||||
@ -39,19 +43,31 @@ namespace API.Services.Tasks
|
|||||||
public async Task Cleanup()
|
public async Task Cleanup()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Starting Cleanup");
|
_logger.LogInformation("Starting Cleanup");
|
||||||
|
await SendProgress(0F);
|
||||||
_logger.LogInformation("Cleaning temp directory");
|
_logger.LogInformation("Cleaning temp directory");
|
||||||
var tempDirectory = DirectoryService.TempDirectory;
|
DirectoryService.ClearDirectory(DirectoryService.TempDirectory);
|
||||||
DirectoryService.ClearDirectory(tempDirectory);
|
await SendProgress(0.1F);
|
||||||
CleanupCacheDirectory();
|
CleanupCacheDirectory();
|
||||||
|
await SendProgress(0.25F);
|
||||||
_logger.LogInformation("Cleaning old database backups");
|
_logger.LogInformation("Cleaning old database backups");
|
||||||
_backupService.CleanupBackups();
|
_backupService.CleanupBackups();
|
||||||
|
await SendProgress(0.50F);
|
||||||
_logger.LogInformation("Cleaning deleted cover images");
|
_logger.LogInformation("Cleaning deleted cover images");
|
||||||
await DeleteSeriesCoverImages();
|
await DeleteSeriesCoverImages();
|
||||||
|
await SendProgress(0.6F);
|
||||||
await DeleteChapterCoverImages();
|
await DeleteChapterCoverImages();
|
||||||
|
await SendProgress(0.7F);
|
||||||
await DeleteTagCoverImages();
|
await DeleteTagCoverImages();
|
||||||
|
await SendProgress(1F);
|
||||||
_logger.LogInformation("Cleanup finished");
|
_logger.LogInformation("Cleanup finished");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SendProgress(float progress)
|
||||||
|
{
|
||||||
|
await _messageHub.Clients.All.SendAsync(SignalREvents.CleanupProgress,
|
||||||
|
MessageFactory.CleanupProgressEvent(progress));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DeleteSeriesCoverImages()
|
private async Task DeleteSeriesCoverImages()
|
||||||
{
|
{
|
||||||
var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync();
|
var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync();
|
||||||
|
@ -56,6 +56,14 @@ namespace API.Services.Tasks
|
|||||||
var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
|
var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
|
||||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders);
|
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders);
|
||||||
var folderPaths = library.Folders.Select(f => f.Path).ToList();
|
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());
|
var dirs = DirectoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList());
|
||||||
|
|
||||||
_logger.LogInformation("Beginning file scan on {SeriesName}", series.Name);
|
_logger.LogInformation("Beginning file scan on {SeriesName}", series.Name);
|
||||||
@ -129,8 +137,7 @@ namespace API.Services.Tasks
|
|||||||
await _unitOfWork.RollbackAsync();
|
await _unitOfWork.RollbackAsync();
|
||||||
}
|
}
|
||||||
// Tell UI that this series is done
|
// Tell UI that this series is done
|
||||||
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name),
|
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), token);
|
||||||
cancellationToken: token);
|
|
||||||
await CleanupDbEntities();
|
await CleanupDbEntities();
|
||||||
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
|
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
|
||||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false));
|
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false));
|
||||||
@ -195,6 +202,14 @@ namespace API.Services.Tasks
|
|||||||
return;
|
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);
|
_logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name);
|
||||||
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
|
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
|
||||||
MessageFactory.ScanLibraryProgressEvent(libraryId, 0));
|
MessageFactory.ScanLibraryProgressEvent(libraryId, 0));
|
||||||
@ -228,7 +243,7 @@ namespace API.Services.Tasks
|
|||||||
|
|
||||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false));
|
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false));
|
||||||
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
|
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
|
||||||
MessageFactory.ScanLibraryProgressEvent(libraryId, 100));
|
MessageFactory.ScanLibraryProgressEvent(libraryId, 1F));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -326,7 +341,7 @@ namespace API.Services.Tasks
|
|||||||
await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id));
|
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,
|
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
|
||||||
MessageFactory.ScanLibraryProgressEvent(library.Id, progress));
|
MessageFactory.ScanLibraryProgressEvent(library.Id, progress));
|
||||||
}
|
}
|
||||||
@ -343,15 +358,14 @@ namespace API.Services.Tasks
|
|||||||
// Key is normalized already
|
// Key is normalized already
|
||||||
Series existingSeries;
|
Series existingSeries;
|
||||||
try
|
try
|
||||||
{// NOTE: Maybe use .Equals() here
|
{
|
||||||
existingSeries = allSeries.SingleOrDefault(s =>
|
existingSeries = allSeries.SingleOrDefault(s => FindSeries(s, key));
|
||||||
(s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName)
|
|
||||||
&& (s.Format == key.Format || s.Format == MangaFormat.Unknown));
|
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
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);
|
_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)
|
foreach (var series in duplicateSeries)
|
||||||
{
|
{
|
||||||
_logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName);
|
_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;
|
if (existingSeries != null) continue;
|
||||||
|
|
||||||
existingSeries = DbFactory.Series(infos[0].Series);
|
var s = DbFactory.Series(infos[0].Series);
|
||||||
existingSeries.Format = key.Format;
|
s.Format = key.Format;
|
||||||
newSeries.Add(existingSeries);
|
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;
|
var i = 0;
|
||||||
foreach(var series in newSeries)
|
foreach(var series in newSeries)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
||||||
|
UpdateSeries(series, parsedSeries);
|
||||||
|
_unitOfWork.SeriesRepository.Attach(series);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
await _unitOfWork.CommitAsync();
|
||||||
UpdateVolumes(series, ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray());
|
_logger.LogInformation(
|
||||||
series.Pages = series.Volumes.Sum(v => v.Pages);
|
"[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
|
||||||
series.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series.
|
newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name);
|
||||||
_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);
|
|
||||||
|
|
||||||
// Inform UI of new series added
|
// Inform UI of new series added
|
||||||
await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id));
|
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++;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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(
|
_logger.LogInformation(
|
||||||
@ -409,13 +415,19 @@ namespace API.Services.Tasks
|
|||||||
newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name);
|
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, List<ParserInfo>> parsedSeries)
|
private void UpdateSeries(Series series, Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
||||||
|
|
||||||
var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray();
|
var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series);
|
||||||
UpdateVolumes(series, parsedInfos);
|
UpdateVolumes(series, parsedInfos);
|
||||||
series.Pages = series.Volumes.Sum(v => v.Pages);
|
series.Pages = series.Volumes.Sum(v => v.Pages);
|
||||||
|
|
||||||
@ -482,7 +494,7 @@ namespace API.Services.Tasks
|
|||||||
/// <param name="missingSeries">Series not found on disk or can't be parsed</param>
|
/// <param name="missingSeries">Series not found on disk or can't be parsed</param>
|
||||||
/// <param name="removeCount"></param>
|
/// <param name="removeCount"></param>
|
||||||
/// <returns>the updated existingSeries</returns>
|
/// <returns>the updated existingSeries</returns>
|
||||||
public static IList<Series> RemoveMissingSeries(IList<Series> existingSeries, IEnumerable<Series> missingSeries, out int removeCount)
|
public static IEnumerable<Series> RemoveMissingSeries(IList<Series> existingSeries, IEnumerable<Series> missingSeries, out int removeCount)
|
||||||
{
|
{
|
||||||
var existingCount = existingSeries.Count;
|
var existingCount = existingSeries.Count;
|
||||||
var missingList = missingSeries.ToList();
|
var missingList = missingSeries.ToList();
|
||||||
@ -496,7 +508,7 @@ namespace API.Services.Tasks
|
|||||||
return existingSeries;
|
return existingSeries;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateVolumes(Series series, ParserInfo[] parsedInfos)
|
private void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos)
|
||||||
{
|
{
|
||||||
var startingVolumeCount = series.Volumes.Count;
|
var startingVolumeCount = series.Volumes.Count;
|
||||||
// Add new volumes and update chapters per volume
|
// Add new volumes and update chapters per volume
|
||||||
@ -550,7 +562,7 @@ namespace API.Services.Tasks
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="volume"></param>
|
/// <param name="volume"></param>
|
||||||
/// <param name="parsedInfos"></param>
|
/// <param name="parsedInfos"></param>
|
||||||
private void UpdateChapters(Volume volume, ParserInfo[] parsedInfos)
|
private void UpdateChapters(Volume volume, IList<ParserInfo> parsedInfos)
|
||||||
{
|
{
|
||||||
// Add new chapters
|
// Add new chapters
|
||||||
foreach (var info in parsedInfos)
|
foreach (var info in parsedInfos)
|
||||||
|
@ -1,46 +1,31 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
|
||||||
using API.DTOs.Stats;
|
using API.DTOs.Stats;
|
||||||
|
using API.Entities.Enums;
|
||||||
using API.Interfaces;
|
using API.Interfaces;
|
||||||
using API.Interfaces.Services;
|
using API.Interfaces.Services;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
using Hangfire;
|
|
||||||
using Kavita.Common;
|
|
||||||
using Kavita.Common.EnvironmentInfo;
|
using Kavita.Common.EnvironmentInfo;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace API.Services.Tasks
|
namespace API.Services.Tasks
|
||||||
{
|
{
|
||||||
public class StatsService : IStatsService
|
public class StatsService : IStatsService
|
||||||
{
|
{
|
||||||
private const string StatFileName = "app_stats.json";
|
|
||||||
|
|
||||||
private readonly DataContext _dbContext;
|
|
||||||
private readonly ILogger<StatsService> _logger;
|
private readonly ILogger<StatsService> _logger;
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private const string ApiUrl = "https://stats.kavitareader.com";
|
||||||
|
|
||||||
#pragma warning disable S1075
|
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork)
|
||||||
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<StatsService> logger,
|
|
||||||
IUnitOfWork unitOfWork)
|
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
|
|
||||||
|
FlurlHttp.ConfigureClient(ApiUrl, cli =>
|
||||||
|
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -55,17 +40,7 @@ namespace API.Services.Tasks
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var rnd = new Random();
|
await SendData();
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -74,66 +49,30 @@ namespace API.Services.Tasks
|
|||||||
// ReSharper disable once MemberCanBePrivate.Global
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
public async Task SendData()
|
public async Task SendData()
|
||||||
{
|
{
|
||||||
await CollectRelevantData();
|
var data = await GetServerInfo();
|
||||||
await FinalizeStats();
|
await SendDataToStatsServer(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RecordClientInfo(ClientInfoDto clientInfoDto)
|
|
||||||
{
|
|
||||||
var statisticsDto = await GetData();
|
|
||||||
statisticsDto.AddClientInfo(clientInfoDto);
|
|
||||||
|
|
||||||
await SaveFile(statisticsDto);
|
private async Task SendDataToStatsServer(ServerInfoDto data)
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CollectRelevantData()
|
|
||||||
{
|
|
||||||
var usageInfo = await GetUsageInfo();
|
|
||||||
var serverInfo = GetServerInfo();
|
|
||||||
|
|
||||||
await PathData(serverInfo, usageInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task FinalizeStats()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var data = await GetExistingData<UsageStatisticsDto>();
|
|
||||||
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<bool> SendDataToStatsServer(UsageStatisticsDto data)
|
|
||||||
{
|
{
|
||||||
var responseContent = string.Empty;
|
var responseContent = string.Empty;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await (ApiUrl + "/api/InstallationStats")
|
var response = await (ApiUrl + "/api/v2/stats")
|
||||||
.WithHeader("Accept", "application/json")
|
.WithHeader("Accept", "application/json")
|
||||||
.WithHeader("User-Agent", "Kavita")
|
.WithHeader("User-Agent", "Kavita")
|
||||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||||
.WithHeader("api-key", "MsnvA2DfQqxSK5jh")
|
|
||||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||||
|
.WithHeader("Content-Type", "application/json")
|
||||||
.WithTimeout(TimeSpan.FromSeconds(30))
|
.WithTimeout(TimeSpan.FromSeconds(30))
|
||||||
.PostJsonAsync(data);
|
.PostJsonAsync(data);
|
||||||
|
|
||||||
if (response.StatusCode != StatusCodes.Status200OK)
|
if (response.StatusCode != StatusCodes.Status200OK)
|
||||||
{
|
{
|
||||||
_logger.LogError("KavitaStats did not respond successfully. {Content}", response);
|
_logger.LogError("KavitaStats did not respond successfully. {Content}", response);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
catch (HttpRequestException e)
|
catch (HttpRequestException e)
|
||||||
{
|
{
|
||||||
@ -149,84 +88,22 @@ namespace API.Services.Tasks
|
|||||||
{
|
{
|
||||||
_logger.LogError(e, "An error happened during the request to KavitaStats");
|
_logger.LogError(e, "An error happened during the request to KavitaStats");
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ResetStats()
|
public async Task<ServerInfoDto> GetServerInfo()
|
||||||
{
|
|
||||||
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<UsageStatisticsDto> GetData()
|
|
||||||
{
|
|
||||||
if (!FileExists) return new UsageStatisticsDto {InstallId = HashUtil.AnonymousToken()};
|
|
||||||
|
|
||||||
return await GetExistingData<UsageStatisticsDto>();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<UsageInfoDto> 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()
|
|
||||||
{
|
{
|
||||||
|
var installId = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId);
|
||||||
var serverInfo = new ServerInfoDto
|
var serverInfo = new ServerInfoDto
|
||||||
{
|
{
|
||||||
|
InstallId = installId.Value,
|
||||||
Os = RuntimeInformation.OSDescription,
|
Os = RuntimeInformation.OSDescription,
|
||||||
DotNetVersion = Environment.Version.ToString(),
|
|
||||||
RunTimeVersion = RuntimeInformation.FrameworkDescription,
|
|
||||||
KavitaVersion = BuildInfo.Version.ToString(),
|
KavitaVersion = BuildInfo.Version.ToString(),
|
||||||
Culture = Thread.CurrentThread.CurrentCulture.Name,
|
DotnetVersion = Environment.Version.ToString(),
|
||||||
BuildBranch = BuildInfo.Branch,
|
|
||||||
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
|
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
|
||||||
NumOfCores = Environment.ProcessorCount
|
NumOfCores = Math.Max(Environment.ProcessorCount, 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
return serverInfo;
|
return serverInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<T> GetExistingData<T>()
|
|
||||||
{
|
|
||||||
var json = await File.ReadAllTextAsync(StatsFilePath);
|
|
||||||
return JsonSerializer.Deserialize<T>(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task SaveFile(UsageStatisticsDto statisticsDto)
|
|
||||||
{
|
|
||||||
DirectoryService.ExistOrCreate(DirectoryService.StatsDirectory);
|
|
||||||
|
|
||||||
await File.WriteAllTextAsync(StatsFilePath, JsonSerializer.Serialize(statisticsDto));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,11 @@ namespace API.Services.Tasks
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public string Html_Url { get; init; }
|
public string Html_Url { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Date Release was Published
|
||||||
|
/// </summary>
|
||||||
|
// ReSharper disable once InconsistentNaming
|
||||||
|
public string Published_At { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UntrustedCertClientFactory : DefaultHttpClientFactory
|
public class UntrustedCertClientFactory : DefaultHttpClientFactory
|
||||||
@ -109,7 +114,8 @@ namespace API.Services.Tasks
|
|||||||
UpdateBody = _markdown.Transform(update.Body.Trim()),
|
UpdateBody = _markdown.Transform(update.Body.Trim()),
|
||||||
UpdateTitle = update.Name,
|
UpdateTitle = update.Name,
|
||||||
UpdateUrl = update.Html_Url,
|
UpdateUrl = update.Html_Url,
|
||||||
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker
|
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
|
||||||
|
PublishDate = update.Published_At
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
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)
|
public static SignalRMessage UpdateVersionEvent(UpdateNotificationDto update)
|
||||||
{
|
{
|
||||||
return new SignalRMessage
|
return new SignalRMessage
|
||||||
|
@ -4,7 +4,14 @@
|
|||||||
{
|
{
|
||||||
public const string UpdateVersion = "UpdateVersion";
|
public const string UpdateVersion = "UpdateVersion";
|
||||||
public const string ScanSeries = "ScanSeries";
|
public const string ScanSeries = "ScanSeries";
|
||||||
|
/// <summary>
|
||||||
|
/// Event during Refresh Metadata for cover image change
|
||||||
|
/// </summary>
|
||||||
public const string RefreshMetadata = "RefreshMetadata";
|
public const string RefreshMetadata = "RefreshMetadata";
|
||||||
|
/// <summary>
|
||||||
|
/// Event sent out during Refresh Metadata for progress tracking
|
||||||
|
/// </summary>
|
||||||
|
public const string RefreshMetadataProgress = "RefreshMetadataProgress";
|
||||||
public const string ScanLibrary = "ScanLibrary";
|
public const string ScanLibrary = "ScanLibrary";
|
||||||
public const string SeriesAdded = "SeriesAdded";
|
public const string SeriesAdded = "SeriesAdded";
|
||||||
public const string SeriesRemoved = "SeriesRemoved";
|
public const string SeriesRemoved = "SeriesRemoved";
|
||||||
@ -12,5 +19,13 @@
|
|||||||
public const string OnlineUsers = "OnlineUsers";
|
public const string OnlineUsers = "OnlineUsers";
|
||||||
public const string SeriesAddedToCollection = "SeriesAddedToCollection";
|
public const string SeriesAddedToCollection = "SeriesAddedToCollection";
|
||||||
public const string ScanLibraryError = "ScanLibraryError";
|
public const string ScanLibraryError = "ScanLibraryError";
|
||||||
|
/// <summary>
|
||||||
|
/// Event sent out during backing up the database
|
||||||
|
/// </summary>
|
||||||
|
public const string BackupDatabaseProgress = "BackupDatabaseProgress";
|
||||||
|
/// <summary>
|
||||||
|
/// Event sent out during cleaning up temp and cache folders
|
||||||
|
/// </summary>
|
||||||
|
public const string CleanupProgress = "CleanupProgress";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
We're always looking for people to help make Kavita even better, there are a number of ways to contribute.
|
We're always looking for people to help make Kavita even better, there are a number of ways to contribute.
|
||||||
|
|
||||||
## Documentation ##
|
## 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 ##
|
## Development ##
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<TargetFramework>net5.0</TargetFramework>
|
<TargetFramework>net5.0</TargetFramework>
|
||||||
<Company>kavitareader.com</Company>
|
<Company>kavitareader.com</Company>
|
||||||
<Product>Kavita</Product>
|
<Product>Kavita</Product>
|
||||||
<AssemblyVersion>0.4.8.1</AssemblyVersion>
|
<AssemblyVersion>0.4.9.0</AssemblyVersion>
|
||||||
<NeutralLanguage>en</NeutralLanguage>
|
<NeutralLanguage>en</NeutralLanguage>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
13
README.md
13
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.**
|
**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
|
## 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.
|
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.
|
||||||
|
|
||||||
[](https://feathub.com/Kareadita/Kavita)
|
|
||||||
|
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
This project exists thanks to all the people who contribute. [Contribute](CONTRIBUTING.md).
|
This project exists thanks to all the people who contribute. [Contribute](CONTRIBUTING.md).
|
||||||
<a href="https://github.com/Kareadita/Kavita/graphs/contributors"><img src="https://opencollective.com/kavita/contributors.svg?width=890&button=false" /></a>
|
<a href="https://github.com/Kareadita/Kavita/graphs/contributors">
|
||||||
|
<img src="https://opencollective.com/kavita/contributors.svg?width=890&button=false&avatarHeight=42" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
## Donate
|
## 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)
|
Thank you to all our backers! 🙏 [Become a backer](https://opencollective.com/Kavita#backer)
|
||||||
|
|
||||||
<img src="https://opencollective.com/Kavita/backers.svg?width=890"></a>
|
<img src="https://opencollective.com/kavita/backers.svg?width=890&avatarHeight=42"></a>
|
||||||
|
|
||||||
## Sponsors
|
## Sponsors
|
||||||
|
|
||||||
@ -116,9 +116,6 @@ Thank you to [<img src="/Logo/jetbrains.svg" alt="" width="32"> JetBrains](http:
|
|||||||
* [<img src="/Logo/rider.svg" alt="" width="32"> Rider](http://www.jetbrains.com/rider/)
|
* [<img src="/Logo/rider.svg" alt="" width="32"> Rider](http://www.jetbrains.com/rider/)
|
||||||
* [<img src="/Logo/dottrace.svg" alt="" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
|
* [<img src="/Logo/dottrace.svg" alt="" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
|
||||||
|
|
||||||
## Sentry
|
|
||||||
Thank you to [<img src="/Logo/sentry.svg" alt="" width="64">](https://sentry.io/welcome/) for providing us with free license to their software.
|
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
|
* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
|
||||||
|
@ -71,7 +71,7 @@
|
|||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "2kb",
|
"maximumWarning": "2kb",
|
||||||
"maximumError": "4kb"
|
"maximumError": "5kb"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
165
UI/Web/package-lock.json
generated
165
UI/Web/package-lock.json
generated
@ -89,6 +89,36 @@
|
|||||||
"webpack-sources": "2.0.1",
|
"webpack-sources": "2.0.1",
|
||||||
"webpack-subresource-integrity": "1.5.1",
|
"webpack-subresource-integrity": "1.5.1",
|
||||||
"worker-plugin": "5.0.0"
|
"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": {
|
"@angular-devkit/build-optimizer": {
|
||||||
@ -3252,9 +3282,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ansi-regex": {
|
"ansi-regex": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg=="
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
|
||||||
},
|
},
|
||||||
"ansi-styles": {
|
"ansi-styles": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
@ -4591,9 +4621,9 @@
|
|||||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
||||||
},
|
},
|
||||||
"color-string": {
|
"color-string": {
|
||||||
"version": "1.5.4",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz",
|
||||||
"integrity": "sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw==",
|
"integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"color-name": "^1.0.0",
|
"color-name": "^1.0.0",
|
||||||
@ -6820,9 +6850,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"glob-parent": {
|
"glob-parent": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
"integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"is-glob": "^4.0.1"
|
"is-glob": "^4.0.1"
|
||||||
@ -9620,9 +9650,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ws": {
|
"ws": {
|
||||||
"version": "7.4.3",
|
"version": "7.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz",
|
||||||
"integrity": "sha512-hr6vCR76GsossIRsr8OLR9acVVm1jyfEWvhbNjtgPOrfvAlKzvyeg/P6r8RuDjRyrcQoPQT7K0DGEPc7Ae6jzA==",
|
"integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -9710,9 +9740,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jszip": {
|
"jszip": {
|
||||||
"version": "3.5.0",
|
"version": "3.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.7.1.tgz",
|
||||||
"integrity": "sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA==",
|
"integrity": "sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"lie": "~3.3.0",
|
"lie": "~3.3.0",
|
||||||
@ -11269,20 +11299,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||||
"dev": true
|
"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
|
"dev": true
|
||||||
},
|
},
|
||||||
"path-parse": {
|
"path-parse": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
|
||||||
},
|
},
|
||||||
"path-to-regexp": {
|
"path-to-regexp": {
|
||||||
"version": "0.1.7",
|
"version": "0.1.7",
|
||||||
@ -11451,6 +11467,12 @@
|
|||||||
"integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
|
"integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
|
||||||
"dev": true
|
"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": {
|
"picomatch": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
|
||||||
@ -11534,14 +11556,13 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"postcss": {
|
"postcss": {
|
||||||
"version": "7.0.32",
|
"version": "7.0.39",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
|
||||||
"integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==",
|
"integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"chalk": "^2.4.2",
|
"picocolors": "^0.2.1",
|
||||||
"source-map": "^0.6.1",
|
"source-map": "^0.6.1"
|
||||||
"supports-color": "^6.1.0"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"source-map": {
|
"source-map": {
|
||||||
@ -11549,15 +11570,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
"dev": true
|
"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
|
"dev": true
|
||||||
},
|
},
|
||||||
"tar": {
|
"tar": {
|
||||||
"version": "6.0.5",
|
"version": "6.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
|
||||||
"integrity": "sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg==",
|
"integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"chownr": "^2.0.0",
|
"chownr": "^2.0.0",
|
||||||
@ -14919,9 +14931,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tmpl": {
|
"tmpl": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||||
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=",
|
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"to-arraybuffer": {
|
"to-arraybuffer": {
|
||||||
@ -15390,9 +15402,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"url-parse": {
|
"url-parse": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
|
||||||
"integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==",
|
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"querystringify": "^2.1.1",
|
"querystringify": "^2.1.1",
|
||||||
"requires-port": "^1.0.0"
|
"requires-port": "^1.0.0"
|
||||||
@ -16678,45 +16690,12 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"wide-align": {
|
"wide-align": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
"integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
|
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"string-width": "^1.0.2 || 2"
|
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wildcard": {
|
"wildcard": {
|
||||||
|
@ -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 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') {
|
// if (this.router.url !== '/no-connection') {
|
||||||
localStorage.setItem(this.urlKey, this.router.url);
|
// localStorage.setItem(this.urlKey, this.router.url);
|
||||||
this.router.navigateByUrl('/no-connection');
|
// this.router.navigateByUrl('/no-connection');
|
||||||
}
|
// }
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return throwError(error);
|
return throwError(error);
|
||||||
|
@ -15,4 +15,5 @@ export interface Chapter {
|
|||||||
pagesRead: number; // Attached for the given user when requesting from API
|
pagesRead: number; // Attached for the given user when requesting from API
|
||||||
isSpecial: boolean;
|
isSpecial: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
created: string;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export interface ScanLibraryProgressEvent {
|
export interface ProgressEvent {
|
||||||
libraryId: number;
|
libraryId: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
eventTime: string;
|
eventTime: string;
|
||||||
|
5
UI/Web/src/app/_models/events/series-removed-event.ts
Normal file
5
UI/Web/src/app/_models/events/series-removed-event.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface SeriesRemovedEvent {
|
||||||
|
libraryId: number;
|
||||||
|
seriesId: number;
|
||||||
|
seriesName: string;
|
||||||
|
}
|
@ -5,4 +5,5 @@ export interface UpdateVersionEvent {
|
|||||||
updateTitle: string;
|
updateTitle: string;
|
||||||
updateUrl: string;
|
updateUrl: string;
|
||||||
isDocker: boolean;
|
isDocker: boolean;
|
||||||
|
publishDate: string;
|
||||||
}
|
}
|
@ -1,5 +1,18 @@
|
|||||||
export enum PageSplitOption {
|
export enum PageSplitOption {
|
||||||
|
/**
|
||||||
|
* Renders the left side of the image then the right side
|
||||||
|
*/
|
||||||
SplitLeftToRight = 0,
|
SplitLeftToRight = 0,
|
||||||
|
/**
|
||||||
|
* Renders the right side of the image then the left side
|
||||||
|
*/
|
||||||
SplitRightToLeft = 1,
|
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
|
||||||
}
|
}
|
||||||
|
@ -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 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 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}];
|
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}];
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { EventEmitter, Injectable } from '@angular/core';
|
import { EventEmitter, Injectable } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
|
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
|
||||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
@ -6,7 +7,7 @@ import { BehaviorSubject, ReplaySubject } from 'rxjs';
|
|||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
|
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
|
||||||
import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event';
|
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 { ScanSeriesEvent } from '../_models/events/scan-series-event';
|
||||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||||
import { User } from '../_models/user';
|
import { User } from '../_models/user';
|
||||||
@ -15,11 +16,15 @@ export enum EVENTS {
|
|||||||
UpdateAvailable = 'UpdateAvailable',
|
UpdateAvailable = 'UpdateAvailable',
|
||||||
ScanSeries = 'ScanSeries',
|
ScanSeries = 'ScanSeries',
|
||||||
RefreshMetadata = 'RefreshMetadata',
|
RefreshMetadata = 'RefreshMetadata',
|
||||||
|
RefreshMetadataProgress = 'RefreshMetadataProgress',
|
||||||
SeriesAdded = 'SeriesAdded',
|
SeriesAdded = 'SeriesAdded',
|
||||||
|
SeriesRemoved = 'SeriesRemoved',
|
||||||
ScanLibraryProgress = 'ScanLibraryProgress',
|
ScanLibraryProgress = 'ScanLibraryProgress',
|
||||||
OnlineUsers = 'OnlineUsers',
|
OnlineUsers = 'OnlineUsers',
|
||||||
SeriesAddedToCollection = 'SeriesAddedToCollection',
|
SeriesAddedToCollection = 'SeriesAddedToCollection',
|
||||||
ScanLibraryError = 'ScanLibraryError'
|
ScanLibraryError = 'ScanLibraryError',
|
||||||
|
BackupDatabaseProgress = 'BackupDatabaseProgress',
|
||||||
|
CleanupProgress = 'CleanupProgress'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message<T> {
|
export interface Message<T> {
|
||||||
@ -42,13 +47,13 @@ export class MessageHubService {
|
|||||||
onlineUsers$ = this.onlineUsersSource.asObservable();
|
onlineUsers$ = this.onlineUsersSource.asObservable();
|
||||||
|
|
||||||
public scanSeries: EventEmitter<ScanSeriesEvent> = new EventEmitter<ScanSeriesEvent>();
|
public scanSeries: EventEmitter<ScanSeriesEvent> = new EventEmitter<ScanSeriesEvent>();
|
||||||
public scanLibrary: EventEmitter<ScanLibraryProgressEvent> = new EventEmitter<ScanLibraryProgressEvent>();
|
public scanLibrary: EventEmitter<ProgressEvent> = new EventEmitter<ProgressEvent>(); // TODO: Refactor this name to be generic
|
||||||
public seriesAdded: EventEmitter<SeriesAddedEvent> = new EventEmitter<SeriesAddedEvent>();
|
public seriesAdded: EventEmitter<SeriesAddedEvent> = new EventEmitter<SeriesAddedEvent>();
|
||||||
public refreshMetadata: EventEmitter<RefreshMetadataEvent> = new EventEmitter<RefreshMetadataEvent>();
|
public refreshMetadata: EventEmitter<RefreshMetadataEvent> = new EventEmitter<RefreshMetadataEvent>();
|
||||||
|
|
||||||
isAdmin: boolean = false;
|
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.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.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => {
|
||||||
this.messagesSource.next({
|
this.messagesSource.next({
|
||||||
event: EVENTS.SeriesAddedToCollection,
|
event: EVENTS.SeriesAddedToCollection,
|
||||||
@ -110,11 +136,19 @@ export class MessageHubService {
|
|||||||
payload: resp.body
|
payload: resp.body
|
||||||
});
|
});
|
||||||
this.seriesAdded.emit(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.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.hubConnection.on(EVENTS.RefreshMetadata, resp => {
|
||||||
this.messagesSource.next({
|
this.messagesSource.next({
|
||||||
event: EVENTS.RefreshMetadata,
|
event: EVENTS.RefreshMetadata,
|
||||||
|
@ -6,7 +6,6 @@ import { environment } from 'src/environments/environment';
|
|||||||
import { Chapter } from '../_models/chapter';
|
import { Chapter } from '../_models/chapter';
|
||||||
import { CollectionTag } from '../_models/collection-tag';
|
import { CollectionTag } from '../_models/collection-tag';
|
||||||
import { InProgressChapter } from '../_models/in-progress-chapter';
|
import { InProgressChapter } from '../_models/in-progress-chapter';
|
||||||
import { MangaFormat } from '../_models/manga-format';
|
|
||||||
import { PaginatedResult } from '../_models/pagination';
|
import { PaginatedResult } from '../_models/pagination';
|
||||||
import { Series } from '../_models/series';
|
import { Series } from '../_models/series';
|
||||||
import { SeriesFilter } from '../_models/series-filter';
|
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);
|
const data = this.createSeriesFilter(filter);
|
||||||
|
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
|
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||||
|
|
||||||
return this.httpClient.post<Series[]>(this.baseUrl + 'series/in-progress?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
|
return this.httpClient.post<Series[]>(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
|
||||||
map(response => {
|
map(response => {
|
||||||
return this._cachePaginatedResults(response, new PaginatedResult<Series[]>());
|
return this._cachePaginatedResults(response, new PaginatedResult<Series[]>());
|
||||||
}));
|
}));
|
||||||
|
@ -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<ClientInfo> {
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,17 +6,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="list-group">
|
<div class="list-group" *ngIf="!isLoading">
|
||||||
<li class="list-group-item" *ngFor="let library of selectedLibraries; let i = index">
|
<div class="form-check">
|
||||||
<div class="form-check">
|
<input id="selectall" type="checkbox" class="form-check-input"
|
||||||
<input id="library-{{i}}" type="checkbox" attr.aria-label="Library {{library.data.name}}" class="form-check-input"
|
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||||
[(ngModel)]="library.selected" name="library">
|
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||||
<label attr.for="library-{{i}}" class="form-check-label">{{library.data.name}}</label>
|
</div>
|
||||||
</div>
|
<ul>
|
||||||
</li>
|
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
|
||||||
<li class="list-group-item" *ngIf="selectedLibraries.length === 0">
|
<div class="form-check">
|
||||||
There are no libraries setup yet.
|
<input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
|
||||||
</li>
|
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
|
||||||
|
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item" *ngIf="allLibraries.length === 0">
|
||||||
|
There are no libraries setup yet.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Component, Input, OnInit } from '@angular/core';
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
import { FormBuilder } from '@angular/forms';
|
import { FormBuilder } from '@angular/forms';
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
|
||||||
import { Library } from 'src/app/_models/library';
|
import { Library } from 'src/app/_models/library';
|
||||||
import { Member } from 'src/app/_models/member';
|
import { Member } from 'src/app/_models/member';
|
||||||
import { LibraryService } from 'src/app/_services/library.service';
|
import { LibraryService } from 'src/app/_services/library.service';
|
||||||
@ -15,24 +16,21 @@ export class LibraryAccessModalComponent implements OnInit {
|
|||||||
@Input() member: Member | undefined;
|
@Input() member: Member | undefined;
|
||||||
allLibraries: Library[] = [];
|
allLibraries: Library[] = [];
|
||||||
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
|
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
|
||||||
|
selections!: SelectionModel<Library>;
|
||||||
|
selectAll: boolean = false;
|
||||||
|
isLoading: boolean = false;
|
||||||
|
|
||||||
|
get hasSomeSelected() {
|
||||||
|
console.log(this.selections != null && this.selections.hasSomeSelected());
|
||||||
|
return this.selections != null && this.selections.hasSomeSelected();
|
||||||
|
}
|
||||||
|
|
||||||
constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { }
|
constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.libraryService.getLibraries().subscribe(libs => {
|
this.libraryService.getLibraries().subscribe(libs => {
|
||||||
this.allLibraries = libs;
|
this.allLibraries = libs;
|
||||||
this.selectedLibraries = libs.map(item => {
|
this.setupSelections();
|
||||||
return {selected: false, data: item};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.member !== undefined) {
|
|
||||||
this.member.libraries.forEach(lib => {
|
|
||||||
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name);
|
|
||||||
if (foundLibrary.length > 0) {
|
|
||||||
foundLibrary[0].selected = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,25 +43,41 @@ export class LibraryAccessModalComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedLibraries = this.selectedLibraries.filter(item => item.selected).map(item => item.data);
|
const selectedLibraries = this.selections.selected();
|
||||||
this.libraryService.updateLibrariesForMember(this.member?.username, selectedLibraries).subscribe(() => {
|
this.libraryService.updateLibrariesForMember(this.member?.username, selectedLibraries).subscribe(() => {
|
||||||
this.modal.close(true);
|
this.modal.close(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
setupSelections() {
|
||||||
this.selectedLibraries = this.allLibraries.map(item => {
|
this.selections = new SelectionModel<Library>(false, this.allLibraries);
|
||||||
return {selected: false, data: item};
|
this.isLoading = false;
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
// If a member is passed in, then auto-select their libraries
|
||||||
if (this.member !== undefined) {
|
if (this.member !== undefined) {
|
||||||
this.member.libraries.forEach(lib => {
|
this.member.libraries.forEach(lib => {
|
||||||
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name);
|
this.selections.toggle(lib, true, (a, b) => a.name === b.name);
|
||||||
if (foundLibrary.length > 0) {
|
|
||||||
foundLibrary[0].selected = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
this.selectAll = this.selections.selected().length === this.allLibraries.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.setupSelections();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAll() {
|
||||||
|
this.selectAll = !this.selectAll;
|
||||||
|
this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSelection(item: Library) {
|
||||||
|
this.selections.toggle(item);
|
||||||
|
const numberOfSelected = this.selections.selected().length;
|
||||||
|
if (numberOfSelected == 0) {
|
||||||
|
this.selectAll = false;
|
||||||
|
} else if (numberOfSelected == this.selectedLibraries.length) {
|
||||||
|
this.selectAll = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
os: string;
|
os: string;
|
||||||
dotNetVersion: string;
|
dotnetVersion: string;
|
||||||
runTimeVersion: string;
|
runTimeVersion: string;
|
||||||
kavitaVersion: string;
|
kavitaVersion: string;
|
||||||
buildBranch: string;
|
NumOfCores: number;
|
||||||
culture: string;
|
installId: string;
|
||||||
|
isDocker: boolean;
|
||||||
}
|
}
|
@ -1,15 +1,19 @@
|
|||||||
<ng-container *ngFor="let update of updates; let indx = index;">
|
<div class="changelog">
|
||||||
|
<ng-container *ngFor="let update of updates; let indx = index;">
|
||||||
<div class="card w-100 mb-2" style="width: 18rem;">
|
<div class="card w-100 mb-2" style="width: 18rem;">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">{{update.updateTitle}}
|
<h4 class="card-title">{{update.updateTitle}}
|
||||||
<span class="badge badge-secondary" *ngIf="update.updateVersion === update.currentVersion">Installed</span>
|
<span class="badge badge-secondary" *ngIf="update.updateVersion === update.currentVersion">Installed</span>
|
||||||
<span class="badge badge-secondary" *ngIf="update.updateVersion > update.currentVersion">Available</span>
|
<span class="badge badge-secondary" *ngIf="update.updateVersion > update.currentVersion">Available</span>
|
||||||
</h5>
|
</h4>
|
||||||
|
<h6 class="card-subtitle mb-2 text-muted">Published: {{update.publishDate | date: 'short'}}</h6>
|
||||||
|
|
||||||
<pre class="card-text update-body" [innerHtml]="update.updateBody | safeHtml"></pre>
|
<pre class="card-text update-body" [innerHtml]="update.updateBody | safeHtml"></pre>
|
||||||
<a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-right" target="_blank">Download</a>
|
<a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-right" target="_blank">Download</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="spinner-border text-secondary" *ngIf="isLoading" role="status">
|
<div class="spinner-border text-secondary" *ngIf="isLoading" role="status">
|
||||||
|
@ -3,3 +3,19 @@
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
::ng-deep .changelog {
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, ul {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import { ToastrService } from 'ngx-toastr';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { take, takeUntil } from 'rxjs/operators';
|
import { take, takeUntil } from 'rxjs/operators';
|
||||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||||
import { ScanLibraryProgressEvent } from 'src/app/_models/events/scan-library-progress-event';
|
import { ProgressEvent } from 'src/app/_models/events/scan-library-progress-event';
|
||||||
import { Library, LibraryType } from 'src/app/_models/library';
|
import { Library, LibraryType } from 'src/app/_models/library';
|
||||||
import { LibraryService } from 'src/app/_services/library.service';
|
import { LibraryService } from 'src/app/_services/library.service';
|
||||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||||
@ -38,9 +38,9 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// when a progress event comes in, show it on the UI next to library
|
// when a progress event comes in, show it on the UI next to library
|
||||||
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
|
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
|
||||||
if (event.event != EVENTS.ScanLibraryProgress) return;
|
if (event.event !== EVENTS.ScanLibraryProgress) return;
|
||||||
|
|
||||||
const scanEvent = event.payload as ScanLibraryProgressEvent;
|
const scanEvent = event.payload as ProgressEvent;
|
||||||
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100};
|
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100};
|
||||||
if (scanEvent.progress === 0) {
|
if (scanEvent.progress === 0) {
|
||||||
this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime;
|
this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime;
|
||||||
@ -55,6 +55,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,8 +33,8 @@
|
|||||||
<dt>Version</dt>
|
<dt>Version</dt>
|
||||||
<dd>{{serverInfo.kavitaVersion}}</dd>
|
<dd>{{serverInfo.kavitaVersion}}</dd>
|
||||||
|
|
||||||
<dt>.NET Version</dt>
|
<dt>Install ID</dt>
|
||||||
<dd>{{serverInfo.dotNetVersion}}</dd>
|
<dd>{{serverInfo.installId}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -43,7 +43,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-4">Home page:</div>
|
<div class="col-4">Home page:</div>
|
||||||
<div class="col"><a href="https://kavitareader.com" target="_blank">kavitareader.com</a></div>
|
<div class="col"><a href="https://www.kavitareader.com" target="_blank">kavitareader.com</a></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-4">Wiki:</div>
|
<div class="col-4">Wiki:</div>
|
||||||
@ -63,7 +63,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-4">Feature Requests:</div>
|
<div class="col-4">Feature Requests:</div>
|
||||||
<div class="col"><a href="https://feathub.com/Kareadita/Kavita" target="_blank">Feathub</a><br/>
|
<div class="col"><a href="https://feats.kavitareader.com" target="_blank">https://feats.kavitareader.com</a><br/>
|
||||||
<a href="https://github.com/Kareadita/Kavita/issues" target="_blank">Github issues</a></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@ -7,7 +7,7 @@ import { RecentlyAddedComponent } from './recently-added/recently-added.componen
|
|||||||
import { UserLoginComponent } from './user-login/user-login.component';
|
import { UserLoginComponent } from './user-login/user-login.component';
|
||||||
import { AuthGuard } from './_guards/auth.guard';
|
import { AuthGuard } from './_guards/auth.guard';
|
||||||
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
||||||
import { InProgressComponent } from './in-progress/in-progress.component';
|
import { OnDeckComponent } from './on-deck/on-deck.component';
|
||||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||||
|
|
||||||
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
|
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
|
||||||
@ -54,7 +54,7 @@ const routes: Routes = [
|
|||||||
children: [
|
children: [
|
||||||
{path: 'library', component: DashboardComponent},
|
{path: 'library', component: DashboardComponent},
|
||||||
{path: 'recently-added', component: RecentlyAddedComponent},
|
{path: 'recently-added', component: RecentlyAddedComponent},
|
||||||
{path: 'in-progress', component: InProgressComponent},
|
{path: 'on-deck', component: OnDeckComponent},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{path: 'login', component: UserLoginComponent},
|
{path: 'login', component: UserLoginComponent},
|
||||||
|
@ -5,7 +5,6 @@ import { AccountService } from './_services/account.service';
|
|||||||
import { LibraryService } from './_services/library.service';
|
import { LibraryService } from './_services/library.service';
|
||||||
import { MessageHubService } from './_services/message-hub.service';
|
import { MessageHubService } from './_services/message-hub.service';
|
||||||
import { NavService } from './_services/nav.service';
|
import { NavService } from './_services/nav.service';
|
||||||
import { StatsService } from './_services/stats.service';
|
|
||||||
import { filter } from 'rxjs/operators';
|
import { filter } from 'rxjs/operators';
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
|
||||||
@ -17,8 +16,8 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
|||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
|
|
||||||
constructor(private accountService: AccountService, public navService: NavService,
|
constructor(private accountService: AccountService, public navService: NavService,
|
||||||
private statsService: StatsService, private messageHub: MessageHubService,
|
private messageHub: MessageHubService, private libraryService: LibraryService,
|
||||||
private libraryService: LibraryService, private router: Router, private ngbModal: NgbModal) {
|
private router: Router, private ngbModal: NgbModal) {
|
||||||
|
|
||||||
// Close any open modals when a route change occurs
|
// Close any open modals when a route change occurs
|
||||||
router.events
|
router.events
|
||||||
@ -32,10 +31,6 @@ export class AppComponent implements OnInit {
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.setCurrentUser();
|
this.setCurrentUser();
|
||||||
|
|
||||||
this.statsService.getInfo().then(data => {
|
|
||||||
this.statsService.sendClientInfo(data).subscribe(() => {/* No Operation */});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { BrowserModule, Title } from '@angular/platform-browser';
|
import { BrowserModule, Title } from '@angular/platform-browser';
|
||||||
import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { APP_BASE_HREF } from '@angular/common';
|
import { APP_BASE_HREF } from '@angular/common';
|
||||||
|
|
||||||
import { AppRoutingModule } from './app-routing.module';
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { NavHeaderComponent } from './nav-header/nav-header.component';
|
import { NavHeaderComponent } from './nav-header/nav-header.component';
|
||||||
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
|
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
|
||||||
import { UserLoginComponent } from './user-login/user-login.component';
|
import { UserLoginComponent } from './user-login/user-login.component';
|
||||||
@ -25,13 +25,14 @@ import { CarouselModule } from './carousel/carousel.module';
|
|||||||
import { PersonBadgeComponent } from './person-badge/person-badge.component';
|
import { PersonBadgeComponent } from './person-badge/person-badge.component';
|
||||||
import { TypeaheadModule } from './typeahead/typeahead.module';
|
import { TypeaheadModule } from './typeahead/typeahead.module';
|
||||||
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
|
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
|
||||||
import { InProgressComponent } from './in-progress/in-progress.component';
|
import { OnDeckComponent } from './on-deck/on-deck.component';
|
||||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||||
import { CardsModule } from './cards/cards.module';
|
import { CardsModule } from './cards/cards.module';
|
||||||
import { CollectionsModule } from './collections/collections.module';
|
import { CollectionsModule } from './collections/collections.module';
|
||||||
import { ReadingListModule } from './reading-list/reading-list.module';
|
import { ReadingListModule } from './reading-list/reading-list.module';
|
||||||
import { SAVER, getSaver } from './shared/_providers/saver.provider';
|
import { SAVER, getSaver } from './shared/_providers/saver.provider';
|
||||||
import { ConfigData } from './_models/config-data';
|
import { ConfigData } from './_models/config-data';
|
||||||
|
import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component';
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@ -46,8 +47,9 @@ import { ConfigData } from './_models/config-data';
|
|||||||
ReviewSeriesModalComponent,
|
ReviewSeriesModalComponent,
|
||||||
PersonBadgeComponent,
|
PersonBadgeComponent,
|
||||||
RecentlyAddedComponent,
|
RecentlyAddedComponent,
|
||||||
InProgressComponent,
|
OnDeckComponent,
|
||||||
DashboardComponent,
|
DashboardComponent,
|
||||||
|
NavEventsToggleComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
@ -59,6 +61,7 @@ import { ConfigData } from './_models/config-data';
|
|||||||
|
|
||||||
NgbDropdownModule, // Nav
|
NgbDropdownModule, // Nav
|
||||||
AutocompleteLibModule, // Nav
|
AutocompleteLibModule, // Nav
|
||||||
|
NgbPopoverModule, // Nav Events toggle
|
||||||
NgbRatingModule, // Series Detail
|
NgbRatingModule, // Series Detail
|
||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div class="fixed-top" #stickyTop>
|
<div class="fixed-top" #stickyTop>
|
||||||
<a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
|
<a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
|
||||||
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
|
<ng-container [ngTemplateOutlet]="actionBar"></ng-container>
|
||||||
<app-drawer #commentDrawer="drawer" [isOpen]="drawerOpen" [style.--drawer-width]="'300px'" [options]="{topOffset: topOffset}" [style.--drawer-background-color]="backgroundColor" (drawerClosed)="closeDrawer()">
|
<app-drawer #commentDrawer="drawer" [isOpen]="drawerOpen" [style.--drawer-width]="'300px'" [options]="{topOffset: topOffset}" [style.--drawer-background-color]="drawerBackgroundColor" (drawerClosed)="closeDrawer()">
|
||||||
<div header>
|
<div header>
|
||||||
<h2 style="margin-top: 0.5rem">Book Settings
|
<h2 style="margin-top: 0.5rem">Book Settings
|
||||||
<button type="button" class="close" aria-label="Close" (click)="commentDrawer.close()">
|
<button type="button" class="close" aria-label="Close" (click)="commentDrawer.close()">
|
||||||
@ -100,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)">
|
<div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)">
|
||||||
<div #readingHtml class="book-content" [ngStyle]="{'padding-bottom': topOffset + 20 + 'px'}" [innerHtml]="page" *ngIf="page !== undefined"></div>
|
<div #readingHtml class="book-content" [ngStyle]="{'padding-bottom': topOffset + 20 + 'px', 'margin': '0px 0px'}" [innerHtml]="page" *ngIf="page !== undefined"></div>
|
||||||
|
|
||||||
<div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate">
|
<div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate">
|
||||||
</div>
|
</div>
|
||||||
|
@ -229,6 +229,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get drawerBackgroundColor() {
|
||||||
|
return this.darkMode ? '#010409': '#fff';
|
||||||
|
}
|
||||||
|
|
||||||
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
||||||
private seriesService: SeriesService, private readerService: ReaderService, private location: Location,
|
private seriesService: SeriesService, private readerService: ReaderService, private location: Location,
|
||||||
private renderer: Renderer2, private navService: NavService, private toastr: ToastrService,
|
private renderer: Renderer2, private navService: NavService, private toastr: ToastrService,
|
||||||
@ -887,7 +891,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDarkModeBackgroundColor() {
|
getDarkModeBackgroundColor() {
|
||||||
return this.darkMode ? '#010409' : '#fff';
|
return this.darkMode ? '#292929' : '#fff';
|
||||||
}
|
}
|
||||||
|
|
||||||
setOverrideStyles() {
|
setOverrideStyles() {
|
||||||
|
@ -17,11 +17,12 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
Id: {{data.id}}
|
Id: {{data.id}}
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col" *ngIf="series !== undefined">
|
||||||
|
Format: <span class="badge badge-secondary">{{utilityService.mangaFormat(series.format) | sentenceCase}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<div class="col" *ngIf="utilityService.isVolume(data)">
|
<div class="col" *ngIf="data.hasOwnProperty('created')">
|
||||||
Added: {{(data.created | date: 'short') || '-'}}
|
Added: {{(data.created | date: 'short') || '-'}}
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@ -58,8 +59,8 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
Pages: {{file.pages}}
|
Pages: {{file.pages}}
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col" *ngIf="data.hasOwnProperty('created')">
|
||||||
Format: <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span>
|
Added: {{(data.created | date: 'short') || '-'}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
@ -15,6 +15,8 @@ import { UploadService } from 'src/app/_services/upload.service';
|
|||||||
import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component';
|
import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component';
|
||||||
import { LibraryType } from '../../../_models/library';
|
import { LibraryType } from '../../../_models/library';
|
||||||
import { LibraryService } from '../../../_services/library.service';
|
import { LibraryService } from '../../../_services/library.service';
|
||||||
|
import { SeriesService } from 'src/app/_services/series.service';
|
||||||
|
import { Series } from 'src/app/_models/series';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -42,6 +44,7 @@ export class CardDetailsModalComponent implements OnInit {
|
|||||||
actions: ActionItem<any>[] = [];
|
actions: ActionItem<any>[] = [];
|
||||||
chapterActions: ActionItem<Chapter>[] = [];
|
chapterActions: ActionItem<Chapter>[] = [];
|
||||||
libraryType: LibraryType = LibraryType.Manga;
|
libraryType: LibraryType = LibraryType.Manga;
|
||||||
|
series: Series | undefined = undefined;
|
||||||
|
|
||||||
get LibraryType(): typeof LibraryType {
|
get LibraryType(): typeof LibraryType {
|
||||||
return LibraryType;
|
return LibraryType;
|
||||||
@ -50,7 +53,8 @@ export class CardDetailsModalComponent implements OnInit {
|
|||||||
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
|
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
|
||||||
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
|
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
|
||||||
private accountService: AccountService, private actionFactoryService: ActionFactoryService,
|
private accountService: AccountService, private actionFactoryService: ActionFactoryService,
|
||||||
private actionService: ActionService, private router: Router, private libraryService: LibraryService) { }
|
private actionService: ActionService, private router: Router, private libraryService: LibraryService,
|
||||||
|
private seriesService: SeriesService) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.isChapter = this.utilityService.isChapter(this.data);
|
this.isChapter = this.utilityService.isChapter(this.data);
|
||||||
@ -79,6 +83,10 @@ export class CardDetailsModalComponent implements OnInit {
|
|||||||
this.chapters.forEach((c: Chapter) => {
|
this.chapters.forEach((c: Chapter) => {
|
||||||
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
|
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.seriesService.getSeries(this.seriesId).subscribe(series => {
|
||||||
|
this.series = series;
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
<h6>Applies to Series</h6>
|
<h6>Applies to Series</h6>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input id="selectall" type="checkbox" class="form-check-input"
|
<input id="selectall" type="checkbox" class="form-check-input"
|
||||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="someSelected">
|
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
|
||||||
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -35,6 +35,11 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||||||
imageUrls: Array<string> = [];
|
imageUrls: Array<string> = [];
|
||||||
selectedCover: string = '';
|
selectedCover: string = '';
|
||||||
|
|
||||||
|
get hasSomeSelected() {
|
||||||
|
return this.selections != null && this.selections.hasSomeSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
|
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
|
||||||
private collectionService: CollectionTagService, private toastr: ToastrService,
|
private collectionService: CollectionTagService, private toastr: ToastrService,
|
||||||
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
|
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
|
||||||
@ -133,11 +138,6 @@ export class EditCollectionTagsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get someSelected() {
|
|
||||||
const selected = this.selections.selected();
|
|
||||||
return (selected.length !== this.series.length && selected.length !== 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSelectedIndex(index: number) {
|
updateSelectedIndex(index: number) {
|
||||||
this.collectionTagForm.patchValue({
|
this.collectionTagForm.patchValue({
|
||||||
coverImageIndex: index
|
coverImageIndex: index
|
||||||
|
@ -110,7 +110,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
Created: {{volume.created | date: 'short'}}
|
Added: {{volume.created | date: 'short'}}
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
Last Modified: {{volume.lastModified | date: 'short'}}
|
Last Modified: {{volume.lastModified | date: 'short'}}
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
@import "../../../theme/colors";
|
@use "../../../theme/colors";
|
||||||
@import "../../../assets/themes/dark.scss";
|
|
||||||
|
|
||||||
.bulk-select {
|
.bulk-select {
|
||||||
background-color: $dark-form-background-no-opacity;
|
background-color: colors.$dark-form-background-no-opacity;
|
||||||
border-bottom: 2px solid $primary-color;
|
border-bottom: 2px solid colors.$primary-color;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -12,5 +11,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.highlight {
|
.highlight {
|
||||||
color: $primary-color !important;
|
color: colors.$primary-color !important;
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
@import '../../../theme/colors';
|
@use '../../../theme/colors';
|
||||||
$image-height: 230px;
|
$image-height: 230px;
|
||||||
$image-width: 160px;
|
$image-width: 160px;
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ $image-width: 160px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selected {
|
.selected {
|
||||||
outline: 5px solid $primary-color;
|
outline: 5px solid colors.$primary-color;
|
||||||
outline-width: medium;
|
outline-width: medium;
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
@ -22,7 +22,7 @@ $image-width: 160px;
|
|||||||
ngx-file-drop ::ng-deep > div {
|
ngx-file-drop ::ng-deep > div {
|
||||||
// styling for the outer drop box
|
// styling for the outer drop box
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 2px solid $primary-color;
|
border: 2px solid colors.$primary-color;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
@ -11,6 +11,7 @@ import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collecti
|
|||||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||||
import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event';
|
import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event';
|
||||||
|
import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event';
|
||||||
import { Pagination } from 'src/app/_models/pagination';
|
import { Pagination } from 'src/app/_models/pagination';
|
||||||
import { Series } from 'src/app/_models/series';
|
import { Series } from 'src/app/_models/series';
|
||||||
import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
|
import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
|
||||||
@ -106,8 +107,12 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
|
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
|
||||||
|
|
||||||
this.messageHub.messages$.pipe(takeWhile(event => event.event === EVENTS.SeriesAddedToCollection), takeUntil(this.onDestory), debounceTime(2000)).subscribe(event => {
|
this.messageHub.messages$.pipe(takeWhile(event => event.event === EVENTS.SeriesAddedToCollection), takeUntil(this.onDestory), debounceTime(2000)).subscribe(event => {
|
||||||
const collectionEvent = event.payload as SeriesAddedToCollectionEvent;
|
if (event.event == EVENTS.SeriesAddedToCollection) {
|
||||||
if (collectionEvent.tagId === this.collectionTag.id) {
|
const collectionEvent = event.payload as SeriesAddedToCollectionEvent;
|
||||||
|
if (collectionEvent.tagId === this.collectionTag.id) {
|
||||||
|
this.loadPage();
|
||||||
|
}
|
||||||
|
} else if (event.event === EVENTS.SeriesRemoved) {
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<p>You haven't been granted access to any libraries.</p>
|
<p>You haven't been granted access to any libraries.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-carousel-reel [items]="inProgress" title="In Progress" (sectionClick)="handleSectionClick($event)">
|
<app-carousel-reel [items]="inProgress" title="On Deck" (sectionClick)="handleSectionClick($event)">
|
||||||
<ng-template #carouselItem let-item let-position="idx">
|
<ng-template #carouselItem let-item let-position="idx">
|
||||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -1,19 +1,15 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { take, takeUntil } from 'rxjs/operators';
|
import { take, takeUntil } from 'rxjs/operators';
|
||||||
import { EditCollectionTagsComponent } from '../cards/_modals/edit-collection-tags/edit-collection-tags.component';
|
|
||||||
import { CollectionTag } from '../_models/collection-tag';
|
|
||||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||||
|
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
|
||||||
import { InProgressChapter } from '../_models/in-progress-chapter';
|
import { InProgressChapter } from '../_models/in-progress-chapter';
|
||||||
import { Library } from '../_models/library';
|
import { Library } from '../_models/library';
|
||||||
import { Series } from '../_models/series';
|
import { Series } from '../_models/series';
|
||||||
import { User } from '../_models/user';
|
import { User } from '../_models/user';
|
||||||
import { AccountService } from '../_services/account.service';
|
import { AccountService } from '../_services/account.service';
|
||||||
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
|
|
||||||
import { CollectionTagService } from '../_services/collection-tag.service';
|
|
||||||
import { ImageService } from '../_services/image.service';
|
import { ImageService } from '../_services/image.service';
|
||||||
import { LibraryService } from '../_services/library.service';
|
import { LibraryService } from '../_services/library.service';
|
||||||
import { EVENTS, MessageHubService } from '../_services/message-hub.service';
|
import { EVENTS, MessageHubService } from '../_services/message-hub.service';
|
||||||
@ -44,14 +40,18 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||||||
private titleService: Title, public imageService: ImageService,
|
private titleService: Title, public imageService: ImageService,
|
||||||
private messageHub: MessageHubService) {
|
private messageHub: MessageHubService) {
|
||||||
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => {
|
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => {
|
||||||
if (res.event == EVENTS.SeriesAdded) {
|
if (res.event === EVENTS.SeriesAdded) {
|
||||||
const seriesAddedEvent = res.payload as SeriesAddedEvent;
|
const seriesAddedEvent = res.payload as SeriesAddedEvent;
|
||||||
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
|
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
|
||||||
this.recentlyAdded.unshift(series);
|
this.recentlyAdded.unshift(series);
|
||||||
});
|
});
|
||||||
|
} else if (res.event === EVENTS.SeriesRemoved) {
|
||||||
|
const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
|
||||||
|
this.recentlyAdded = this.recentlyAdded.filter(item => item.id != seriesRemovedEvent.seriesId);
|
||||||
|
this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.titleService.setTitle('Kavita - Dashboard');
|
this.titleService.setTitle('Kavita - Dashboard');
|
||||||
@ -75,7 +75,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
reloadSeries() {
|
reloadSeries() {
|
||||||
this.loadRecentlyAdded();
|
this.loadRecentlyAdded();
|
||||||
this.loadInProgress();
|
this.loadOnDeck();
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadInProgress(series: Series | boolean) {
|
reloadInProgress(series: Series | boolean) {
|
||||||
@ -88,11 +88,11 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loadInProgress();
|
this.loadOnDeck();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadInProgress() {
|
loadOnDeck() {
|
||||||
this.seriesService.getInProgress().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => {
|
this.seriesService.getOnDeck().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => {
|
||||||
this.inProgress = updatedSeries.result;
|
this.inProgress = updatedSeries.result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -108,8 +108,15 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['collections']);
|
this.router.navigate(['collections']);
|
||||||
} else if (sectionTitle.toLowerCase() === 'recently added') {
|
} else if (sectionTitle.toLowerCase() === 'recently added') {
|
||||||
this.router.navigate(['recently-added']);
|
this.router.navigate(['recently-added']);
|
||||||
} else if (sectionTitle.toLowerCase() === 'in progress') {
|
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||||
this.router.navigate(['in-progress']);
|
this.router.navigate(['on-deck']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFromArray(arr: Array<any>, element: any) {
|
||||||
|
const index = arr.indexOf(element);
|
||||||
|
if (index >= 0) {
|
||||||
|
arr.splice(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,9 +26,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngFor="let item of webtoonImages | async; let index = index;">
|
<ng-container *ngFor="let item of webtoonImages | async; let index = index;">
|
||||||
<img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
|
<img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<div *ngIf="atBottom" class="spacer bottom" role="alert" (click)="loadPrevChapter.emit()">
|
<div *ngIf="atBottom" class="spacer bottom" role="alert" (click)="loadNextChapter.emit()">
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-icon mx-auto">
|
<button class="btn btn-icon mx-auto">
|
||||||
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i>
|
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i>
|
||||||
|
@ -21,6 +21,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes move-up-down {
|
@keyframes move-up-down {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@ -29,3 +34,16 @@
|
|||||||
transform: translateY(-10px);
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookmark-effect {
|
||||||
|
animation: bookmark 1s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bookmark {
|
||||||
|
0%, 100% {
|
||||||
|
filter: opacity(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: opacity(0.25);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
|
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
|
||||||
import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs';
|
import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs';
|
||||||
import { debounceTime, takeUntil } from 'rxjs/operators';
|
import { debounceTime, takeUntil } from 'rxjs/operators';
|
||||||
import { ReaderService } from '../../_services/reader.service';
|
import { ReaderService } from '../../_services/reader.service';
|
||||||
@ -63,6 +62,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
@Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>();
|
@Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>();
|
||||||
|
|
||||||
@Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>();
|
@Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>();
|
||||||
|
@Input() bookmarkPage: ReplaySubject<number> = new ReplaySubject<number>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores and emits all the src urls
|
* Stores and emits all the src urls
|
||||||
@ -127,12 +127,16 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
return Math.max(...Object.values(this.imagesLoaded));
|
return Math.max(...Object.values(this.imagesLoaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get areImagesWiderThanWindow() {
|
||||||
|
return this.webtoonImageWidth > (window.innerWidth || document.documentElement.clientWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
constructor(private readerService: ReaderService, private renderer: Renderer2, private toastr: ToastrService) {}
|
constructor(private readerService: ReaderService, private renderer: Renderer2) {}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) {
|
if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) {
|
||||||
@ -167,11 +171,23 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.setPageNum(page, true);
|
this.setPageNum(page, true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.bookmarkPage) {
|
||||||
|
this.bookmarkPage.pipe(takeUntil(this.onDestroy)).subscribe(page => {
|
||||||
|
const image = document.querySelector('img[id^="page-' + page + '"]');
|
||||||
|
if (image) {
|
||||||
|
this.renderer.addClass(image, 'bookmark-effect');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.renderer.removeClass(image, 'bookmark-effect');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On scroll in document, calculate if the user/javascript has scrolled to the current image element (and it's visible), update that scrolling has ended completely,
|
* On scroll in document, calculate if the user/javascript has scrolled to the current image element (and it's visible), update that scrolling has ended completely,
|
||||||
* and calculate the direction the scrolling is occuring. This is used for prefetching.
|
* and calculate the direction the scrolling is occuring. This is not used for prefetching.
|
||||||
* @param event Scroll Event
|
* @param event Scroll Event
|
||||||
*/
|
*/
|
||||||
handleScrollEvent(event?: any) {
|
handleScrollEvent(event?: any) {
|
||||||
@ -179,11 +195,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|| document.documentElement.scrollTop
|
|| document.documentElement.scrollTop
|
||||||
|| document.body.scrollTop || 0);
|
|| document.body.scrollTop || 0);
|
||||||
|
|
||||||
if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) {
|
|
||||||
this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false');
|
|
||||||
this.isScrolling = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (verticalOffset > this.prevScrollPosition) {
|
if (verticalOffset > this.prevScrollPosition) {
|
||||||
this.scrollingDirection = PAGING_DIRECTION.FORWARD;
|
this.scrollingDirection = PAGING_DIRECTION.FORWARD;
|
||||||
} else {
|
} else {
|
||||||
@ -191,6 +202,23 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.prevScrollPosition = verticalOffset;
|
this.prevScrollPosition = verticalOffset;
|
||||||
|
|
||||||
|
if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) {
|
||||||
|
this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false');
|
||||||
|
this.isScrolling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isScrolling) {
|
||||||
|
// Use offset of the image against the scroll container to test if the most of the image is visible on the screen. We can use this
|
||||||
|
// to mark the current page and separate the prefetching code.
|
||||||
|
const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]'))
|
||||||
|
.filter(entry => this.shouldElementCountAsCurrentPage(entry));
|
||||||
|
|
||||||
|
if (midlineImages.length > 0) {
|
||||||
|
this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check if we hit the last page
|
// Check if we hit the last page
|
||||||
this.checkIfShouldTriggerContinuousReader();
|
this.checkIfShouldTriggerContinuousReader();
|
||||||
|
|
||||||
@ -219,12 +247,13 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (this.atTop && this.pageNum > 0) {
|
if (this.atTop && this.pageNum > 0) {
|
||||||
this.atTop = false;
|
this.atTop = false;
|
||||||
}
|
}
|
||||||
// debug mode will add an extra pixel from the image border + (this.debug ? 1 : 0)
|
|
||||||
if (totalScroll === totalHeight && !this.atBottom) {
|
if (totalScroll === totalHeight && !this.atBottom) {
|
||||||
this.atBottom = true;
|
this.atBottom = true;
|
||||||
this.setPageNum(this.totalPages);
|
this.setPageNum(this.totalPages);
|
||||||
|
|
||||||
// Scroll user back to original location
|
// Scroll user back to original location
|
||||||
this.previousScrollHeightMinusTop = document.documentElement.scrollTop;
|
this.previousScrollHeightMinusTop = this.getScrollTop();
|
||||||
requestAnimationFrame(() => document.documentElement.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2));
|
requestAnimationFrame(() => document.documentElement.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2));
|
||||||
} else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) {
|
} else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) {
|
||||||
// This if statement will fire once we scroll into the spacer at all
|
// This if statement will fire once we scroll into the spacer at all
|
||||||
@ -266,6 +295,28 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is any part of the element visible in the scrollport and is it above the midline trigger.
|
||||||
|
* The midline trigger does not mean it is half of the screen. It may be top 25%.
|
||||||
|
* @param elem HTML Element
|
||||||
|
* @returns If above midline
|
||||||
|
*/
|
||||||
|
shouldElementCountAsCurrentPage(elem: Element) {
|
||||||
|
if (elem === null || elem === undefined) { return false; }
|
||||||
|
|
||||||
|
var rect = elem.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (rect.bottom >= 0 &&
|
||||||
|
rect.right >= 0 &&
|
||||||
|
rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
|
rect.left <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
|
) {
|
||||||
|
const topX = (window.innerHeight || document.documentElement.clientHeight);
|
||||||
|
return Math.abs(rect.top / topX) <= 0.25;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
initWebtoonReader() {
|
initWebtoonReader() {
|
||||||
this.imagesLoaded = {};
|
this.imagesLoaded = {};
|
||||||
@ -327,7 +378,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.debugLog('[Intersection] Page ' + imagePage + ' is visible: ', entry.isIntersecting);
|
this.debugLog('[Intersection] Page ' + imagePage + ' is visible: ', entry.isIntersecting);
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
this.debugLog('[Intersection] ! Page ' + imagePage + ' just entered screen');
|
this.debugLog('[Intersection] ! Page ' + imagePage + ' just entered screen');
|
||||||
this.setPageNum(imagePage);
|
this.prefetchWebtoonImages(imagePage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -350,10 +401,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|
|
||||||
if (scrollToPage) {
|
if (scrollToPage) {
|
||||||
const currentImage = document.querySelector('img#page-' + this.pageNum);
|
const currentImage = document.querySelector('img#page-' + this.pageNum);
|
||||||
if (currentImage !== null && !this.isElementVisible(currentImage)) {
|
if (currentImage === null) return;
|
||||||
this.debugLog('[GoToPage] Scrolling to page', this.pageNum);
|
this.debugLog('[GoToPage] Scrolling to page', this.pageNum);
|
||||||
this.scrollToCurrentPage();
|
this.scrollToCurrentPage();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,6 +415,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
* Performs the scroll for the current page element. Updates any state variables needed.
|
* Performs the scroll for the current page element. Updates any state variables needed.
|
||||||
*/
|
*/
|
||||||
scrollToCurrentPage() {
|
scrollToCurrentPage() {
|
||||||
|
this.debugLog('Scrolling to ', this.pageNum);
|
||||||
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
|
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
|
||||||
if (!this.currentPageElem) { return; }
|
if (!this.currentPageElem) { return; }
|
||||||
|
|
||||||
@ -414,19 +465,29 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
calculatePrefetchIndecies() {
|
/**
|
||||||
|
* Finds the ranges of indecies to load from backend. totalPages - 1 is due to backend will automatically return last page for any page number
|
||||||
|
* above totalPages. Webtoon reader might ask for that which results in duplicate last pages.
|
||||||
|
* @param pageNum
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
calculatePrefetchIndecies(pageNum: number = -1) {
|
||||||
|
if (pageNum == -1) {
|
||||||
|
pageNum = this.pageNum;
|
||||||
|
}
|
||||||
|
|
||||||
let startingIndex = 0;
|
let startingIndex = 0;
|
||||||
let endingIndex = 0;
|
let endingIndex = 0;
|
||||||
if (this.isScrollingForwards()) {
|
if (this.isScrollingForwards()) {
|
||||||
startingIndex = Math.min(Math.max(this.pageNum - this.bufferPages, 0), this.totalPages);
|
startingIndex = Math.min(Math.max(pageNum - this.bufferPages, 0), this.totalPages - 1);
|
||||||
endingIndex = Math.min(Math.max(this.pageNum + this.bufferPages, 0), this.totalPages);
|
endingIndex = Math.min(Math.max(pageNum + this.bufferPages, 0), this.totalPages - 1);
|
||||||
|
|
||||||
if (startingIndex === this.totalPages) {
|
if (startingIndex === this.totalPages) {
|
||||||
return [0, 0];
|
return [0, 0];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
startingIndex = Math.min(Math.max(this.pageNum - this.bufferPages, 0), this.totalPages);
|
startingIndex = Math.min(Math.max(pageNum - this.bufferPages, 0), this.totalPages - 1);
|
||||||
endingIndex = Math.min(Math.max(this.pageNum + this.bufferPages, 0), this.totalPages);
|
endingIndex = Math.min(Math.max(pageNum + this.bufferPages, 0), this.totalPages - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -443,8 +504,13 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
return [...Array(size).keys()].map(i => i + startAt);
|
return [...Array(size).keys()].map(i => i + startAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
prefetchWebtoonImages() {
|
prefetchWebtoonImages(pageNum: number = -1) {
|
||||||
const [startingIndex, endingIndex] = this.calculatePrefetchIndecies();
|
|
||||||
|
if (pageNum === -1) {
|
||||||
|
pageNum = this.pageNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [startingIndex, endingIndex] = this.calculatePrefetchIndecies(pageNum);
|
||||||
if (startingIndex === 0 && endingIndex === 0) { return; }
|
if (startingIndex === 0 && endingIndex === 0) { return; }
|
||||||
|
|
||||||
this.debugLog('\t[PREFETCH] prefetching pages: ' + startingIndex + ' to ' + endingIndex);
|
this.debugLog('\t[PREFETCH] prefetching pages: ' + startingIndex + ' to ' + endingIndex);
|
||||||
|
@ -24,11 +24,19 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<div (click)="toggleMenu()" class="reading-area">
|
<div (click)="toggleMenu()" class="reading-area">
|
||||||
<canvas #content class="{{getFittingOptionClass()}} {{this.colorMode}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}}"
|
<canvas #content class="{{getFittingOptionClass()}} {{this.colorMode}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}} "
|
||||||
ondragstart="return false;" onselectstart="return false;">
|
ondragstart="return false;" onselectstart="return false;">
|
||||||
</canvas>
|
</canvas>
|
||||||
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
|
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
|
||||||
<app-infinite-scroller [pageNum]="pageNum" [bufferPages]="5" [goToPage]="goToPageEvent" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages - 1" [urlProvider]="getPageUrl" (loadNextChapter)="loadNextChapter()" (loadPrevChapter)="loadPrevChapter()"></app-infinite-scroller>
|
<app-infinite-scroller [pageNum]="pageNum"
|
||||||
|
[bufferPages]="5"
|
||||||
|
[goToPage]="goToPageEvent"
|
||||||
|
(pageNumberChange)="handleWebtoonPageChange($event)"
|
||||||
|
[totalPages]="maxPages"
|
||||||
|
[urlProvider]="getPageUrl"
|
||||||
|
(loadNextChapter)="loadNextChapter()"
|
||||||
|
(loadPrevChapter)="loadPrevChapter()"
|
||||||
|
[bookmarkPage]="showBookmarkEffectEvent"></app-infinite-scroller>
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD">
|
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD">
|
||||||
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
|
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
|
||||||
@ -43,7 +51,7 @@
|
|||||||
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
|
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
|
||||||
<button class="btn btn-small btn-icon col-1" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" title="First Page"><i class="fa fa-step-backward" aria-hidden="true"></i></button>
|
<button class="btn btn-small btn-icon col-1" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" title="First Page"><i class="fa fa-step-backward" aria-hidden="true"></i></button>
|
||||||
<div class="col custom-slider" *ngIf="pageOptions.ceil > 0; else noSlider">
|
<div class="col custom-slider" *ngIf="pageOptions.ceil > 0; else noSlider">
|
||||||
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
|
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChange)="sliderDragUpdate($event)" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
|
||||||
</div>
|
</div>
|
||||||
<ng-template #noSlider>
|
<ng-template #noSlider>
|
||||||
<div class="col custom-slider">
|
<div class="col custom-slider">
|
||||||
@ -94,9 +102,7 @@
|
|||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<select class="form-control" id="page-splitting" formControlName="pageSplitOption">
|
<select class="form-control" id="page-splitting" formControlName="pageSplitOption">
|
||||||
<option [value]="1">Right to Left</option>
|
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text}}</option>
|
||||||
<option [value]="0">Left to Right</option>
|
|
||||||
<option [value]="2">None</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '../../theme/colors';
|
@use '../../theme/colors';
|
||||||
|
|
||||||
$center-width: 50%;
|
$center-width: 50%;
|
||||||
$side-width: 25%;
|
$side-width: 25%;
|
||||||
@ -178,7 +178,7 @@ canvas {
|
|||||||
height: 2px;
|
height: 2px;
|
||||||
}
|
}
|
||||||
.custom-slider .ngx-slider .ngx-slider-selection {
|
.custom-slider .ngx-slider .ngx-slider-selection {
|
||||||
background: $primary-color;
|
background: colors.$primary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-slider .ngx-slider .ngx-slider-pointer {
|
.custom-slider .ngx-slider .ngx-slider-pointer {
|
||||||
@ -186,7 +186,7 @@ canvas {
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
top: auto; /* to remove the default positioning */
|
top: auto; /* to remove the default positioning */
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: $primary-color; // #333;
|
background-color: colors.$primary-color; // #333;
|
||||||
border-top-left-radius: 3px;
|
border-top-left-radius: 3px;
|
||||||
border-top-right-radius: 3px;
|
border-top-right-radius: 3px;
|
||||||
}
|
}
|
||||||
@ -217,12 +217,13 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-slider .ngx-slider .ngx-slider-tick.ngx-slider-selected {
|
.custom-slider .ngx-slider .ngx-slider-tick.ngx-slider-selected {
|
||||||
background: $primary-color;
|
background: colors.$primary-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.webtoon-images {
|
.webtoon-images {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight {
|
.highlight {
|
||||||
@ -234,7 +235,16 @@ canvas {
|
|||||||
animation: fadein .5s both;
|
animation: fadein .5s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEBUG
|
|
||||||
.active-image {
|
.bookmark-effect {
|
||||||
border: 5px solid red;
|
animation: bookmark 0.7s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes bookmark {
|
||||||
|
0%, 100% {
|
||||||
|
border: 0px;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border: 5px solid colors.$primary-color;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { AfterViewInit, Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core';
|
import { AfterViewInit, Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
|
||||||
import { Location } from '@angular/common';
|
import { Location } from '@angular/common';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { take, takeUntil } from 'rxjs/operators';
|
import { take, takeUntil } from 'rxjs/operators';
|
||||||
@ -12,7 +12,7 @@ import { ScalingOption } from '../_models/preferences/scaling-option';
|
|||||||
import { PageSplitOption } from '../_models/preferences/page-split-option';
|
import { PageSplitOption } from '../_models/preferences/page-split-option';
|
||||||
import { forkJoin, ReplaySubject, Subject } from 'rxjs';
|
import { forkJoin, ReplaySubject, Subject } from 'rxjs';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
import { KEY_CODES, UtilityService, Breakpoint } from '../shared/_services/utility.service';
|
||||||
import { CircularArray } from '../shared/data-structures/circular-array';
|
import { CircularArray } from '../shared/data-structures/circular-array';
|
||||||
import { MemberService } from '../_services/member.service';
|
import { MemberService } from '../_services/member.service';
|
||||||
import { Stack } from '../shared/data-structures/stack';
|
import { Stack } from '../shared/data-structures/stack';
|
||||||
@ -20,7 +20,7 @@ import { ChangeContext, LabelType, Options } from '@angular-slider/ngx-slider';
|
|||||||
import { trigger, state, style, transition, animate } from '@angular/animations';
|
import { trigger, state, style, transition, animate } from '@angular/animations';
|
||||||
import { ChapterInfo } from './_models/chapter-info';
|
import { ChapterInfo } from './_models/chapter-info';
|
||||||
import { COLOR_FILTER, FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums';
|
import { COLOR_FILTER, FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums';
|
||||||
import { Preferences, scalingOptions } from '../_models/preferences/preferences';
|
import { pageSplitOptions, scalingOptions } from '../_models/preferences/preferences';
|
||||||
import { READER_MODE } from '../_models/preferences/reader-mode';
|
import { READER_MODE } from '../_models/preferences/reader-mode';
|
||||||
import { MangaFormat } from '../_models/manga-format';
|
import { MangaFormat } from '../_models/manga-format';
|
||||||
import { LibraryService } from '../_services/library.service';
|
import { LibraryService } from '../_services/library.service';
|
||||||
@ -96,13 +96,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
scalingOptions = scalingOptions;
|
scalingOptions = scalingOptions;
|
||||||
readingDirection = ReadingDirection.LeftToRight;
|
readingDirection = ReadingDirection.LeftToRight;
|
||||||
scalingOption = ScalingOption.FitToHeight;
|
scalingOption = ScalingOption.FitToHeight;
|
||||||
pageSplitOption = PageSplitOption.SplitRightToLeft;
|
pageSplitOption = PageSplitOption.FitSplit;
|
||||||
currentImageSplitPart: SPLIT_PAGE_PART = SPLIT_PAGE_PART.NO_SPLIT;
|
currentImageSplitPart: SPLIT_PAGE_PART = SPLIT_PAGE_PART.NO_SPLIT;
|
||||||
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
|
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
|
||||||
colorMode: COLOR_FILTER = COLOR_FILTER.NONE;
|
colorMode: COLOR_FILTER = COLOR_FILTER.NONE;
|
||||||
autoCloseMenu: boolean = true;
|
autoCloseMenu: boolean = true;
|
||||||
readerMode: READER_MODE = READER_MODE.MANGA_LR;
|
readerMode: READER_MODE = READER_MODE.MANGA_LR;
|
||||||
|
|
||||||
|
pageSplitOptions = pageSplitOptions;
|
||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
@ViewChild('content') canvas: ElementRef | undefined;
|
@ViewChild('content') canvas: ElementRef | undefined;
|
||||||
@ -124,7 +126,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* An event emiter when a page change occurs. Used soley by the webtoon reader.
|
* An event emiter when a page change occurs. Used soley by the webtoon reader.
|
||||||
*/
|
*/
|
||||||
goToPageEvent: ReplaySubject<number> = new ReplaySubject<number>();
|
goToPageEvent: ReplaySubject<number> = new ReplaySubject<number>();
|
||||||
|
/**
|
||||||
|
* An event emiter when a bookmark on a page change occurs. Used soley by the webtoon reader.
|
||||||
|
*/
|
||||||
|
showBookmarkEffectEvent: ReplaySubject<number> = new ReplaySubject<number>();
|
||||||
/**
|
/**
|
||||||
* If the menu is open/visible.
|
* If the menu is open/visible.
|
||||||
*/
|
*/
|
||||||
@ -263,11 +268,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
return ReadingDirection;
|
return ReadingDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get PageSplitOption(): typeof PageSplitOption {
|
||||||
|
return PageSplitOption;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
|
||||||
public readerService: ReaderService, private location: Location,
|
public readerService: ReaderService, private location: Location,
|
||||||
private formBuilder: FormBuilder, private navService: NavService,
|
private formBuilder: FormBuilder, private navService: NavService,
|
||||||
private toastr: ToastrService, private memberService: MemberService,
|
private toastr: ToastrService, private memberService: MemberService,
|
||||||
private libraryService: LibraryService, private utilityService: UtilityService) {
|
private libraryService: LibraryService, private utilityService: UtilityService,
|
||||||
|
private renderer: Renderer2) {
|
||||||
this.navService.hideNavBar();
|
this.navService.hideNavBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -309,7 +319,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.generalSettingsForm = this.formBuilder.group({
|
this.generalSettingsForm = this.formBuilder.group({
|
||||||
autoCloseMenu: this.autoCloseMenu,
|
autoCloseMenu: this.autoCloseMenu,
|
||||||
pageSplitOption: this.pageSplitOption + '',
|
pageSplitOption: this.pageSplitOption,
|
||||||
fittingOption: this.translateScalingOption(this.scalingOption)
|
fittingOption: this.translateScalingOption(this.scalingOption)
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -317,8 +327,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => {
|
this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => {
|
||||||
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
|
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
|
||||||
// On change of splitting, re-render the page if the page is already split
|
const needsSplitting = this.isCoverImage();
|
||||||
const needsSplitting = this.canvasImage.width > this.canvasImage.height;
|
// If we need to split on a menu change, then we need to re-render.
|
||||||
if (needsSplitting) {
|
if (needsSplitting) {
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
}
|
}
|
||||||
@ -354,6 +364,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.onDestroy.next();
|
this.onDestroy.next();
|
||||||
this.onDestroy.complete();
|
this.onDestroy.complete();
|
||||||
this.goToPageEvent.complete();
|
this.goToPageEvent.complete();
|
||||||
|
this.showBookmarkEffectEvent.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:keyup', ['$event'])
|
@HostListener('window:keyup', ['$event'])
|
||||||
@ -421,7 +432,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.maxPages = results.chapterInfo.pages;
|
this.maxPages = results.chapterInfo.pages;
|
||||||
let page = results.progress.pageNum;
|
let page = results.progress.pageNum;
|
||||||
if (page > this.maxPages) {
|
if (page > this.maxPages) {
|
||||||
page = this.maxPages - 1;
|
page = this.maxPages;
|
||||||
}
|
}
|
||||||
this.setPageNum(page);
|
this.setPageNum(page);
|
||||||
|
|
||||||
@ -614,15 +625,20 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSplitLeftToRight() {
|
isSplitLeftToRight() {
|
||||||
return (this.generalSettingsForm?.get('pageSplitOption')?.value + '') === (PageSplitOption.SplitLeftToRight + '');
|
return parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) === PageSplitOption.SplitLeftToRight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns If the current model reflects no split of fit split
|
||||||
|
*/
|
||||||
isNoSplit() {
|
isNoSplit() {
|
||||||
return (this.generalSettingsForm?.get('pageSplitOption')?.value + '') === (PageSplitOption.NoSplit + '');
|
const splitValue = parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10);
|
||||||
|
return splitValue === PageSplitOption.NoSplit || splitValue === PageSplitOption.FitSplit;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSplitPage() {
|
updateSplitPage() {
|
||||||
const needsSplitting = this.canvasImage.width > this.canvasImage.height;
|
const needsSplitting = this.isCoverImage();
|
||||||
if (!needsSplitting || this.isNoSplit()) {
|
if (!needsSplitting || this.isNoSplit()) {
|
||||||
this.currentImageSplitPart = SPLIT_PAGE_PART.NO_SPLIT;
|
this.currentImageSplitPart = SPLIT_PAGE_PART.NO_SPLIT;
|
||||||
return;
|
return;
|
||||||
@ -734,6 +750,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
loadNextChapter() {
|
loadNextChapter() {
|
||||||
if (this.nextPageDisabled) { return; }
|
if (this.nextPageDisabled) { return; }
|
||||||
|
if (this.nextChapterDisabled) { return; }
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) {
|
if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) {
|
||||||
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
|
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
|
||||||
@ -747,6 +764,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
loadPrevChapter() {
|
loadPrevChapter() {
|
||||||
if (this.prevPageDisabled) { return; }
|
if (this.prevPageDisabled) { return; }
|
||||||
|
if (this.prevChapterDisabled) { return; }
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.continuousChaptersStack.pop();
|
this.continuousChaptersStack.pop();
|
||||||
const prevChapter = this.continuousChaptersStack.peek();
|
const prevChapter = this.continuousChaptersStack.peek();
|
||||||
@ -814,21 +832,23 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (needsScaling) {
|
if (needsScaling) {
|
||||||
this.canvas.nativeElement.width = isSafari ? 4_096 : 16_384;
|
this.canvas.nativeElement.width = isSafari ? 4_096 : 16_384;
|
||||||
this.canvas.nativeElement.height = isSafari ? 4_096 : 16_384;
|
this.canvas.nativeElement.height = isSafari ? 4_096 : 16_384;
|
||||||
|
} else if (this.isCoverImage()) {
|
||||||
|
//this.canvas.nativeElement.width = this.canvasImage.width / 2;
|
||||||
|
//this.canvas.nativeElement.height = this.canvasImage.height;
|
||||||
} else {
|
} else {
|
||||||
this.canvas.nativeElement.width = this.canvasImage.width;
|
this.canvas.nativeElement.width = this.canvasImage.width;
|
||||||
this.canvas.nativeElement.height = this.canvasImage.height;
|
this.canvas.nativeElement.height = this.canvasImage.height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPage() {
|
renderPage() {
|
||||||
if (this.ctx && this.canvas) {
|
if (this.ctx && this.canvas) {
|
||||||
this.canvasImage.onload = null;
|
this.canvasImage.onload = null;
|
||||||
|
|
||||||
if (!this.setCanvasSize()) return;
|
this.setCanvasSize();
|
||||||
|
|
||||||
const needsSplitting = this.canvasImage.width > this.canvasImage.height;
|
const needsSplitting = this.isCoverImage();
|
||||||
this.updateSplitPage();
|
this.updateSplitPage();
|
||||||
|
|
||||||
if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.LEFT_PART) {
|
if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.LEFT_PART) {
|
||||||
@ -839,31 +859,39 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height);
|
this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height);
|
||||||
} else {
|
} else {
|
||||||
if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) {
|
if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) {
|
||||||
|
this.updateScalingForFirstPageRender();
|
||||||
|
}
|
||||||
|
|
||||||
let newScale = this.generalSettingsForm.get('fittingOption')?.value;
|
// Fit Split on a page that needs splitting
|
||||||
|
if (this.shouldRenderAsFitSplit()) {
|
||||||
const windowWidth = window.innerWidth
|
const windowWidth = window.innerWidth
|
||||||
|| document.documentElement.clientWidth
|
|| document.documentElement.clientWidth
|
||||||
|| document.body.clientWidth;
|
|| document.body.clientWidth;
|
||||||
const windowHeight = window.innerHeight
|
const windowHeight = window.innerHeight
|
||||||
|| document.documentElement.clientHeight
|
|| document.documentElement.clientHeight
|
||||||
|| document.body.clientHeight;
|
|| document.body.clientHeight;
|
||||||
|
// If the user's screen is wider than the image, just pretend this is no split, as it will render nicer
|
||||||
const widthRatio = windowWidth / this.canvasImage.width;
|
this.canvas.nativeElement.width = windowWidth;
|
||||||
const heightRatio = windowHeight / this.canvasImage.height;
|
this.canvas.nativeElement.height = windowHeight;
|
||||||
|
const ratio = this.canvasImage.width / this.canvasImage.height;
|
||||||
// Given that we now have image dimensions, assuming this isn't a split image,
|
let newWidth = windowWidth;
|
||||||
// Try to reset one time based on who's dimension (width/height) is smaller
|
let newHeight = newWidth / ratio;
|
||||||
if (widthRatio < heightRatio) {
|
if (newHeight > windowHeight) {
|
||||||
newScale = FITTING_OPTION.WIDTH;
|
newHeight = windowHeight;
|
||||||
} else if (widthRatio > heightRatio) {
|
newWidth = newHeight * ratio;
|
||||||
newScale = FITTING_OPTION.HEIGHT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.generalSettingsForm.get('fittingOption')?.setValue(newScale);
|
// Optimization: When the screen is larger than newWidth, allow no split rendering to occur for a better fit
|
||||||
this.firstPageRendered = true;
|
if (windowWidth > newWidth) {
|
||||||
|
this.ctx.drawImage(this.canvasImage, 0, 0);
|
||||||
|
} else {
|
||||||
|
this.ctx.drawImage(this.canvasImage, 0, 0, newWidth, newHeight);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.ctx.drawImage(this.canvasImage, 0, 0);
|
||||||
}
|
}
|
||||||
this.ctx.drawImage(this.canvasImage, 0, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset scroll on non HEIGHT Fits
|
// Reset scroll on non HEIGHT Fits
|
||||||
if (this.getFit() !== FITTING_OPTION.HEIGHT) {
|
if (this.getFit() !== FITTING_OPTION.HEIGHT) {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
@ -873,6 +901,41 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateScalingForFirstPageRender() {
|
||||||
|
const windowWidth = window.innerWidth
|
||||||
|
|| document.documentElement.clientWidth
|
||||||
|
|| document.body.clientWidth;
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
|| document.documentElement.clientHeight
|
||||||
|
|| document.body.clientHeight;
|
||||||
|
|
||||||
|
const needsSplitting = this.isCoverImage();
|
||||||
|
let newScale = this.generalSettingsForm.get('fittingOption')?.value;
|
||||||
|
const widthRatio = windowWidth / (this.canvasImage.width / (needsSplitting ? 2 : 1));
|
||||||
|
const heightRatio = windowHeight / (this.canvasImage.height);
|
||||||
|
|
||||||
|
// Given that we now have image dimensions, assuming this isn't a split image,
|
||||||
|
// Try to reset one time based on who's dimension (width/height) is smaller
|
||||||
|
if (widthRatio < heightRatio) {
|
||||||
|
newScale = FITTING_OPTION.WIDTH;
|
||||||
|
} else if (widthRatio > heightRatio) {
|
||||||
|
newScale = FITTING_OPTION.HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.firstPageRendered = true;
|
||||||
|
this.generalSettingsForm.get('fittingOption')?.setValue(newScale, {emitEvent: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
isCoverImage() {
|
||||||
|
return this.canvasImage.width > this.canvasImage.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
shouldRenderAsFitSplit() {
|
||||||
|
if (!this.isCoverImage() || parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) !== PageSplitOption.FitSplit) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
prefetch() {
|
prefetch() {
|
||||||
let index = 1;
|
let index = 1;
|
||||||
@ -895,16 +958,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
loadPage() {
|
loadPage() {
|
||||||
if (!this.canvas || !this.ctx) { return; }
|
if (!this.canvas || !this.ctx) { return; }
|
||||||
|
|
||||||
// Due to the fact that we start at image 0, but page 1, we need the last page to have progress as page + 1 to be completed
|
|
||||||
let pageNum = this.pageNum;
|
|
||||||
if (this.pageNum == this.maxPages - 1) {
|
|
||||||
pageNum = this.pageNum + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.incognitoMode) {
|
|
||||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.canvasImage = this.cachedImages.current();
|
this.canvasImage = this.cachedImages.current();
|
||||||
if (this.readerService.imageUrlToPageNum(this.canvasImage.src) !== this.pageNum || this.canvasImage.src === '' || !this.canvasImage.complete) {
|
if (this.readerService.imageUrlToPageNum(this.canvasImage.src) !== this.pageNum || this.canvasImage.src === '' || !this.canvasImage.complete) {
|
||||||
@ -942,6 +995,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
return side === 'right' ? 'highlight-2' : 'highlight';
|
return side === 'right' ? 'highlight-2' : 'highlight';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sliderDragUpdate(context: ChangeContext) {
|
||||||
|
// This will update the value for value except when in webtoon due to how the webtoon reader
|
||||||
|
// responds to page changes
|
||||||
|
if (this.readerMode !== READER_MODE.WEBTOON) {
|
||||||
|
this.setPageNum(context.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sliderPageUpdate(context: ChangeContext) {
|
sliderPageUpdate(context: ChangeContext) {
|
||||||
const page = context.value;
|
const page = context.value;
|
||||||
|
|
||||||
@ -974,6 +1035,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Due to the fact that we start at image 0, but page 1, we need the last page to have progress as page + 1 to be completed
|
||||||
|
let tempPageNum = this.pageNum;
|
||||||
|
if (this.pageNum == this.maxPages - 1) {
|
||||||
|
tempPageNum = this.pageNum + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.incognitoMode) {
|
||||||
|
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
goToPage(pageNum: number) {
|
goToPage(pageNum: number) {
|
||||||
@ -1053,54 +1124,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
handleWebtoonPageChange(updatedPageNum: number) {
|
handleWebtoonPageChange(updatedPageNum: number) {
|
||||||
this.setPageNum(updatedPageNum);
|
this.setPageNum(updatedPageNum);
|
||||||
if (this.incognitoMode) return;
|
|
||||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSettings() {
|
|
||||||
// NOTE: This is not called anywhere
|
|
||||||
if (this.user === undefined) return;
|
|
||||||
|
|
||||||
const data: Preferences = {
|
|
||||||
readingDirection: this.readingDirection,
|
|
||||||
scalingOption: this.scalingOption,
|
|
||||||
pageSplitOption: this.pageSplitOption,
|
|
||||||
autoCloseMenu: this.autoCloseMenu,
|
|
||||||
readerMode: this.readerMode,
|
|
||||||
|
|
||||||
bookReaderDarkMode: this.user.preferences.bookReaderDarkMode,
|
|
||||||
bookReaderFontFamily: this.user.preferences.bookReaderFontFamily,
|
|
||||||
bookReaderFontSize: this.user.preferences.bookReaderFontSize,
|
|
||||||
bookReaderLineSpacing: this.user.preferences.bookReaderLineSpacing,
|
|
||||||
bookReaderMargin: this.user.preferences.bookReaderMargin,
|
|
||||||
bookReaderTapToPaginate: this.user.preferences.bookReaderTapToPaginate,
|
|
||||||
bookReaderReadingDirection: this.readingDirection,
|
|
||||||
|
|
||||||
siteDarkMode: this.user.preferences.siteDarkMode,
|
|
||||||
};
|
|
||||||
this.accountService.updatePreferences(data).pipe(take(1)).subscribe((updatedPrefs) => {
|
|
||||||
this.toastr.success('User settings updated');
|
|
||||||
if (this.user) {
|
|
||||||
this.user.preferences = updatedPrefs;
|
|
||||||
}
|
|
||||||
this.resetSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSettings() {
|
|
||||||
this.generalSettingsForm.get('fittingOption')?.value.get('fittingOption')?.setValue(this.translateScalingOption(this.user.preferences.scalingOption));
|
|
||||||
this.generalSettingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption + '');
|
|
||||||
this.generalSettingsForm.get('autoCloseMenu')?.setValue(this.autoCloseMenu);
|
|
||||||
|
|
||||||
this.updateForm();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bookmarks the current page for the chapter
|
* Bookmarks the current page for the chapter
|
||||||
*/
|
*/
|
||||||
bookmarkPage() {
|
bookmarkPage() {
|
||||||
// TODO: Show some sort of UI visual to show that a page was bookmarked
|
|
||||||
const pageNum = this.pageNum;
|
const pageNum = this.pageNum;
|
||||||
if (this.pageBookmarked) {
|
if (this.pageBookmarked) {
|
||||||
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {
|
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {
|
||||||
@ -1112,6 +1141,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show an effect on the image to show that it was bookmarked
|
||||||
|
this.showBookmarkEffectEvent.next(pageNum);
|
||||||
|
if (this.readerMode != READER_MODE.WEBTOON) {
|
||||||
|
if (this.canvas) {
|
||||||
|
this.renderer.addClass(this.canvas?.nativeElement, 'bookmark-effect');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.renderer.removeClass(this.canvas?.nativeElement, 'bookmark-effect');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
<ng-container>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-icon {{progressEventsSource.getValue().length > 0 ? 'colored' : ''}}"
|
||||||
|
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'">
|
||||||
|
<i aria-hidden="true" class="fa fa-wave-square"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ng-template #popContent>
|
||||||
|
<ul class="list-group list-group-flush dark-menu">
|
||||||
|
<li class="list-group-item dark-menu-item" *ngFor="let event of progressEvents$ | async">
|
||||||
|
<div class="spinner-border text-primary small-spinner"
|
||||||
|
role="status" title="Started at {{event.timestamp | date: 'short'}}"
|
||||||
|
attr.aria-valuetext="{{prettyPrintProgress(event.progress)}}%" [attr.aria-valuenow]="prettyPrintProgress(event.progress)">
|
||||||
|
<span class="sr-only">Scan for {{event.libraryName}} in progress</span>
|
||||||
|
</div>
|
||||||
|
{{prettyPrintProgress(event.progress)}}%
|
||||||
|
{{prettyPrintEvent(event.eventType)}} {{event.libraryName}}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item dark-menu-item" *ngIf="progressEventsSource.getValue().length === 0">Not much going on here</li>
|
||||||
|
</ul>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
@ -0,0 +1,23 @@
|
|||||||
|
@import "../../theme/colors";
|
||||||
|
|
||||||
|
.small-spinner {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-events {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-events .popover-body {
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colored {
|
||||||
|
background-color: $primary-color;
|
||||||
|
border-radius: 60px;
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
import { ProgressEvent } from '../_models/events/scan-library-progress-event';
|
||||||
|
import { User } from '../_models/user';
|
||||||
|
import { LibraryService } from '../_services/library.service';
|
||||||
|
import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
|
||||||
|
|
||||||
|
interface ProcessedEvent {
|
||||||
|
eventType: string;
|
||||||
|
timestamp?: string;
|
||||||
|
progress: number;
|
||||||
|
libraryId: number;
|
||||||
|
libraryName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressType = EVENTS.ScanLibraryProgress | EVENTS.RefreshMetadataProgress | EVENTS.BackupDatabaseProgress | EVENTS.CleanupProgress;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nav-events-toggle',
|
||||||
|
templateUrl: './nav-events-toggle.component.html',
|
||||||
|
styleUrls: ['./nav-events-toggle.component.scss']
|
||||||
|
})
|
||||||
|
export class NavEventsToggleComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
@Input() user!: User;
|
||||||
|
|
||||||
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events that come through and are merged (ie progress event gets merged into a progress event)
|
||||||
|
*/
|
||||||
|
progressEventsSource = new BehaviorSubject<ProcessedEvent[]>([]);
|
||||||
|
progressEvents$ = this.progressEventsSource.asObservable();
|
||||||
|
|
||||||
|
constructor(private messageHub: MessageHubService, private libraryService: LibraryService) { }
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.onDestroy.next();
|
||||||
|
this.onDestroy.complete();
|
||||||
|
this.progressEventsSource.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
|
||||||
|
if (event.event === EVENTS.ScanLibraryProgress || event.event === EVENTS.RefreshMetadataProgress || event.event === EVENTS.BackupDatabaseProgress || event.event === EVENTS.CleanupProgress) {
|
||||||
|
this.processProgressEvent(event, event.event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
processProgressEvent(event: Message<ProgressEvent>, eventType: string) {
|
||||||
|
const scanEvent = event.payload as ProgressEvent;
|
||||||
|
console.log(event.event, event.payload);
|
||||||
|
|
||||||
|
|
||||||
|
this.libraryService.getLibraryNames().subscribe(names => {
|
||||||
|
const data = this.progressEventsSource.getValue();
|
||||||
|
const index = data.findIndex(item => item.eventType === eventType && item.libraryId === event.payload.libraryId);
|
||||||
|
if (index >= 0) {
|
||||||
|
data.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanEvent.progress !== 1) {
|
||||||
|
const libraryName = names[scanEvent.libraryId] || '';
|
||||||
|
const newEvent = {eventType: eventType, timestamp: scanEvent.eventTime, progress: scanEvent.progress, libraryId: scanEvent.libraryId, libraryName};
|
||||||
|
data.push(newEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
this.progressEventsSource.next(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prettyPrintProgress(progress: number) {
|
||||||
|
return Math.trunc(progress * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
prettyPrintEvent(eventType: string) {
|
||||||
|
switch(eventType) {
|
||||||
|
case (EVENTS.ScanLibraryProgress): return 'Scanning ';
|
||||||
|
case (EVENTS.RefreshMetadataProgress): return 'Refreshing ';
|
||||||
|
case (EVENTS.CleanupProgress): return 'Clearing Cache';
|
||||||
|
case (EVENTS.BackupDatabaseProgress): return 'Backing up Database';
|
||||||
|
default: return eventType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -62,6 +62,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
|
||||||
|
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
|
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
|
||||||
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>
|
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>
|
||||||
{{user.username | sentenceCase}}
|
{{user.username | sentenceCase}}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '~bootstrap/scss/mixins/_breakpoints.scss';
|
@import '~bootstrap/scss/mixins/_breakpoints.scss'; // TODO: Use @forwards for this?
|
||||||
|
|
||||||
$primary-color: white;
|
$primary-color: white;
|
||||||
$bg-color: rgb(22, 27, 34);
|
$bg-color: rgb(22, 27, 34);
|
||||||
|
@ -11,6 +11,7 @@ export class NotConnectedComponent implements OnInit {
|
|||||||
constructor(private memberService: MemberService, private router: Router) { }
|
constructor(private memberService: MemberService, private router: Router) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// BUG: TODO: This causes an infinite reload loop on the UI when the API on backend doesn't exist
|
||||||
// We make a call to backend on refresh so that if it's up, we can redirect to /home
|
// We make a call to backend on refresh so that if it's up, we can redirect to /home
|
||||||
this.memberService.adminExists().subscribe((exists) => {
|
this.memberService.adminExists().subscribe((exists) => {
|
||||||
const pageResume = localStorage.getItem('kavita--no-connection-url');
|
const pageResume = localStorage.getItem('kavita--no-connection-url');
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||||
<app-card-detail-layout header="In Progress"
|
<app-card-detail-layout header="On Deck"
|
||||||
[isLoading]="isLoading"
|
[isLoading]="isLoading"
|
||||||
[items]="series"
|
[items]="series"
|
||||||
[filters]="filters"
|
[filters]="filters"
|
@ -13,11 +13,11 @@ import { ActionService } from '../_services/action.service';
|
|||||||
import { SeriesService } from '../_services/series.service';
|
import { SeriesService } from '../_services/series.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-in-progress',
|
selector: 'app-on-deck',
|
||||||
templateUrl: './in-progress.component.html',
|
templateUrl: './on-deck.component.html',
|
||||||
styleUrls: ['./in-progress.component.scss']
|
styleUrls: ['./on-deck.component.scss']
|
||||||
})
|
})
|
||||||
export class InProgressComponent implements OnInit {
|
export class OnDeckComponent implements OnInit {
|
||||||
|
|
||||||
isLoading: boolean = true;
|
isLoading: boolean = true;
|
||||||
series: Series[] = [];
|
series: Series[] = [];
|
||||||
@ -31,7 +31,7 @@ export class InProgressComponent implements OnInit {
|
|||||||
constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title,
|
constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title,
|
||||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService) {
|
private actionService: ActionService, public bulkSelectionService: BulkSelectionService) {
|
||||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||||
this.titleService.setTitle('Kavita - In Progress');
|
this.titleService.setTitle('Kavita - On Deck');
|
||||||
if (this.pagination === undefined || this.pagination === null) {
|
if (this.pagination === undefined || this.pagination === null) {
|
||||||
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||||
}
|
}
|
||||||
@ -79,7 +79,7 @@ export class InProgressComponent implements OnInit {
|
|||||||
this.pagination.currentPage = parseInt(page, 10);
|
this.pagination.currentPage = parseInt(page, 10);
|
||||||
}
|
}
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.seriesService.getInProgress(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
this.seriesService.getOnDeck(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||||
this.series = series.result;
|
this.series = series.result;
|
||||||
this.pagination = series.pagination;
|
this.pagination = series.pagination;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
@ -136,7 +136,12 @@ export class ReadingListDetailComponent implements OnInit {
|
|||||||
return 'Volume ' + this.utilityService.cleanSpecialTitle(item.chapterNumber);
|
return 'Volume ' + this.utilityService.cleanSpecialTitle(item.chapterNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.utilityService.formatChapterName(this.libraryTypes[item.libraryId], true, true) + item.chapterNumber;
|
let chapterNum = item.chapterNumber;
|
||||||
|
if (!item.chapterNumber.match(/^\d+$/)) {
|
||||||
|
chapterNum = this.utilityService.cleanSpecialTitle(item.chapterNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.utilityService.formatChapterName(this.libraryTypes[item.libraryId], true, true) + chapterNum;
|
||||||
}
|
}
|
||||||
|
|
||||||
orderUpdated(event: IndexUpdateEvent) {
|
orderUpdated(event: IndexUpdateEvent) {
|
||||||
|
@ -10,13 +10,13 @@ import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
|||||||
import { Pagination } from '../_models/pagination';
|
import { Pagination } from '../_models/pagination';
|
||||||
import { Series } from '../_models/series';
|
import { Series } from '../_models/series';
|
||||||
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';
|
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';
|
||||||
import { Action, ActionFactoryService } from '../_services/action-factory.service';
|
import { Action } from '../_services/action-factory.service';
|
||||||
import { ActionService } from '../_services/action.service';
|
import { ActionService } from '../_services/action.service';
|
||||||
import { MessageHubService } from '../_services/message-hub.service';
|
import { MessageHubService } from '../_services/message-hub.service';
|
||||||
import { SeriesService } from '../_services/series.service';
|
import { SeriesService } from '../_services/series.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is used as a standard layout for any card detail. ie) series, in-progress, collections, etc.
|
* This component is used as a standard layout for any card detail. ie) series, on-deck, collections, etc.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-recently-added',
|
selector: 'app-recently-added',
|
||||||
|
@ -14,4 +14,5 @@
|
|||||||
|
|
||||||
input {
|
input {
|
||||||
background-color: #fff !important;
|
background-color: #fff !important;
|
||||||
|
color: black !important;
|
||||||
}
|
}
|
@ -16,6 +16,7 @@ import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
|||||||
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
|
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
|
||||||
import { Chapter } from '../_models/chapter';
|
import { Chapter } from '../_models/chapter';
|
||||||
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
|
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
|
||||||
|
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
|
||||||
import { LibraryType } from '../_models/library';
|
import { LibraryType } from '../_models/library';
|
||||||
import { MangaFormat } from '../_models/manga-format';
|
import { MangaFormat } from '../_models/manga-format';
|
||||||
import { Series } from '../_models/series';
|
import { Series } from '../_models/series';
|
||||||
@ -26,7 +27,7 @@ import { ActionItem, ActionFactoryService, Action } from '../_services/action-fa
|
|||||||
import { ActionService } from '../_services/action.service';
|
import { ActionService } from '../_services/action.service';
|
||||||
import { ImageService } from '../_services/image.service';
|
import { ImageService } from '../_services/image.service';
|
||||||
import { LibraryService } from '../_services/library.service';
|
import { LibraryService } from '../_services/library.service';
|
||||||
import { MessageHubService } from '../_services/message-hub.service';
|
import { EVENTS, MessageHubService } from '../_services/message-hub.service';
|
||||||
import { ReaderService } from '../_services/reader.service';
|
import { ReaderService } from '../_services/reader.service';
|
||||||
import { SeriesService } from '../_services/series.service';
|
import { SeriesService } from '../_services/series.service';
|
||||||
|
|
||||||
@ -180,6 +181,16 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.toastr.success('Scan series completed');
|
this.toastr.success('Scan series completed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
|
||||||
|
if (event.event === EVENTS.SeriesRemoved) {
|
||||||
|
const seriesRemovedEvent = event.payload as SeriesRemovedEvent;
|
||||||
|
if (seriesRemovedEvent.seriesId === this.series.id) {
|
||||||
|
this.toastr.info('This series no longer exists');
|
||||||
|
this.router.navigateByUrl('/libraries');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const seriesId = parseInt(routeId, 10);
|
const seriesId = parseInt(routeId, 10);
|
||||||
this.libraryId = parseInt(libraryId, 10);
|
this.libraryId = parseInt(libraryId, 10);
|
||||||
this.seriesImage = this.imageService.getSeriesCoverImage(seriesId);
|
this.seriesImage = this.imageService.getSeriesCoverImage(seriesId);
|
||||||
|
@ -158,5 +158,4 @@ export class UtilityService {
|
|||||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
@import '../../../theme/colors';
|
|
||||||
|
|
||||||
$bg-color: #c9c9c9;
|
$bg-color: #c9c9c9;
|
||||||
$bdr-color: #f2f2f2;
|
$bdr-color: #f2f2f2;
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import '../../assets/themes/dark.scss';
|
@import "../../theme/colors";
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 15px;
|
width: 15px;
|
||||||
|
@ -5,6 +5,8 @@ import { debounceTime, filter, map, shareReplay, switchMap, take, takeUntil, tap
|
|||||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||||
import { TypeaheadSettings } from './typeahead-settings';
|
import { TypeaheadSettings } from './typeahead-settings';
|
||||||
|
|
||||||
|
export type SelectionCompareFn<T> = (a: T, b: T) => boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SelectionModel<T> is used for keeping track of multiple selections. Simple interface with ability to toggle.
|
* SelectionModel<T> is used for keeping track of multiple selections. Simple interface with ability to toggle.
|
||||||
* @param selectedState Optional state to set selectedOptions to. If not passed, defaults to false.
|
* @param selectedState Optional state to set selectedOptions to. If not passed, defaults to false.
|
||||||
@ -30,10 +32,16 @@ export class SelectionModel<T> {
|
|||||||
/**
|
/**
|
||||||
* Will toggle if the data item is selected or not. If data option is not tracked, will add it and set state to true.
|
* Will toggle if the data item is selected or not. If data option is not tracked, will add it and set state to true.
|
||||||
* @param data Item to toggle
|
* @param data Item to toggle
|
||||||
|
* @param selectedState Force the state
|
||||||
|
* @param compareFn An optional function to use for the lookup, else will use shallowEqual implementation
|
||||||
*/
|
*/
|
||||||
toggle(data: T, selectedState?: boolean) {
|
toggle(data: T, selectedState?: boolean, compareFn?: SelectionCompareFn<T>) {
|
||||||
//const dataItem = this._data.filter(d => d.value == data);
|
let lookupMethod = this.shallowEqual;
|
||||||
const dataItem = this._data.filter(d => this.shallowEqual(d.value, data));
|
if (compareFn != undefined || compareFn != null) {
|
||||||
|
lookupMethod = compareFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataItem = this._data.filter(d => lookupMethod(d.value, data));
|
||||||
if (dataItem.length > 0) {
|
if (dataItem.length > 0) {
|
||||||
if (selectedState != undefined) {
|
if (selectedState != undefined) {
|
||||||
dataItem[0].selected = selectedState;
|
dataItem[0].selected = selectedState;
|
||||||
@ -45,6 +53,7 @@ export class SelectionModel<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is the passed item selected
|
* Is the passed item selected
|
||||||
* @param data item to check against
|
* @param data item to check against
|
||||||
@ -65,6 +74,15 @@ export class SelectionModel<T> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns If some of the items are selected, but not all
|
||||||
|
*/
|
||||||
|
hasSomeSelected(): boolean {
|
||||||
|
const selectedCount = this._data.filter(d => d.selected).length;
|
||||||
|
return (selectedCount !== this._data.length && selectedCount !== 0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @returns All Selected items
|
* @returns All Selected items
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
@import "../../theme/_colors.scss";
|
@use "../../theme/colors";
|
||||||
|
|
||||||
.login {
|
.login {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -36,7 +36,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background-color: $primary-color;
|
background-color: colors.$primary-color;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user