mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
More Bugfixes (#2989)
This commit is contained in:
parent
1ae723b405
commit
a3e020fe17
@ -6,13 +6,13 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.5" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.6" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" />
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" />
|
||||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" />
|
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" />
|
||||||
<PackageReference Include="xunit" Version="2.8.0" />
|
<PackageReference Include="xunit" Version="2.8.1" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
@ -210,6 +210,7 @@ public class MangaParsingTests
|
|||||||
[InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1", "เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท")]
|
[InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1", "เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท")]
|
||||||
[InlineData("Max Level Returner เล่มที่ 5", "Max Level Returner")]
|
[InlineData("Max Level Returner เล่มที่ 5", "Max Level Returner")]
|
||||||
[InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")]
|
[InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")]
|
||||||
|
[InlineData("不安の種\uff0b - 01", "不安の種\uff0b")]
|
||||||
public void ParseSeriesTest(string filename, string expected)
|
public void ParseSeriesTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga));
|
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga));
|
||||||
|
@ -201,7 +201,6 @@ public class ParsingTests
|
|||||||
[InlineData("06", "06")]
|
[InlineData("06", "06")]
|
||||||
[InlineData("", "")]
|
[InlineData("", "")]
|
||||||
[InlineData("不安の種+", "不安の種+")]
|
[InlineData("不安の種+", "不安の種+")]
|
||||||
[InlineData("不安の種*", "不安の種*")]
|
|
||||||
public void NormalizeTest(string input, string expected)
|
public void NormalizeTest(string input, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, Normalize(input));
|
Assert.Equal(expected, Normalize(input));
|
||||||
|
@ -244,7 +244,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
|||||||
var directoriesSeen = new HashSet<string>();
|
var directoriesSeen = new HashSet<string>();
|
||||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
||||||
var scanResults = psf.ProcessFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
|
var scanResults = await psf.ProcessFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
|
||||||
foreach (var scanResult in scanResults)
|
foreach (var scanResult in scanResults)
|
||||||
{
|
{
|
||||||
directoriesSeen.Add(scanResult.Folder);
|
directoriesSeen.Add(scanResult.Folder);
|
||||||
@ -266,7 +266,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
|||||||
Assert.NotNull(library);
|
Assert.NotNull(library);
|
||||||
|
|
||||||
var directoriesSeen = new HashSet<string>();
|
var directoriesSeen = new HashSet<string>();
|
||||||
var scanResults = psf.ProcessFiles("C:/Data/", false,
|
var scanResults = await psf.ProcessFiles("C:/Data/", false,
|
||||||
await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
|
await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
|
||||||
|
|
||||||
foreach (var scanResult in scanResults)
|
foreach (var scanResult in scanResults)
|
||||||
@ -299,7 +299,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
|||||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
||||||
Assert.NotNull(library);
|
Assert.NotNull(library);
|
||||||
var scanResults = psf.ProcessFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
|
var scanResults = await psf.ProcessFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
|
||||||
|
|
||||||
Assert.Equal(2, scanResults.Count);
|
Assert.Equal(2, scanResults.Count);
|
||||||
}
|
}
|
||||||
@ -328,7 +328,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
|||||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
||||||
Assert.NotNull(library);
|
Assert.NotNull(library);
|
||||||
var scanResults = psf.ProcessFiles("C:/Data", false,
|
var scanResults = await psf.ProcessFiles("C:/Data", false,
|
||||||
await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
|
await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
|
||||||
|
|
||||||
Assert.Single(scanResults);
|
Assert.Single(scanResults);
|
||||||
|
@ -53,9 +53,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="32.0.1" />
|
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
||||||
<PackageReference Include="MailKit" Version="4.5.0" />
|
<PackageReference Include="MailKit" Version="4.6.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
@ -66,17 +66,17 @@
|
|||||||
<PackageReference Include="Flurl" Version="3.0.7" />
|
<PackageReference Include="Flurl" Version="3.0.7" />
|
||||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||||
<PackageReference Include="Hangfire" Version="1.8.12" />
|
<PackageReference Include="Hangfire" Version="1.8.12" />
|
||||||
<PackageReference Include="Hangfire.InMemory" Version="0.9.0" />
|
<PackageReference Include="Hangfire.InMemory" Version="0.10.0" />
|
||||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||||
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
|
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
|
||||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.12" />
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.12" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.6" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
|
||||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||||
@ -84,7 +84,7 @@
|
|||||||
<PackageReference Include="NetVips" Version="2.4.1" />
|
<PackageReference Include="NetVips" Version="2.4.1" />
|
||||||
<PackageReference Include="NetVips.Native" Version="8.15.2" />
|
<PackageReference Include="NetVips.Native" Version="8.15.2" />
|
||||||
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
|
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
|
||||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
<PackageReference Include="Serilog" Version="4.0.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
|
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
|
||||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
||||||
@ -95,16 +95,16 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.37.2" />
|
<PackageReference Include="SharpCompress" Version="0.37.2" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.25.0.90414">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.26.0.92422">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.2" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="21.0.2" />
|
<PackageReference Include="System.IO.Abstractions" Version="21.0.2" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="8.0.4" />
|
<PackageReference Include="System.Drawing.Common" Version="8.0.6" />
|
||||||
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
@ -853,27 +853,35 @@ public class OpdsController : BaseApiController
|
|||||||
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||||
foreach (var volume in seriesDetail.Volumes)
|
foreach (var volume in seriesDetail.Volumes)
|
||||||
{
|
{
|
||||||
var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files);
|
var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files | ChapterIncludes.People);
|
||||||
|
|
||||||
foreach (var chapter in chapters)
|
foreach (var chapter in chaptersForVolume)
|
||||||
{
|
{
|
||||||
var chapterId = chapter.Id;
|
var chapterId = chapter.Id;
|
||||||
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
||||||
foreach (var mangaFile in chapter.Files)
|
foreach (var mangaFile in chapter.Files)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, mangaFile, series, chapterDto, apiKey, prefix, baseUrl));
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series,
|
||||||
|
chapterDto, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
|
var chapters = seriesDetail.StorylineChapters;
|
||||||
|
if (!seriesDetail.StorylineChapters.Any() && seriesDetail.Chapters.Any())
|
||||||
{
|
{
|
||||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id);
|
chapters = seriesDetail.Chapters;
|
||||||
var chapterDto = _mapper.Map<ChapterDto>(storylineChapter);
|
}
|
||||||
|
|
||||||
|
foreach (var chapter in chapters.Where(c => !c.IsSpecial))
|
||||||
|
{
|
||||||
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
|
||||||
|
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
||||||
foreach (var mangaFile in files)
|
foreach (var mangaFile in files)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterDto, apiKey, prefix, baseUrl));
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, chapter.VolumeId, chapter.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
|
||||||
|
chapterDto, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -883,7 +891,8 @@ public class OpdsController : BaseApiController
|
|||||||
var chapterDto = _mapper.Map<ChapterDto>(special);
|
var chapterDto = _mapper.Map<ChapterDto>(special);
|
||||||
foreach (var mangaFile in files)
|
foreach (var mangaFile in files)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterDto, apiKey, prefix, baseUrl));
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
|
||||||
|
chapterDto, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -909,9 +918,9 @@ public class OpdsController : BaseApiController
|
|||||||
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
|
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
|
||||||
foreach (var chapter in chapters)
|
foreach (var chapter in chapters)
|
||||||
{
|
{
|
||||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
|
//var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
|
||||||
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id);
|
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id, ChapterIncludes.Files | ChapterIncludes.People);
|
||||||
foreach (var mangaFile in files)
|
foreach (var mangaFile in chapterDto.Files)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapter.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapter.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
@ -928,17 +937,20 @@ public class OpdsController : BaseApiController
|
|||||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||||
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
||||||
var (baseUrl, prefix) = await GetPrefix();
|
var (baseUrl, prefix) = await GetPrefix();
|
||||||
|
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
|
||||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
|
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People);
|
||||||
|
|
||||||
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
|
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
|
||||||
|
|
||||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
|
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
|
||||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
|
||||||
|
|
||||||
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s",
|
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s",
|
||||||
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix);
|
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix);
|
||||||
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files");
|
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files");
|
||||||
foreach (var mangaFile in files)
|
|
||||||
|
foreach (var mangaFile in chapter.Files)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl));
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
@ -1028,22 +1040,30 @@ public class OpdsController : BaseApiController
|
|||||||
Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary)
|
Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary)
|
||||||
? string.Empty
|
? string.Empty
|
||||||
: $" Summary: {metadata.Summary}"),
|
: $" Summary: {metadata.Summary}"),
|
||||||
Authors = metadata.Writers.Select(p => new FeedAuthor()
|
Authors = metadata.Writers.Select(CreateAuthor).ToList(),
|
||||||
{
|
|
||||||
Name = p.Name,
|
|
||||||
Uri = "http://opds-spec.org/author/" + p.Id
|
|
||||||
}).ToList(),
|
|
||||||
Categories = metadata.Genres.Select(g => new FeedCategory()
|
Categories = metadata.Genres.Select(g => new FeedCategory()
|
||||||
{
|
{
|
||||||
Label = g.Title,
|
Label = g.Title,
|
||||||
Term = string.Empty
|
Term = string.Empty
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
Links = new List<FeedLink>()
|
Links =
|
||||||
{
|
[
|
||||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{seriesDto.Id}"),
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
||||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"),
|
$"{prefix}{apiKey}/series/{seriesDto.Id}"),
|
||||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}")
|
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
||||||
}
|
$"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"),
|
||||||
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
||||||
|
$"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}")
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FeedAuthor CreateAuthor(PersonDto person)
|
||||||
|
{
|
||||||
|
return new FeedAuthor()
|
||||||
|
{
|
||||||
|
Name = person.Name,
|
||||||
|
Uri = "http://opds-spec.org/author/" + person.Id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1070,6 +1090,7 @@ public class OpdsController : BaseApiController
|
|||||||
Id = chapterId.ToString(),
|
Id = chapterId.ToString(),
|
||||||
Title = title,
|
Title = title,
|
||||||
Summary = summary ?? string.Empty,
|
Summary = summary ?? string.Empty,
|
||||||
|
|
||||||
Links = new List<FeedLink>()
|
Links = new List<FeedLink>()
|
||||||
{
|
{
|
||||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
||||||
@ -1082,7 +1103,7 @@ public class OpdsController : BaseApiController
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
|
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
|
||||||
{
|
{
|
||||||
var fileSize =
|
var fileSize =
|
||||||
mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) :
|
mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) :
|
||||||
@ -1143,7 +1164,8 @@ public class OpdsController : BaseApiController
|
|||||||
{
|
{
|
||||||
Text = fileType,
|
Text = fileType,
|
||||||
Type = "text"
|
Type = "text"
|
||||||
}
|
},
|
||||||
|
Authors = chapter.Writers.Select(CreateAuthor).ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
var canPageStream = mangaFile.Extension != ".epub";
|
var canPageStream = mangaFile.Extension != ".epub";
|
||||||
@ -1241,7 +1263,7 @@ public class OpdsController : BaseApiController
|
|||||||
throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
|
throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<FeedLink> CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey, string prefix)
|
private async Task<FeedLink> CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, string apiKey, string prefix)
|
||||||
{
|
{
|
||||||
var userId = await GetUser(apiKey);
|
var userId = await GetUser(apiKey);
|
||||||
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId);
|
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId);
|
||||||
|
@ -263,9 +263,9 @@ public class ReaderController : BaseApiController
|
|||||||
info.Title += " - " + info.ChapterTitle;
|
info.Title += " - " + info.ChapterTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.IsSpecial && dto.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume))
|
if (info.IsSpecial)
|
||||||
{
|
{
|
||||||
info.Subtitle = info.FileName;
|
info.Subtitle = Path.GetFileNameWithoutExtension(info.FileName);
|
||||||
} else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume))
|
} else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume))
|
||||||
{
|
{
|
||||||
info.Subtitle = ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber;
|
info.Subtitle = ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber;
|
||||||
|
@ -6,10 +6,23 @@ namespace API.DTOs;
|
|||||||
public class MangaFileDto
|
public class MangaFileDto
|
||||||
{
|
{
|
||||||
public int Id { get; init; }
|
public int Id { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute path to the archive file (normalized)
|
||||||
|
/// </summary>
|
||||||
public string FilePath { get; init; } = default!;
|
public string FilePath { get; init; } = default!;
|
||||||
|
/// <summary>
|
||||||
|
/// Number of pages for the given file
|
||||||
|
/// </summary>
|
||||||
public int Pages { get; init; }
|
public int Pages { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// How many bytes make up this file
|
||||||
|
/// </summary>
|
||||||
public long Bytes { get; init; }
|
public long Bytes { get; init; }
|
||||||
public MangaFormat Format { get; init; }
|
public MangaFormat Format { get; init; }
|
||||||
public DateTime Created { get; init; }
|
public DateTime Created { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// File extension
|
||||||
|
/// </summary>
|
||||||
|
public string? Extension { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using API.Entities.Enums;
|
using System;
|
||||||
|
using API.Entities.Enums;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
|
||||||
namespace API.DTOs.Settings;
|
namespace API.DTOs.Settings;
|
||||||
@ -88,6 +89,14 @@ public class ServerSettingDto
|
|||||||
/// SMTP Configuration
|
/// SMTP Configuration
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SmtpConfigDto SmtpConfig { get; set; }
|
public SmtpConfigDto SmtpConfig { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The Date Kavita was first installed
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? FirstInstallDate { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The Version of Kavita on the first run
|
||||||
|
/// </summary>
|
||||||
|
public string? FirstInstallVersion { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Are at least some basics filled in
|
/// Are at least some basics filled in
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
namespace API.DTOs.Stats;
|
using System;
|
||||||
|
|
||||||
|
namespace API.DTOs.Stats;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This is just for the Server tab on UI
|
/// This is just for the Server tab on UI
|
||||||
@ -17,5 +19,13 @@ public class ServerInfoSlimDto
|
|||||||
/// Version of Kavita
|
/// Version of Kavita
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string KavitaVersion { get; set; }
|
public required string KavitaVersion { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The Date Kavita was first installed
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? FirstInstallDate { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The Version of Kavita on the first run
|
||||||
|
/// </summary>
|
||||||
|
public string? FirstInstallVersion { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
53
API/Data/ManualMigrations/MigrateInitialInstallData.cs
Normal file
53
API/Data/ManualMigrations/MigrateInitialInstallData.cs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
using API.Services;
|
||||||
|
using Kavita.Common.EnvironmentInfo;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace API.Data.ManualMigrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v0.8.2 I started collecting information on when the user first installed Kavita as a nice to have info for the user
|
||||||
|
/// </summary>
|
||||||
|
public static class MigrateInitialInstallData
|
||||||
|
{
|
||||||
|
public static async Task Migrate(DataContext dataContext, ILogger<Program> logger, IDirectoryService directoryService)
|
||||||
|
{
|
||||||
|
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateInitialInstallData"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogCritical(
|
||||||
|
"Running MigrateInitialInstallData migration - Please be patient, this may take some time. This is not an error");
|
||||||
|
|
||||||
|
var settings = await dataContext.ServerSetting.ToListAsync();
|
||||||
|
|
||||||
|
// Get the Install Date as Date the DB was written
|
||||||
|
var dbFile = Path.Join(directoryService.ConfigDirectory, "kavita.db");
|
||||||
|
if (!string.IsNullOrEmpty(dbFile) && directoryService.FileSystem.File.Exists(dbFile))
|
||||||
|
{
|
||||||
|
var fi = directoryService.FileSystem.FileInfo.New(dbFile);
|
||||||
|
var setting = settings.First(s => s.Key == ServerSettingKey.FirstInstallDate);
|
||||||
|
setting.Value = fi.CreationTimeUtc.ToString();
|
||||||
|
await dataContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||||
|
{
|
||||||
|
Name = "MigrateInitialInstallData",
|
||||||
|
ProductVersion = BuildInfo.Version.ToString(),
|
||||||
|
RanAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await dataContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
logger.LogCritical(
|
||||||
|
"Running MigrateInitialInstallData migration - Completed. This is not an error");
|
||||||
|
}
|
||||||
|
}
|
@ -22,6 +22,7 @@ public enum ChapterIncludes
|
|||||||
None = 1,
|
None = 1,
|
||||||
Volumes = 2,
|
Volumes = 2,
|
||||||
Files = 4,
|
Files = 4,
|
||||||
|
People = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IChapterRepository
|
public interface IChapterRepository
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@ -137,6 +138,7 @@ public interface ISeriesRepository
|
|||||||
Task<IList<Series>> GetWantToReadForUserAsync(int userId);
|
Task<IList<Series>> GetWantToReadForUserAsync(int userId);
|
||||||
Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
|
Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
|
||||||
Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
|
Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
|
||||||
|
Task<Series?> GetSeriesThatContainsLowestFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
|
||||||
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
|
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
|
||||||
int userId, SeriesIncludes includes = SeriesIncludes.None);
|
int userId, SeriesIncludes includes = SeriesIncludes.None);
|
||||||
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
|
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
|
||||||
@ -1589,14 +1591,29 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Return a Series by Folder path. Null if not found.
|
/// Return a Series by Folder path. Null if not found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="folder">This will be normalized in the query</param>
|
/// <param name="folder">This will be normalized in the query and checked against FolderPath and LowestFolderPath</param>
|
||||||
/// <param name="includes">Additional relationships to include with the base query</param>
|
/// <param name="includes">Additional relationships to include with the base query</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None)
|
public async Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None)
|
||||||
{
|
{
|
||||||
var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder);
|
var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder);
|
||||||
|
if (string.IsNullOrEmpty(normalized)) return null;
|
||||||
|
|
||||||
return await _context.Series
|
return await _context.Series
|
||||||
.Where(s => s.FolderPath != null && s.FolderPath.Equals(normalized))
|
.Where(s => (!string.IsNullOrEmpty(s.FolderPath) && s.FolderPath.Equals(normalized) || (!string.IsNullOrEmpty(s.LowestFolderPath) && s.LowestFolderPath.Equals(normalized))))
|
||||||
|
.Includes(includes)
|
||||||
|
.SingleOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Series?> GetSeriesThatContainsLowestFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None)
|
||||||
|
{
|
||||||
|
var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder);
|
||||||
|
if (string.IsNullOrEmpty(normalized)) return null;
|
||||||
|
|
||||||
|
normalized = normalized.TrimEnd('/');
|
||||||
|
|
||||||
|
return await _context.Series
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s.LowestFolderPath) && EF.Functions.Like(normalized, s.LowestFolderPath + "%"))
|
||||||
.Includes(includes)
|
.Includes(includes)
|
||||||
.SingleOrDefaultAsync();
|
.SingleOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@ -251,6 +252,9 @@ public static class Seed
|
|||||||
new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"},
|
new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"},
|
||||||
new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty},
|
new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty},
|
||||||
new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"},
|
new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"},
|
||||||
|
new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()},
|
||||||
|
new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString()},
|
||||||
|
|
||||||
}.ToArray());
|
}.ToArray());
|
||||||
|
|
||||||
foreach (var defaultSetting in DefaultSettings)
|
foreach (var defaultSetting in DefaultSettings)
|
||||||
|
@ -186,5 +186,15 @@ public enum ServerSettingKey
|
|||||||
/// When the cleanup task should run - Critical to keeping Kavita working
|
/// When the cleanup task should run - Critical to keeping Kavita working
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Description("TaskCleanup")]
|
[Description("TaskCleanup")]
|
||||||
TaskCleanup = 37
|
TaskCleanup = 37,
|
||||||
|
/// <summary>
|
||||||
|
/// The Date Kavita was first installed
|
||||||
|
/// </summary>
|
||||||
|
[Description("FirstInstallDate")]
|
||||||
|
FirstInstallDate = 38,
|
||||||
|
/// <summary>
|
||||||
|
/// The Version of Kavita on the first run
|
||||||
|
/// </summary>
|
||||||
|
[Description("FirstInstallVersion")]
|
||||||
|
FirstInstallVersion = 39,
|
||||||
}
|
}
|
||||||
|
@ -53,6 +53,12 @@ public static class IncludesExtensions
|
|||||||
.Include(c => c.Files);
|
.Include(c => c.Files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includes.HasFlag(ChapterIncludes.People))
|
||||||
|
{
|
||||||
|
queryable = queryable
|
||||||
|
.Include(c => c.People);
|
||||||
|
}
|
||||||
|
|
||||||
return queryable.AsSplitQuery();
|
return queryable.AsSplitQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -122,6 +122,12 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
|
|||||||
destination.SmtpConfig ??= new SmtpConfigDto();
|
destination.SmtpConfig ??= new SmtpConfigDto();
|
||||||
destination.SmtpConfig.CustomizedTemplates = bool.Parse(row.Value);
|
destination.SmtpConfig.CustomizedTemplates = bool.Parse(row.Value);
|
||||||
break;
|
break;
|
||||||
|
case ServerSettingKey.FirstInstallDate:
|
||||||
|
destination.FirstInstallDate = DateTime.Parse(row.Value);
|
||||||
|
break;
|
||||||
|
case ServerSettingKey.FirstInstallVersion:
|
||||||
|
destination.FirstInstallVersion = row.Value;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,6 +35,12 @@ public class DownloadService : IDownloadService
|
|||||||
// Figures out what the content type should be based on the file name.
|
// Figures out what the content type should be based on the file name.
|
||||||
if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType))
|
if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType))
|
||||||
{
|
{
|
||||||
|
if (contentType == null)
|
||||||
|
{
|
||||||
|
// Get extension
|
||||||
|
contentType = Path.GetExtension(filepath);
|
||||||
|
}
|
||||||
|
|
||||||
contentType = Path.GetExtension(filepath).ToLowerInvariant() switch
|
contentType = Path.GetExtension(filepath).ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
".cbz" => "application/x-cbz",
|
".cbz" => "application/x-cbz",
|
||||||
|
@ -169,6 +169,9 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService
|
|||||||
s.NormalizedLocalizedName == normalizedSeriesName)
|
s.NormalizedLocalizedName == normalizedSeriesName)
|
||||||
&& formats.Contains(s.Format));
|
&& formats.Contains(s.Format));
|
||||||
|
|
||||||
|
_logger.LogDebug("Trying to find {SeriesName} with formats ({Formats}) within Kavita for linking. Found: {ExistingSeriesName} ({ExistingSeriesId})",
|
||||||
|
seriesInfo.SeriesName, formats, existingSeries?.Name, existingSeries?.Id);
|
||||||
|
|
||||||
if (existingSeries != null)
|
if (existingSeries != null)
|
||||||
{
|
{
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
|
@ -57,7 +57,10 @@ public class StatisticService : IStatisticService
|
|||||||
public async Task<UserReadStatistics> GetUserReadStatistics(int userId, IList<int> libraryIds)
|
public async Task<UserReadStatistics> GetUserReadStatistics(int userId, IList<int> libraryIds)
|
||||||
{
|
{
|
||||||
if (libraryIds.Count == 0)
|
if (libraryIds.Count == 0)
|
||||||
|
{
|
||||||
libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Total Pages Read
|
// Total Pages Read
|
||||||
var totalPagesRead = await _context.AppUserProgresses
|
var totalPagesRead = await _context.AppUserProgresses
|
||||||
|
@ -20,7 +20,7 @@ public interface ITaskScheduler
|
|||||||
Task ScheduleStatsTasks();
|
Task ScheduleStatsTasks();
|
||||||
void ScheduleUpdaterTasks();
|
void ScheduleUpdaterTasks();
|
||||||
Task ScheduleKavitaPlusTasks();
|
Task ScheduleKavitaPlusTasks();
|
||||||
void ScanFolder(string folderPath, TimeSpan delay);
|
void ScanFolder(string folderPath, string originalPath, TimeSpan delay);
|
||||||
void ScanFolder(string folderPath);
|
void ScanFolder(string folderPath);
|
||||||
void ScanLibrary(int libraryId, bool force = false);
|
void ScanLibrary(int libraryId, bool force = false);
|
||||||
void ScanLibraries(bool force = false);
|
void ScanLibraries(bool force = false);
|
||||||
@ -267,24 +267,38 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
BackgroundJob.Enqueue(() => CheckForUpdate());
|
BackgroundJob.Enqueue(() => CheckForUpdate());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ScanFolder(string folderPath, TimeSpan delay)
|
/// <summary>
|
||||||
|
/// Queue up a Scan folder for a folder from Library Watcher.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="folderPath"></param>
|
||||||
|
/// <param name="originalPath"></param>
|
||||||
|
/// <param name="delay"></param>
|
||||||
|
public void ScanFolder(string folderPath, string originalPath, TimeSpan delay)
|
||||||
{
|
{
|
||||||
var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath);
|
var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath);
|
||||||
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder]))
|
var normalizedOriginal = Tasks.Scanner.Parser.Parser.NormalizePath(originalPath);
|
||||||
|
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, normalizedOriginal]) ||
|
||||||
|
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty]))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued",
|
_logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued",
|
||||||
normalizedFolder);
|
normalizedFolder);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not sure where we should put this code, but we can get a bunch of ScanFolders when original has slight variations, like
|
||||||
|
// create a folder, add a new file, etc. All of these can be merged into just 1 request.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder);
|
_logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder);
|
||||||
BackgroundJob.Schedule(() => _scannerService.ScanFolder(normalizedFolder), delay);
|
BackgroundJob.Schedule(() => _scannerService.ScanFolder(normalizedFolder, normalizedOriginal), delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ScanFolder(string folderPath)
|
public void ScanFolder(string folderPath)
|
||||||
{
|
{
|
||||||
var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath);
|
var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath);
|
||||||
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", new object[] {normalizedFolder}))
|
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty]))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued",
|
_logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued",
|
||||||
normalizedFolder);
|
normalizedFolder);
|
||||||
@ -292,7 +306,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder);
|
_logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder);
|
||||||
_scannerService.ScanFolder(normalizedFolder);
|
_scannerService.ScanFolder(normalizedFolder, string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@ -350,9 +364,9 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
public void RefreshMetadata(int libraryId, bool forceUpdate = true)
|
public void RefreshMetadata(int libraryId, bool forceUpdate = true)
|
||||||
{
|
{
|
||||||
var alreadyEnqueued = HasAlreadyEnqueuedTask(MetadataService.Name, "GenerateCoversForLibrary",
|
var alreadyEnqueued = HasAlreadyEnqueuedTask(MetadataService.Name, "GenerateCoversForLibrary",
|
||||||
new object[] {libraryId, true}) ||
|
[libraryId, true]) ||
|
||||||
HasAlreadyEnqueuedTask("MetadataService", "GenerateCoversForLibrary",
|
HasAlreadyEnqueuedTask("MetadataService", "GenerateCoversForLibrary",
|
||||||
new object[] {libraryId, false});
|
[libraryId, false]);
|
||||||
if (alreadyEnqueued)
|
if (alreadyEnqueued)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping");
|
_logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping");
|
||||||
@ -365,7 +379,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
|
|
||||||
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false)
|
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false)
|
||||||
{
|
{
|
||||||
if (HasAlreadyEnqueuedTask(MetadataService.Name,"GenerateCoversForSeries", new object[] {libraryId, seriesId, forceUpdate}))
|
if (HasAlreadyEnqueuedTask(MetadataService.Name,"GenerateCoversForSeries", [libraryId, seriesId, forceUpdate]))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping");
|
_logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping");
|
||||||
return;
|
return;
|
||||||
@ -377,7 +391,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
|
|
||||||
public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
|
public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
|
||||||
{
|
{
|
||||||
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", new object[] {seriesId, forceUpdate}, ScanQueue))
|
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, forceUpdate], ScanQueue))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("A duplicate request to scan series occured. Skipping");
|
_logger.LogInformation("A duplicate request to scan series occured. Skipping");
|
||||||
return;
|
return;
|
||||||
@ -396,7 +410,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
|
|
||||||
public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false)
|
public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false)
|
||||||
{
|
{
|
||||||
if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", new object[] {libraryId, seriesId, forceUpdate}))
|
if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", [libraryId, seriesId, forceUpdate]))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("A duplicate request to scan series occured. Skipping");
|
_logger.LogInformation("A duplicate request to scan series occured. Skipping");
|
||||||
return;
|
return;
|
||||||
@ -426,13 +440,13 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
public static bool HasScanTaskRunningForLibrary(int libraryId, bool checkRunningJobs = true)
|
public static bool HasScanTaskRunningForLibrary(int libraryId, bool checkRunningJobs = true)
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, true, true}, ScanQueue,
|
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, true, true], ScanQueue,
|
||||||
checkRunningJobs) ||
|
checkRunningJobs) ||
|
||||||
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, false, true}, ScanQueue,
|
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, false, true], ScanQueue,
|
||||||
checkRunningJobs) ||
|
checkRunningJobs) ||
|
||||||
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, true, false}, ScanQueue,
|
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, true, false], ScanQueue,
|
||||||
checkRunningJobs) ||
|
checkRunningJobs) ||
|
||||||
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, false, false}, ScanQueue,
|
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, false, false], ScanQueue,
|
||||||
checkRunningJobs);
|
checkRunningJobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,8 +459,8 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
public static bool HasScanTaskRunningForSeries(int seriesId, bool checkRunningJobs = true)
|
public static bool HasScanTaskRunningForSeries(int seriesId, bool checkRunningJobs = true)
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", new object[] {seriesId, true}, ScanQueue, checkRunningJobs) ||
|
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, true], ScanQueue, checkRunningJobs) ||
|
||||||
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", new object[] {seriesId, false}, ScanQueue, checkRunningJobs);
|
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, false], ScanQueue, checkRunningJobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -488,6 +502,7 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks against any jobs that are running or about to run
|
/// Checks against any jobs that are running or about to run
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -56,9 +56,9 @@ public class LibraryWatcher : ILibraryWatcher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Counts within a time frame how many times the buffer became full. Is used to reschedule LibraryWatcher to start monitoring much later rather than instantly
|
/// Counts within a time frame how many times the buffer became full. Is used to reschedule LibraryWatcher to start monitoring much later rather than instantly
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private int _bufferFullCounter;
|
private static int _bufferFullCounter;
|
||||||
private int _restartCounter;
|
private static int _restartCounter;
|
||||||
private DateTime _lastErrorTime = DateTime.MinValue;
|
private static DateTime _lastErrorTime = DateTime.MinValue;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Used to lock buffer Full Counter
|
/// Used to lock buffer Full Counter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -262,17 +262,19 @@ public class LibraryWatcher : ILibraryWatcher
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_taskScheduler.ScanFolder(fullPath, _queueWaitTime);
|
_taskScheduler.ScanFolder(fullPath, filePath, _queueWaitTime);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "[LibraryWatcher] An error occured when processing a watch event");
|
_logger.LogError(ex, "[LibraryWatcher] An error occured when processing a watch event");
|
||||||
}
|
}
|
||||||
_logger.LogDebug("[LibraryWatcher] ProcessChange completed in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
|
_logger.LogTrace("[LibraryWatcher] ProcessChange completed in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetFolder(string filePath, IEnumerable<string> libraryFolders)
|
private string GetFolder(string filePath, IEnumerable<string> libraryFolders)
|
||||||
{
|
{
|
||||||
|
// TODO: I can optimize this to avoid a library scan and instead do a Series Scan by finding the series that has a lowestFolderPath higher or equal to the filePath
|
||||||
|
|
||||||
var parentDirectory = _directoryService.GetParentDirectoryName(filePath);
|
var parentDirectory = _directoryService.GetParentDirectoryName(filePath);
|
||||||
_logger.LogTrace("[LibraryWatcher] Parent Directory: {ParentDirectory}", parentDirectory);
|
_logger.LogTrace("[LibraryWatcher] Parent Directory: {ParentDirectory}", parentDirectory);
|
||||||
if (string.IsNullOrEmpty(parentDirectory)) return string.Empty;
|
if (string.IsNullOrEmpty(parentDirectory)) return string.Empty;
|
||||||
|
@ -114,7 +114,6 @@ public class ParseScannedFiles
|
|||||||
_eventHub = eventHub;
|
_eventHub = eventHub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This will Scan all files in a folder path. For each folder within the folderPath, FolderAction will be invoked for all files contained
|
/// This will Scan all files in a folder path. For each folder within the folderPath, FolderAction will be invoked for all files contained
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -122,48 +121,53 @@ public class ParseScannedFiles
|
|||||||
/// <param name="seriesPaths">A dictionary mapping a normalized path to a list of <see cref="SeriesModified"/> to help scanner skip I/O</param>
|
/// <param name="seriesPaths">A dictionary mapping a normalized path to a list of <see cref="SeriesModified"/> to help scanner skip I/O</param>
|
||||||
/// <param name="folderPath">A library folder or series folder</param>
|
/// <param name="folderPath">A library folder or series folder</param>
|
||||||
/// <param name="forceCheck">If we should bypass any folder last write time checks on the scan and force I/O</param>
|
/// <param name="forceCheck">If we should bypass any folder last write time checks on the scan and force I/O</param>
|
||||||
public IList<ScanResult> ProcessFiles(string folderPath, bool scanDirectoryByDirectory,
|
public async Task<IList<ScanResult>> ProcessFiles(string folderPath, bool scanDirectoryByDirectory,
|
||||||
IDictionary<string, IList<SeriesModified>> seriesPaths, Library library, bool forceCheck = false)
|
IDictionary<string, IList<SeriesModified>> seriesPaths, Library library, bool forceCheck = false)
|
||||||
{
|
{
|
||||||
string normalizedPath;
|
|
||||||
var result = new List<ScanResult>();
|
|
||||||
var fileExtensions = string.Join("|", library.LibraryFileTypes.Select(l => l.FileTypeGroup.GetRegex()));
|
var fileExtensions = string.Join("|", library.LibraryFileTypes.Select(l => l.FileTypeGroup.GetRegex()));
|
||||||
|
var matcher = BuildMatcher(library);
|
||||||
|
|
||||||
|
var result = new List<ScanResult>();
|
||||||
|
|
||||||
if (scanDirectoryByDirectory)
|
if (scanDirectoryByDirectory)
|
||||||
{
|
{
|
||||||
// This is used in library scan, so we should check first for a ignore file and use that here as well
|
var directories = _directoryService.GetDirectories(folderPath, matcher).Select(Parser.Parser.NormalizePath);
|
||||||
var matcher = new GlobMatcher();
|
|
||||||
foreach (var pattern in library.LibraryExcludePatterns.Where(p => !string.IsNullOrEmpty(p.Pattern)))
|
|
||||||
{
|
|
||||||
matcher.AddExclude(pattern.Pattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
var directories = _directoryService.GetDirectories(folderPath, matcher).ToList();
|
|
||||||
foreach (var directory in directories)
|
foreach (var directory in directories)
|
||||||
{
|
{
|
||||||
// Since this is a loop, we need a list return
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
normalizedPath = Parser.Parser.NormalizePath(directory);
|
MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated));
|
||||||
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck))
|
|
||||||
|
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, directory, forceCheck))
|
||||||
{
|
{
|
||||||
result.Add(new ScanResult()
|
if (result.Exists(r => r.Folder == directory))
|
||||||
{
|
{
|
||||||
Files = ArraySegment<string>.Empty,
|
continue;
|
||||||
Folder = directory,
|
}
|
||||||
LibraryRoot = folderPath,
|
result.Add(CreateScanResult(directory, folderPath, false, ArraySegment<string>.Empty));
|
||||||
HasChanged = false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
else if (seriesPaths.TryGetValue(normalizedPath, out var series) && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath)))
|
else if (seriesPaths.TryGetValue(directory, out var series) && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath)))
|
||||||
{
|
{
|
||||||
// If there are multiple series inside this path, let's check each of them to see which was modified and only scan those
|
// If there are multiple series inside this path, let's check each of them to see which was modified and only scan those
|
||||||
// This is very helpful for ComicVine libraries by Publisher
|
// This is very helpful for ComicVine libraries by Publisher
|
||||||
|
_logger.LogDebug("[ProcessFiles] {Directory} is dirty and has multiple series folders, checking if we can avoid a full scan", directory);
|
||||||
foreach (var seriesModified in series)
|
foreach (var seriesModified in series)
|
||||||
{
|
{
|
||||||
if (HasSeriesFolderNotChangedSinceLastScan(seriesModified, seriesModified.LowestFolderPath!))
|
var hasFolderChangedSinceLastScan = library.LastScanned.Truncate(TimeSpan.TicksPerSecond) <
|
||||||
|
_directoryService
|
||||||
|
.GetLastWriteTime(seriesModified.LowestFolderPath!)
|
||||||
|
.Truncate(TimeSpan.TicksPerSecond);
|
||||||
|
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
|
MessageFactory.FileScanProgressEvent(seriesModified.LowestFolderPath!, library.Name, ProgressEventType.Updated));
|
||||||
|
|
||||||
|
if (!hasFolderChangedSinceLastScan)
|
||||||
{
|
{
|
||||||
result.Add(CreateScanResult(directory, folderPath, false, ArraySegment<string>.Empty));
|
_logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} did not change since last scan, adding entry to skip", directory, seriesModified.LowestFolderPath);
|
||||||
|
result.Add(CreateScanResult(seriesModified.LowestFolderPath!, folderPath, false, ArraySegment<string>.Empty));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} changed, adding folders", directory, seriesModified.LowestFolderPath);
|
||||||
result.Add(CreateScanResult(directory, folderPath, true,
|
result.Add(CreateScanResult(directory, folderPath, true,
|
||||||
_directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher)));
|
_directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher)));
|
||||||
}
|
}
|
||||||
@ -173,19 +177,22 @@ public class ParseScannedFiles
|
|||||||
{
|
{
|
||||||
// For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication
|
// For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication
|
||||||
result.Add(CreateScanResult(directory, folderPath, true,
|
result.Add(CreateScanResult(directory, folderPath, true,
|
||||||
_directoryService.ScanFiles(directory, fileExtensions)));
|
_directoryService.ScanFiles(directory, fileExtensions, matcher)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizedPath = Parser.Parser.NormalizePath(folderPath);
|
var normalizedPath = Parser.Parser.NormalizePath(folderPath);
|
||||||
var libraryRoot =
|
var libraryRoot =
|
||||||
library.Folders.FirstOrDefault(f =>
|
library.Folders.FirstOrDefault(f =>
|
||||||
Parser.Parser.NormalizePath(folderPath).Contains(Parser.Parser.NormalizePath(f.Path)))?.Path ??
|
normalizedPath.Contains(Parser.Parser.NormalizePath(f.Path)))?.Path ??
|
||||||
folderPath;
|
folderPath;
|
||||||
|
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
|
MessageFactory.FileScanProgressEvent(normalizedPath, library.Name, ProgressEventType.Updated));
|
||||||
|
|
||||||
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck))
|
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck))
|
||||||
{
|
{
|
||||||
result.Add(CreateScanResult(folderPath, libraryRoot, false, ArraySegment<string>.Empty));
|
result.Add(CreateScanResult(folderPath, libraryRoot, false, ArraySegment<string>.Empty));
|
||||||
@ -193,13 +200,24 @@ public class ParseScannedFiles
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
result.Add(CreateScanResult(folderPath, libraryRoot, true,
|
result.Add(CreateScanResult(folderPath, libraryRoot, true,
|
||||||
_directoryService.ScanFiles(folderPath, fileExtensions)));
|
_directoryService.ScanFiles(folderPath, fileExtensions, matcher)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static GlobMatcher BuildMatcher(Library library)
|
||||||
|
{
|
||||||
|
var matcher = new GlobMatcher();
|
||||||
|
foreach (var pattern in library.LibraryExcludePatterns.Where(p => !string.IsNullOrEmpty(p.Pattern)))
|
||||||
|
{
|
||||||
|
matcher.AddExclude(pattern.Pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matcher;
|
||||||
|
}
|
||||||
|
|
||||||
private static ScanResult CreateScanResult(string folderPath, string libraryRoot, bool hasChanged,
|
private static ScanResult CreateScanResult(string folderPath, string libraryRoot, bool hasChanged,
|
||||||
IList<string> files)
|
IList<string> files)
|
||||||
{
|
{
|
||||||
@ -243,7 +261,7 @@ public class ParseScannedFiles
|
|||||||
NormalizedName = normalizedSeries
|
NormalizedName = normalizedSeries
|
||||||
};
|
};
|
||||||
|
|
||||||
scannedSeries.AddOrUpdate(existingKey, new List<ParserInfo>() {info}, (_, oldValue) =>
|
scannedSeries.AddOrUpdate(existingKey, [info], (_, oldValue) =>
|
||||||
{
|
{
|
||||||
oldValue ??= new List<ParserInfo>();
|
oldValue ??= new List<ParserInfo>();
|
||||||
if (!oldValue.Contains(info))
|
if (!oldValue.Contains(info))
|
||||||
@ -338,7 +356,7 @@ public class ParseScannedFiles
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var scanResults = ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck);
|
var scanResults = await ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck);
|
||||||
|
|
||||||
foreach (var scanResult in scanResults)
|
foreach (var scanResult in scanResults)
|
||||||
{
|
{
|
||||||
@ -414,15 +432,19 @@ public class ParseScannedFiles
|
|||||||
/// <param name="library"></param>
|
/// <param name="library"></param>
|
||||||
private async Task ProcessScanResult(ScanResult result, IDictionary<string, IList<SeriesModified>> seriesPaths, Library library)
|
private async Task ProcessScanResult(ScanResult result, IDictionary<string, IList<SeriesModified>> seriesPaths, Library library)
|
||||||
{
|
{
|
||||||
|
// TODO: This should return the result as we are modifying it as a side effect
|
||||||
|
|
||||||
// If the folder hasn't changed, generate fake ParserInfos for the Series that were in that folder.
|
// If the folder hasn't changed, generate fake ParserInfos for the Series that were in that folder.
|
||||||
|
var normalizedFolder = Parser.Parser.NormalizePath(result.Folder);
|
||||||
if (!result.HasChanged)
|
if (!result.HasChanged)
|
||||||
{
|
{
|
||||||
var normalizedFolder = Parser.Parser.NormalizePath(result.Folder);
|
result.ParserInfos = seriesPaths[normalizedFolder]
|
||||||
result.ParserInfos = seriesPaths[normalizedFolder].Select(fp => new ParserInfo()
|
.Select(fp => new ParserInfo()
|
||||||
{
|
{
|
||||||
Series = fp.SeriesName,
|
Series = fp.SeriesName,
|
||||||
Format = fp.Format,
|
Format = fp.Format,
|
||||||
}).ToList();
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
_logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed since last scan", normalizedFolder);
|
_logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed since last scan", normalizedFolder);
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
@ -431,25 +453,24 @@ public class ParseScannedFiles
|
|||||||
}
|
}
|
||||||
|
|
||||||
var files = result.Files;
|
var files = result.Files;
|
||||||
var folder = result.Folder;
|
|
||||||
var libraryRoot = result.LibraryRoot;
|
|
||||||
|
|
||||||
// When processing files for a folder and we do enter, we need to parse the information and combine parser infos
|
// When processing files for a folder and we do enter, we need to parse the information and combine parser infos
|
||||||
// NOTE: We might want to move the merge step later in the process, like return and combine.
|
// NOTE: We might want to move the merge step later in the process, like return and combine.
|
||||||
_logger.LogDebug("[ScannerService] Found {Count} files for {Folder}", files.Count, folder);
|
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
|
||||||
MessageFactory.FileScanProgressEvent($"{files.Count} files in {folder}", library.Name, ProgressEventType.Updated));
|
|
||||||
|
|
||||||
if (files.Count == 0)
|
if (files.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[ScannerService] {Folder} is empty, no longer in this location, or has no file types that match Library File Types", folder);
|
_logger.LogInformation("[ScannerService] {Folder} is empty, no longer in this location, or has no file types that match Library File Types", normalizedFolder);
|
||||||
result.ParserInfos = ArraySegment<ParserInfo>.Empty;
|
result.ParserInfos = ArraySegment<ParserInfo>.Empty;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("[ScannerService] Found {Count} files for {Folder}", files.Count, normalizedFolder);
|
||||||
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
|
MessageFactory.FileScanProgressEvent($"{files.Count} files in {normalizedFolder}", library.Name, ProgressEventType.Updated));
|
||||||
|
|
||||||
// Multiple Series can exist within a folder. We should instead put these infos on the result and perform merging above
|
// Multiple Series can exist within a folder. We should instead put these infos on the result and perform merging above
|
||||||
IList<ParserInfo> infos = files
|
IList<ParserInfo> infos = files
|
||||||
.Select(file => _readingItemService.ParseFile(file, folder, libraryRoot, library.Type))
|
.Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type))
|
||||||
.Where(info => info != null)
|
.Where(info => info != null)
|
||||||
.ToList()!;
|
.ToList()!;
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
|||||||
{
|
{
|
||||||
Filename = Path.GetFileName(filePath),
|
Filename = Path.GetFileName(filePath),
|
||||||
Format = Parser.ParseFormat(filePath),
|
Format = Parser.ParseFormat(filePath),
|
||||||
Title = Parser.RemoveExtensionIfSupported(fileName),
|
Title = Parser.RemoveExtensionIfSupported(fileName)!,
|
||||||
FullFilePath = Parser.NormalizePath(filePath),
|
FullFilePath = Parser.NormalizePath(filePath),
|
||||||
Series = string.Empty,
|
Series = string.Empty,
|
||||||
ComicInfo = comicInfo
|
ComicInfo = comicInfo
|
||||||
@ -76,6 +76,9 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
|||||||
ret.Chapters = Parser.DefaultChapter;
|
ret.Chapters = Parser.DefaultChapter;
|
||||||
ret.Volumes = Parser.SpecialVolume;
|
ret.Volumes = Parser.SpecialVolume;
|
||||||
|
|
||||||
|
// NOTE: This uses rootPath. LibraryRoot works better for manga, but it's not always that way.
|
||||||
|
// It might be worth writing some logic if the file is a special, to take the folder above the Specials/
|
||||||
|
// if present
|
||||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,7 +101,7 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void UpdateFromComicInfo(ParserInfo info)
|
protected static void UpdateFromComicInfo(ParserInfo info)
|
||||||
{
|
{
|
||||||
if (info.ComicInfo == null) return;
|
if (info.ComicInfo == null) return;
|
||||||
|
|
||||||
@ -109,6 +109,10 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
|
|||||||
{
|
{
|
||||||
info.Volumes = info.ComicInfo.Volume;
|
info.Volumes = info.ComicInfo.Volume;
|
||||||
}
|
}
|
||||||
|
if (!string.IsNullOrEmpty(info.ComicInfo.Number))
|
||||||
|
{
|
||||||
|
info.Chapters = info.ComicInfo.Number;
|
||||||
|
}
|
||||||
if (!string.IsNullOrEmpty(info.ComicInfo.Series))
|
if (!string.IsNullOrEmpty(info.ComicInfo.Series))
|
||||||
{
|
{
|
||||||
info.Series = info.ComicInfo.Series.Trim();
|
info.Series = info.ComicInfo.Series.Trim();
|
||||||
@ -125,16 +129,6 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
|
|||||||
info.Volumes = Parser.SpecialVolume;
|
info.Volumes = Parser.SpecialVolume;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(info.ComicInfo.Number))
|
|
||||||
{
|
|
||||||
info.Chapters = info.ComicInfo.Number;
|
|
||||||
if (info.IsSpecial && Parser.DefaultChapter != info.Chapters)
|
|
||||||
{
|
|
||||||
info.IsSpecial = false;
|
|
||||||
info.Volumes = Parser.SpecialVolume;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch is SeriesSort from ComicInfo
|
// Patch is SeriesSort from ComicInfo
|
||||||
if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort))
|
if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort))
|
||||||
{
|
{
|
||||||
|
@ -103,7 +103,11 @@ public static class Parser
|
|||||||
private static readonly Regex CoverImageRegex = new Regex(@"(?<![[a-z]\d])(?:!?)(?<!back)(?<!back_)(?<!back-)(cover|folder)(?![\w\d])",
|
private static readonly Regex CoverImageRegex = new Regex(@"(?<![[a-z]\d])(?:!?)(?<!back)(?<!back_)(?<!back-)(cover|folder)(?![\w\d])",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
|
|
||||||
private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!\*]",
|
/// <summary>
|
||||||
|
/// Normalize everything within Kavita. Some characters don't fall under Unicode, like full-width characters and need to be
|
||||||
|
/// added on a case-by-case basis.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!*!+]",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -45,7 +45,7 @@ public interface IScannerService
|
|||||||
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||||
Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true);
|
Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true);
|
||||||
|
|
||||||
Task ScanFolder(string folder);
|
Task ScanFolder(string folder, string originalPath);
|
||||||
Task AnalyzeFiles();
|
Task AnalyzeFiles();
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -135,30 +135,35 @@ public class ScannerService : IScannerService
|
|||||||
/// Given a generic folder path, will invoke a Series scan or Library scan.
|
/// Given a generic folder path, will invoke a Series scan or Library scan.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>This will Schedule the job to run 1 minute in the future to allow for any close-by duplicate requests to be dropped</remarks>
|
/// <remarks>This will Schedule the job to run 1 minute in the future to allow for any close-by duplicate requests to be dropped</remarks>
|
||||||
/// <param name="folder"></param>
|
/// <param name="folder">Normalized folder</param>
|
||||||
public async Task ScanFolder(string folder)
|
/// <param name="originalPath">If invoked from LibraryWatcher, this maybe a nested folder and can allow for optimization</param>
|
||||||
|
public async Task ScanFolder(string folder, string originalPath)
|
||||||
{
|
{
|
||||||
Series? series = null;
|
Series? series = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
series = await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(folder, SeriesIncludes.Library);
|
series = await _unitOfWork.SeriesRepository.GetSeriesThatContainsLowestFolderPath(originalPath,
|
||||||
|
SeriesIncludes.Library) ??
|
||||||
|
await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(originalPath, SeriesIncludes.Library) ??
|
||||||
|
await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(folder, SeriesIncludes.Library);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
if (ex.Message.Equals("Sequence contains more than one element."))
|
if (ex.Message.Equals("Sequence contains more than one element."))
|
||||||
{
|
{
|
||||||
_logger.LogCritical(ex, "[ScannerService] Multiple series map to this folder. Library scan will be used for ScanFolder");
|
_logger.LogCritical(ex, "[ScannerService] Multiple series map to this folder or folder is at library root. Library scan will be used for ScanFolder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Figure out why we have the library type restriction here
|
// TODO: Figure out why we have the library type restriction here
|
||||||
if (series != null && (series.Library.Type != LibraryType.Book || series.Library.Type != LibraryType.LightNovel))
|
if (series != null && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel))
|
||||||
{
|
{
|
||||||
if (TaskScheduler.HasScanTaskRunningForSeries(series.Id))
|
if (TaskScheduler.HasScanTaskRunningForSeries(series.Id))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
|
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder);
|
||||||
BackgroundJob.Schedule(() => ScanSeries(series.Id, true), TimeSpan.FromMinutes(1));
|
BackgroundJob.Schedule(() => ScanSeries(series.Id, true), TimeSpan.FromMinutes(1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -181,7 +181,9 @@ public class StatsService : IStatsService
|
|||||||
{
|
{
|
||||||
InstallId = serverSettings.InstallId,
|
InstallId = serverSettings.InstallId,
|
||||||
KavitaVersion = serverSettings.InstallVersion,
|
KavitaVersion = serverSettings.InstallVersion,
|
||||||
IsDocker = OsInfo.IsDocker
|
IsDocker = OsInfo.IsDocker,
|
||||||
|
FirstInstallDate = serverSettings.FirstInstallDate,
|
||||||
|
FirstInstallVersion = serverSettings.FirstInstallVersion
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -268,6 +268,7 @@ public class Startup
|
|||||||
|
|
||||||
// v0.8.2
|
// v0.8.2
|
||||||
await ManualMigrateThemeDescription.Migrate(dataContext, logger);
|
await ManualMigrateThemeDescription.Migrate(dataContext, logger);
|
||||||
|
await MigrateInitialInstallData.Migrate(dataContext, logger, directoryService);
|
||||||
|
|
||||||
// Update the version in the DB after all migrations are run
|
// Update the version in the DB after all migrations are run
|
||||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||||
|
@ -14,10 +14,10 @@
|
|||||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.25.0.90414">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.26.0.92422">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="xunit.assert" Version="2.8.0" />
|
<PackageReference Include="xunit.assert" Version="2.8.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
@ -10,6 +10,7 @@ import { Volume } from '../_models/volume';
|
|||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { DeviceService } from './device.service';
|
import { DeviceService } from './device.service';
|
||||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||||
|
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||||
|
|
||||||
export enum Action {
|
export enum Action {
|
||||||
Submenu = -1,
|
Submenu = -1,
|
||||||
@ -150,6 +151,7 @@ export class ActionFactoryService {
|
|||||||
bookmarkActions: Array<ActionItem<Series>> = [];
|
bookmarkActions: Array<ActionItem<Series>> = [];
|
||||||
|
|
||||||
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||||
|
smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
||||||
|
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
|
|
||||||
@ -178,6 +180,10 @@ export class ActionFactoryService {
|
|||||||
return this.applyCallbackToList(this.sideNavStreamActions, callback);
|
return this.applyCallbackToList(this.sideNavStreamActions, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSmartFilterActions(callback: ActionCallback<SmartFilter>) {
|
||||||
|
return this.applyCallbackToList(this.smartFilterActions, callback);
|
||||||
|
}
|
||||||
|
|
||||||
getVolumeActions(callback: ActionCallback<Volume>) {
|
getVolumeActions(callback: ActionCallback<Volume>) {
|
||||||
return this.applyCallbackToList(this.volumeActions, callback);
|
return this.applyCallbackToList(this.volumeActions, callback);
|
||||||
}
|
}
|
||||||
@ -620,6 +626,16 @@ export class ActionFactoryService {
|
|||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
this.smartFilterActions = [
|
||||||
|
{
|
||||||
|
action: Action.Delete,
|
||||||
|
title: 'delete',
|
||||||
|
callback: this.dummyCallback,
|
||||||
|
requiresAdmin: false,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {
|
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, OnDestroy } from '@angular/core';
|
import { Injectable, OnDestroy } from '@angular/core';
|
||||||
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';
|
||||||
import { Subject } from 'rxjs';
|
import {Subject, tap} from 'rxjs';
|
||||||
import { take } from 'rxjs/operators';
|
import { take } from 'rxjs/operators';
|
||||||
import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component';
|
import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component';
|
||||||
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
|
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
|
||||||
@ -19,9 +19,11 @@ import { LibraryService } from './library.service';
|
|||||||
import { MemberService } from './member.service';
|
import { MemberService } from './member.service';
|
||||||
import { ReaderService } from './reader.service';
|
import { ReaderService } from './reader.service';
|
||||||
import { SeriesService } from './series.service';
|
import { SeriesService } from './series.service';
|
||||||
import {translate, TranslocoService} from "@ngneat/transloco";
|
import {translate} from "@ngneat/transloco";
|
||||||
import {UserCollection} from "../_models/collection-tag";
|
import {UserCollection} from "../_models/collection-tag";
|
||||||
import {CollectionTagService} from "./collection-tag.service";
|
import {CollectionTagService} from "./collection-tag.service";
|
||||||
|
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||||
|
import {FilterService} from "./filter.service";
|
||||||
|
|
||||||
export type LibraryActionCallback = (library: Partial<Library>) => void;
|
export type LibraryActionCallback = (library: Partial<Library>) => void;
|
||||||
export type SeriesActionCallback = (series: Series) => void;
|
export type SeriesActionCallback = (series: Series) => void;
|
||||||
@ -46,7 +48,7 @@ export class ActionService implements OnDestroy {
|
|||||||
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
|
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
|
||||||
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
|
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
|
||||||
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService,
|
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService,
|
||||||
private readonly collectionTagService: CollectionTagService) { }
|
private readonly collectionTagService: CollectionTagService, private filterService: FilterService) { }
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.onDestroy.next();
|
this.onDestroy.next();
|
||||||
@ -655,4 +657,21 @@ export class ActionService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteFilter(filterId: number, callback?: BooleanActionCallback) {
|
||||||
|
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) {
|
||||||
|
if (callback) {
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filterService.deleteFilter(filterId).subscribe(_ => {
|
||||||
|
this.toastr.success(translate('toasts.smart-filter-deleted'));
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
export interface ServerInfoSlim {
|
export interface ServerInfoSlim {
|
||||||
kavitaVersion: string;
|
kavitaVersion: string;
|
||||||
installId: string;
|
installId: string;
|
||||||
isDocker: boolean;
|
isDocker: boolean;
|
||||||
|
firstInstallVersion?: string;
|
||||||
|
firstInstallDate?: string;
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,12 @@
|
|||||||
|
|
||||||
<dt>{{t('installId-title')}}</dt>
|
<dt>{{t('installId-title')}}</dt>
|
||||||
<dd>{{serverInfo.installId}}</dd>
|
<dd>{{serverInfo.installId}}</dd>
|
||||||
|
|
||||||
|
<dt>{{t('first-install-version-title')}}</dt>
|
||||||
|
<dd>{{serverInfo.firstInstallVersion | defaultValue}}</dd>
|
||||||
|
|
||||||
|
<dt>{{t('first-install-date-title')}}</dt>
|
||||||
|
<dd>{{serverInfo.firstInstallDate | date:'shortDate'}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||||
import {ServerService} from 'src/app/_services/server.service';
|
import {ServerService} from 'src/app/_services/server.service';
|
||||||
import {ServerInfoSlim} from '../_models/server-info';
|
import {ServerInfoSlim} from '../_models/server-info';
|
||||||
import {NgIf} from '@angular/common';
|
import {DatePipe, NgIf} from '@angular/common';
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
import {ChangelogComponent} from "../../announcements/_components/changelog/changelog.component";
|
import {ChangelogComponent} from "../../announcements/_components/changelog/changelog.component";
|
||||||
|
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||||
|
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manage-system',
|
selector: 'app-manage-system',
|
||||||
@ -11,18 +13,16 @@ import {ChangelogComponent} from "../../announcements/_components/changelog/chan
|
|||||||
styleUrls: ['./manage-system.component.scss'],
|
styleUrls: ['./manage-system.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [NgIf, TranslocoDirective, ChangelogComponent]
|
imports: [NgIf, TranslocoDirective, ChangelogComponent, DefaultDatePipe, DefaultValuePipe, DatePipe]
|
||||||
})
|
})
|
||||||
export class ManageSystemComponent implements OnInit {
|
export class ManageSystemComponent implements OnInit {
|
||||||
|
|
||||||
serverInfo!: ServerInfoSlim;
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly serverService = inject(ServerService);
|
||||||
|
|
||||||
|
serverInfo!: ServerInfoSlim;
|
||||||
constructor(public serverService: ServerService) { }
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
|
||||||
this.serverService.getServerInfo().subscribe(info => {
|
this.serverService.getServerInfo().subscribe(info => {
|
||||||
this.serverInfo = info;
|
this.serverInfo = info;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
@ -3,32 +3,18 @@
|
|||||||
<h2 title>
|
<h2 title>
|
||||||
{{t('title')}}
|
{{t('title')}}
|
||||||
</h2>
|
</h2>
|
||||||
<h6 subtitle >{{t('count', {count: filters.length | number})}}</h6>
|
<div subtitle>
|
||||||
|
<h6>
|
||||||
|
<span>{{t('count', {count: filters.length | number})}}</span>
|
||||||
|
<a class="ms-2" href="/all-series?name=New%20Filter">{{t('create')}}</a>
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</app-side-nav-companion-bar>
|
</app-side-nav-companion-bar>
|
||||||
<app-card-detail-layout
|
|
||||||
[isLoading]="isLoading"
|
<app-manage-smart-filters></app-manage-smart-filters>
|
||||||
[items]="filters"
|
|
||||||
[trackByIdentity]="trackByIdentity"
|
|
||||||
[jumpBarKeys]="jumpbarKeys"
|
|
||||||
>
|
|
||||||
<ng-template #cardItem let-item let-position="idx">
|
|
||||||
<!-- TODO: figure a way to get a hover effect -->
|
|
||||||
<div class="card-item-container card clickable" (click)="loadSmartFilter(item)">
|
|
||||||
<div class="overlay filter">
|
|
||||||
<div class="card-overlay"></div>
|
|
||||||
<div class="overlay-information overlay-information--centered">
|
|
||||||
<div class="position-relative">
|
|
||||||
<span class="card-title library mx-auto" style="width: auto;">
|
|
||||||
<i class="fa-solid fa-filter" style="font-size: 26px;" [ngClass]="{'error': isErrored(item)}" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<span class="card-title">{{item.name}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
</app-card-detail-layout>
|
|
||||||
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -1,16 +1,2 @@
|
|||||||
.card-title {
|
|
||||||
width: 146px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--error-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card, .overlay {
|
|
||||||
height: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-item-container .overlay-information.overlay-information--centered {
|
|
||||||
top: 54px;
|
|
||||||
left: 51px;
|
|
||||||
}
|
|
||||||
|
@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, injec
|
|||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {JumpKey} from "../_models/jumpbar/jump-key";
|
import {JumpKey} from "../_models/jumpbar/jump-key";
|
||||||
import {EVENTS, Message, MessageHubService} from "../_services/message-hub.service";
|
import {EVENTS, Message, MessageHubService} from "../_services/message-hub.service";
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||||
import {CardItemComponent} from "../cards/card-item/card-item.component";
|
import {CardItemComponent} from "../cards/card-item/card-item.component";
|
||||||
import {
|
import {
|
||||||
SideNavCompanionBarComponent
|
SideNavCompanionBarComponent
|
||||||
@ -11,14 +11,20 @@ import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
|||||||
import {FilterService} from "../_services/filter.service";
|
import {FilterService} from "../_services/filter.service";
|
||||||
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
||||||
import {SafeHtmlPipe} from "../_pipes/safe-html.pipe";
|
import {SafeHtmlPipe} from "../_pipes/safe-html.pipe";
|
||||||
import {Router} from "@angular/router";
|
import {Router, RouterLink} from "@angular/router";
|
||||||
import {Series} from "../_models/series";
|
import {Series} from "../_models/series";
|
||||||
import {JumpbarService} from "../_services/jumpbar.service";
|
import {JumpbarService} from "../_services/jumpbar.service";
|
||||||
|
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
|
||||||
|
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||||
|
import {ActionService} from "../_services/action.service";
|
||||||
|
import {FilterPipe} from "../_pipes/filter.pipe";
|
||||||
|
import {filter} from "rxjs";
|
||||||
|
import {ManageSmartFiltersComponent} from "../sidenav/_components/manage-smart-filters/manage-smart-filters.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-all-filters',
|
selector: 'app-all-filters',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslocoDirective, CardItemComponent, SideNavCompanionBarComponent, CardDetailLayoutComponent, SafeHtmlPipe],
|
imports: [CommonModule, TranslocoDirective, CardItemComponent, SideNavCompanionBarComponent, CardDetailLayoutComponent, SafeHtmlPipe, CardActionablesComponent, RouterLink, FilterPipe, ManageSmartFiltersComponent],
|
||||||
templateUrl: './all-filters.component.html',
|
templateUrl: './all-filters.component.html',
|
||||||
styleUrl: './all-filters.component.scss',
|
styleUrl: './all-filters.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@ -28,13 +34,20 @@ export class AllFiltersComponent implements OnInit {
|
|||||||
private readonly jumpbarService = inject(JumpbarService);
|
private readonly jumpbarService = inject(JumpbarService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly filterService = inject(FilterService);
|
private readonly filterService = inject(FilterService);
|
||||||
|
private readonly actionFactory = inject(ActionFactoryService);
|
||||||
|
private readonly actionService = inject(ActionService);
|
||||||
|
|
||||||
|
filterActions = this.actionFactory.getSmartFilterActions(this.handleAction.bind(this));
|
||||||
jumpbarKeys: Array<JumpKey> = [];
|
jumpbarKeys: Array<JumpKey> = [];
|
||||||
filters: SmartFilter[] = [];
|
filters: SmartFilter[] = [];
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
trackByIdentity = (index: number, item: SmartFilter) => item.name;
|
trackByIdentity = (index: number, item: SmartFilter) => item.name;
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData() {
|
||||||
this.filterService.getAllFilters().subscribe(filters => {
|
this.filterService.getAllFilters().subscribe(filters => {
|
||||||
this.filters = filters;
|
this.filters = filters;
|
||||||
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.filters, (s: Series) => s.name);
|
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.filters, (s: Series) => s.name);
|
||||||
@ -51,4 +64,23 @@ export class AllFiltersComponent implements OnInit {
|
|||||||
return !decodeURIComponent(filter.filter).includes('¦');
|
return !decodeURIComponent(filter.filter).includes('¦');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteFilter(filter: SmartFilter) {
|
||||||
|
await this.actionService.deleteFilter(filter.id, success => {
|
||||||
|
this.filters = this.filters.filter(f => f.id != filter.id);
|
||||||
|
this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.filters, (s: Series) => s.name);
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAction(action: ActionItem<SmartFilter>, filter: SmartFilter) {
|
||||||
|
switch (action.action) {
|
||||||
|
case(Action.Delete):
|
||||||
|
await this.deleteFilter(filter);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly filter = filter;
|
||||||
}
|
}
|
||||||
|
@ -57,15 +57,18 @@
|
|||||||
@if (title.length > 0 || actions.length > 0) {
|
@if (title.length > 0 || actions.length > 0) {
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div>
|
<div>
|
||||||
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
|
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
|
||||||
<app-promoted-icon [promoted]="isPromoted()"></app-promoted-icon>
|
<app-promoted-icon [promoted]="isPromoted()"></app-promoted-icon>
|
||||||
<app-series-format [format]="format"></app-series-format>
|
<app-series-format [format]="format"></app-series-format>
|
||||||
{{title}}
|
{{title}}
|
||||||
</span>
|
</span>
|
||||||
<span class="card-actions float-end" *ngIf="actions && actions.length > 0">
|
@if (actions && actions.length > 0) {
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
<span class="card-actions float-end">
|
||||||
</span>
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (subtitleTemplate) {
|
@if (subtitleTemplate) {
|
||||||
<div style="text-align: center">
|
<div style="text-align: center">
|
||||||
<ng-container [ngTemplateOutlet]="subtitleTemplate" [ngTemplateOutletContext]="{ $implicit: entity }"></ng-container>
|
<ng-container [ngTemplateOutlet]="subtitleTemplate" [ngTemplateOutletContext]="{ $implicit: entity }"></ng-container>
|
||||||
|
@ -37,7 +37,7 @@ import {FormsModule} from "@angular/forms";
|
|||||||
import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
|
import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
|
||||||
import {MangaFormatIconPipe} from "../../_pipes/manga-format-icon.pipe";
|
import {MangaFormatIconPipe} from "../../_pipes/manga-format-icon.pipe";
|
||||||
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
||||||
import {CommonModule} from "@angular/common";
|
import {DecimalPipe, NgTemplateOutlet} from "@angular/common";
|
||||||
import {RouterLink, RouterLinkActive} from "@angular/router";
|
import {RouterLink, RouterLinkActive} from "@angular/router";
|
||||||
import {TranslocoModule} from "@ngneat/transloco";
|
import {TranslocoModule} from "@ngneat/transloco";
|
||||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||||
@ -51,7 +51,6 @@ import {SeriesFormatComponent} from "../../shared/series-format/series-format.co
|
|||||||
selector: 'app-card-item',
|
selector: 'app-card-item',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
|
||||||
ImageComponent,
|
ImageComponent,
|
||||||
NgbProgressbar,
|
NgbProgressbar,
|
||||||
DownloadIndicatorComponent,
|
DownloadIndicatorComponent,
|
||||||
@ -66,7 +65,9 @@ import {SeriesFormatComponent} from "../../shared/series-format/series-format.co
|
|||||||
SafeHtmlPipe,
|
SafeHtmlPipe,
|
||||||
RouterLinkActive,
|
RouterLinkActive,
|
||||||
PromotedIconComponent,
|
PromotedIconComponent,
|
||||||
SeriesFormatComponent
|
SeriesFormatComponent,
|
||||||
|
DecimalPipe,
|
||||||
|
NgTemplateOutlet
|
||||||
],
|
],
|
||||||
templateUrl: './card-item.component.html',
|
templateUrl: './card-item.component.html',
|
||||||
styleUrls: ['./card-item.component.scss'],
|
styleUrls: ['./card-item.component.scss'],
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
width="16px" height="16px" [styles]="{'vertical-align': 'text-top'}"
|
width="16px" height="16px" [styles]="{'vertical-align': 'text-top'}"
|
||||||
[ngbTooltip]="collectionTag.source | providerName" tabindex="0"></app-image>
|
[ngbTooltip]="collectionTag.source | providerName" tabindex="0"></app-image>
|
||||||
<span class="ms-2 me-2">{{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}}</span>
|
<span class="ms-2 me-2">{{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}}</span>
|
||||||
<i class="fa-solid fa-question-circle" aria-hidden="true" [ngbTooltip]="t('last-sync', {date: collectionTag.lastSyncUtc | date: 'shortDate' | defaultDate })"></i>
|
<i class="fa-solid fa-question-circle" aria-hidden="true" [ngbTooltip]="t('last-sync', {date: collectionTag.lastSyncUtc | date: 'short' | defaultDate })"></i>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@ -154,12 +154,17 @@
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (hasData && !includeChapterAndFiles) {
|
@if (hasData && (isAdmin$ | async)) {
|
||||||
<li class="list-group-item" style="min-height: 34px">
|
<li class="list-group-item" style="min-height: 34px" (click)="$event.stopPropagation()">
|
||||||
<ng-container [ngTemplateOutlet]="extraTemplate"></ng-container>
|
<ng-container [ngTemplateOutlet]="extraTemplate"></ng-container>
|
||||||
<a href="javascript:void(0)" (click)="toggleIncludeFiles()" class="float-end">
|
<form [formGroup]="searchSettingsForm">
|
||||||
{{t('include-extras')}}
|
<div class="form-check form-switch">
|
||||||
</a>
|
<input type="checkbox" id="search-include-extras" role="switch" formControlName="includeExtras" class="form-check-input"
|
||||||
|
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
|
||||||
|
<label class="form-check-label" for="search-include-extras">{{t('include-extras')}}</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -13,14 +13,16 @@ import {
|
|||||||
TemplateRef,
|
TemplateRef,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||||
import { debounceTime } from 'rxjs/operators';
|
import {debounceTime, distinctUntilChanged} from 'rxjs/operators';
|
||||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||||
import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
|
import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import { NgClass, NgTemplateOutlet } from '@angular/common';
|
import {AsyncPipe, NgClass, NgTemplateOutlet} from '@angular/common';
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
||||||
|
import {map, startWith, tap} from "rxjs";
|
||||||
|
import {AccountService} from "../../../_services/account.service";
|
||||||
|
|
||||||
export interface SearchEvent {
|
export interface SearchEvent {
|
||||||
value: string;
|
value: string;
|
||||||
@ -33,11 +35,12 @@ export interface SearchEvent {
|
|||||||
styleUrls: ['./grouped-typeahead.component.scss'],
|
styleUrls: ['./grouped-typeahead.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, LoadingComponent]
|
imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, LoadingComponent, AsyncPipe]
|
||||||
})
|
})
|
||||||
export class GroupedTypeaheadComponent implements OnInit {
|
export class GroupedTypeaheadComponent implements OnInit {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly accountService = inject(AccountService);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unique id to tie with a label element
|
* Unique id to tie with a label element
|
||||||
@ -97,12 +100,15 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
@ContentChild('bookmarkTemplate') bookmarkTemplate!: TemplateRef<any>;
|
@ContentChild('bookmarkTemplate') bookmarkTemplate!: TemplateRef<any>;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
hasFocus: boolean = false;
|
hasFocus: boolean = false;
|
||||||
typeaheadForm: FormGroup = new FormGroup({});
|
typeaheadForm: FormGroup = new FormGroup({});
|
||||||
includeChapterAndFiles: boolean = false;
|
includeChapterAndFiles: boolean = false;
|
||||||
|
|
||||||
prevSearchTerm: string = '';
|
prevSearchTerm: string = '';
|
||||||
|
searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)}));
|
||||||
|
isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => {
|
||||||
|
if (!u) return false;
|
||||||
|
return this.accountService.hasAdminRole(u);
|
||||||
|
}));
|
||||||
|
|
||||||
get searchTerm() {
|
get searchTerm() {
|
||||||
return this.typeaheadForm.get('typeahead')?.value || '';
|
return this.typeaheadForm.get('typeahead')?.value || '';
|
||||||
@ -139,6 +145,17 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
|
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
|
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(
|
||||||
|
startWith(false),
|
||||||
|
map(val => {
|
||||||
|
if (val === null) return false;
|
||||||
|
return val;
|
||||||
|
}),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
tap((val: boolean) => this.toggleIncludeFiles(val)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
this.typeaheadForm.valueChanges.pipe(
|
this.typeaheadForm.valueChanges.pipe(
|
||||||
debounceTime(this.debounceTime),
|
debounceTime(this.debounceTime),
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
@ -183,13 +200,22 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
this.selected.emit(item);
|
this.selected.emit(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleIncludeFiles() {
|
toggleIncludeFiles(val: boolean) {
|
||||||
this.includeChapterAndFiles = true;
|
const firstRun = val === false && val === this.includeChapterAndFiles;
|
||||||
|
|
||||||
|
this.includeChapterAndFiles = val;
|
||||||
this.inputChanged.emit({value: this.searchTerm, includeFiles: this.includeChapterAndFiles});
|
this.inputChanged.emit({value: this.searchTerm, includeFiles: this.includeChapterAndFiles});
|
||||||
|
|
||||||
this.hasFocus = true;
|
if (!firstRun) {
|
||||||
this.inputElem.nativeElement.focus();
|
this.hasFocus = true;
|
||||||
this.openDropdown();
|
if (this.inputElem && this.inputElem.nativeElement) {
|
||||||
|
this.inputElem.nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<form [formGroup]="form" *transloco="let t">
|
<form [formGroup]="form" *transloco="let t">
|
||||||
|
|
||||||
@for(item of Items; let i = $index; track item) {
|
@for(item of Items; let i = $index; track item; let isFirst = $first) {
|
||||||
<div class="row g-0 mb-3">
|
<div class="row g-0 mb-3">
|
||||||
<div class="col-lg-10 col-md-12 pe-2">
|
<div class="col-lg-10 col-md-12 pe-2">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -13,7 +13,7 @@
|
|||||||
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||||
<span class="visually-hidden">{{t('common.add')}}</span>
|
<span class="visually-hidden">{{t('common.add')}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" (click)="remove(i)">
|
<button class="btn btn-secondary" (click)="remove(i)" [disabled]="isFirst">
|
||||||
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
|
||||||
<span class="visually-hidden">{{t('common.remove')}}</span>
|
<span class="visually-hidden">{{t('common.remove')}}</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,25 +1,35 @@
|
|||||||
<ng-container *transloco="let t; read:'manage-smart-filters'">
|
<ng-container *transloco="let t; read:'manage-smart-filters'">
|
||||||
<form [formGroup]="listForm">
|
<form [formGroup]="listForm">
|
||||||
<div class="mb-3" *ngIf="filters.length >= 3">
|
@if (filters.length >= 3) {
|
||||||
<label for="filter" class="form-label">{{t('filter')}}</label>
|
<div class="mb-3">
|
||||||
<div class="input-group">
|
<label for="filter" class="form-label">{{t('filter')}}</label>
|
||||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
<div class="input-group">
|
||||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
|
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li class="list-group-item" *ngFor="let f of filters | filter: filterList">
|
@for(f of filters | filter: filterList; track f.name) {
|
||||||
<a [href]="'all-series?' + f.filter" target="_blank">{{f.name}}</a>
|
<li class="list-group-item">
|
||||||
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
|
<span>
|
||||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
@if (isErrored(f)) {
|
||||||
<span class="visually-hidden">{{t('delete')}}</span>
|
<i class="fa-solid fa-triangle-exclamation red me-2" [ngbTooltip]="t('errored')"></i>
|
||||||
</button>
|
<span class="visually-hidden">{{t('errored')}}</span>
|
||||||
</li>
|
}
|
||||||
|
<a [href]="'all-series?' + f.filter" target="_blank">{{f.name}}</a>
|
||||||
<li class="list-group-item" *ngIf="filters.length === 0">
|
</span>
|
||||||
{{t('no-data')}}
|
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
|
||||||
</li>
|
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||||
|
<span class="visually-hidden">{{t('delete')}}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="list-group-item">
|
||||||
|
{{t('no-data')}}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -19,3 +19,7 @@ ul {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.red {
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
|
||||||
import {FilterService} from "../../../_services/filter.service";
|
import {FilterService} from "../../../_services/filter.service";
|
||||||
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
|
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
|
||||||
import {Router} from "@angular/router";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
import {ConfirmService} from "../../../shared/confirm.service";
|
|
||||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
|
||||||
import {ToastrService} from "ngx-toastr";
|
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||||
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
||||||
|
import {ActionService} from "../../../_services/action.service";
|
||||||
|
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manage-smart-filters',
|
selector: 'app-manage-smart-filters',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, TranslocoDirective, FilterPipe],
|
imports: [ReactiveFormsModule, TranslocoDirective, FilterPipe, NgbTooltip],
|
||||||
templateUrl: './manage-smart-filters.component.html',
|
templateUrl: './manage-smart-filters.component.html',
|
||||||
styleUrls: ['./manage-smart-filters.component.scss'],
|
styleUrls: ['./manage-smart-filters.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@ -20,10 +18,9 @@ import {FilterPipe} from "../../../_pipes/filter.pipe";
|
|||||||
export class ManageSmartFiltersComponent {
|
export class ManageSmartFiltersComponent {
|
||||||
|
|
||||||
private readonly filterService = inject(FilterService);
|
private readonly filterService = inject(FilterService);
|
||||||
private readonly confirmService = inject(ConfirmService);
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly actionService = inject(ActionService);
|
||||||
|
|
||||||
filters: Array<SmartFilter> = [];
|
filters: Array<SmartFilter> = [];
|
||||||
listForm: FormGroup = new FormGroup({
|
listForm: FormGroup = new FormGroup({
|
||||||
'filterQuery': new FormControl('', [])
|
'filterQuery': new FormControl('', [])
|
||||||
@ -33,11 +30,6 @@ export class ManageSmartFiltersComponent {
|
|||||||
const filterVal = (this.listForm.value.filterQuery || '').toLowerCase();
|
const filterVal = (this.listForm.value.filterQuery || '').toLowerCase();
|
||||||
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
|
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
|
||||||
}
|
}
|
||||||
resetFilter() {
|
|
||||||
this.listForm.get('filterQuery')?.setValue('');
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadData();
|
this.loadData();
|
||||||
@ -50,15 +42,18 @@ export class ManageSmartFiltersComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFilter(f: SmartFilter) {
|
resetFilter() {
|
||||||
await this.router.navigateByUrl('all-series?' + f.filter);
|
this.listForm.get('filterQuery')?.setValue('');
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
isErrored(filter: SmartFilter) {
|
||||||
|
return !decodeURIComponent(filter.filter).includes('¦');
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFilter(f: SmartFilter) {
|
async deleteFilter(f: SmartFilter) {
|
||||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) return;
|
await this.actionService.deleteFilter(f.id, success => {
|
||||||
|
if (!success) return;
|
||||||
this.filterService.deleteFilter(f.id).subscribe(() => {
|
|
||||||
this.toastr.success(translate('toasts.smart-filter-deleted'));
|
|
||||||
this.resetFilter();
|
this.resetFilter();
|
||||||
this.loadData();
|
this.loadData();
|
||||||
});
|
});
|
||||||
|
@ -147,8 +147,10 @@ export class SideNavComponent implements OnInit {
|
|||||||
this.accountService.hasValidLicense$.subscribe(res =>{
|
this.accountService.hasValidLicense$.subscribe(res =>{
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
|
|
||||||
this.homeActions.push({action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)});
|
if (this.homeActions.filter(f => f.title === 'import-mal-stack').length === 0) {
|
||||||
this.cdRef.markForCheck();
|
this.homeActions.push({action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)});
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -573,7 +573,8 @@
|
|||||||
|
|
||||||
"all-filters": {
|
"all-filters": {
|
||||||
"title": "All Smart Filters",
|
"title": "All Smart Filters",
|
||||||
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}"
|
"count": "{{count}} {{customize-dashboard-modal.title-smart-filters}}",
|
||||||
|
"create": "{{common.create}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"announcements": {
|
"announcements": {
|
||||||
@ -1273,6 +1274,8 @@
|
|||||||
"version-title": "Version",
|
"version-title": "Version",
|
||||||
"installId-title": "Install ID",
|
"installId-title": "Install ID",
|
||||||
"more-info-title": "More Info",
|
"more-info-title": "More Info",
|
||||||
|
"first-install-version-title": "First Install Version",
|
||||||
|
"first-install-date-title": "First Install Date",
|
||||||
"home-page-title": "Home page:",
|
"home-page-title": "Home page:",
|
||||||
"wiki-title": "Wiki:",
|
"wiki-title": "Wiki:",
|
||||||
"discord-title": "Discord:",
|
"discord-title": "Discord:",
|
||||||
@ -2022,7 +2025,8 @@
|
|||||||
"delete": "{{common.delete}}",
|
"delete": "{{common.delete}}",
|
||||||
"no-data": "No Smart Filters created",
|
"no-data": "No Smart Filters created",
|
||||||
"filter": "{{common.filter}}",
|
"filter": "{{common.filter}}",
|
||||||
"clear": "{{common.clear}}"
|
"clear": "{{common.clear}}",
|
||||||
|
"errored": "There is an encoding error in the filter. You need to recreate it."
|
||||||
},
|
},
|
||||||
|
|
||||||
"edit-external-source-item": {
|
"edit-external-source-item": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user