v0.7.3 - The Quality of Life Update (#2036)

* Version bump

* Okay this should be the last (#2037)

* Fixed improper date visualization for reading list detail page.

* Correct not-read badge position (#2034)

---------

Co-authored-by: Andre Smith <Hobogrammer@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* Merged develop in

---------

Co-authored-by: Andre Smith <Hobogrammer@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2023-06-07 07:55:53 -05:00 committed by GitHub
parent 51e23b7eca
commit 1b3866568f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
235 changed files with 14827 additions and 21948 deletions

View file

@ -156,11 +156,11 @@ jobs:
- name: NodeJS to Compile WebUI - name: NodeJS to Compile WebUI
uses: actions/setup-node@v2.1.5 uses: actions/setup-node@v2.1.5
with: with:
node-version: '14' node-version: '16'
- run: | - run: |
cd UI/Web || exit cd UI/Web || exit
echo 'Installing web dependencies' echo 'Installing web dependencies'
npm ci npm install --legacy-peer-deps
echo 'Building UI' echo 'Building UI'
npm run prod npm run prod
@ -280,12 +280,12 @@ jobs:
- name: NodeJS to Compile WebUI - name: NodeJS to Compile WebUI
uses: actions/setup-node@v2.1.5 uses: actions/setup-node@v2.1.5
with: with:
node-version: '14' node-version: '16'
- run: | - run: |
cd UI/Web || exit cd UI/Web || exit
echo 'Installing web dependencies' echo 'Installing web dependencies'
npm install npm ci
echo 'Building UI' echo 'Building UI'
npm run prod npm run prod

1
.gitignore vendored
View file

@ -512,6 +512,7 @@ UI/Web/dist/
/API/config/themes/ /API/config/themes/
/API/config/stats/ /API/config/stats/
/API/config/bookmarks/ /API/config/bookmarks/
/API/config/favicons/
/API/config/kavita.db /API/config/kavita.db
/API/config/kavita.db-shm /API/config/kavita.db-shm
/API/config/kavita.db-wal /API/config/kavita.db-wal

View file

@ -5,6 +5,8 @@ using Microsoft.Extensions.Logging.Abstractions;
using API.Services; using API.Services;
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order; using BenchmarkDotNet.Order;
using EasyCaching.Core;
using NSubstitute;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Formats.Webp;
@ -30,8 +32,8 @@ public class ArchiveServiceBenchmark
public ArchiveServiceBenchmark() public ArchiveServiceBenchmark()
{ {
_directoryService = new DirectoryService(null, new FileSystem()); _directoryService = new DirectoryService(null, new FileSystem());
_imageService = new ImageService(null, _directoryService); _imageService = new ImageService(null, _directoryService, Substitute.For<IEasyCachingProviderFactory>());
_archiveService = new ArchiveService(new NullLogger<ArchiveService>(), _directoryService, _imageService); _archiveService = new ArchiveService(new NullLogger<ArchiveService>(), _directoryService, _imageService, Substitute.For<IMediaErrorService>());
} }
[Benchmark(Baseline = true)] [Benchmark(Baseline = true)]

View file

@ -1,105 +0,0 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Services;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using HtmlAgilityPack;
using VersOne.Epub;
namespace API.Benchmark;
[StopOnFirstError]
[MemoryDiagnoser]
[RankColumn]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[SimpleJob(launchCount: 1, warmupCount: 5, invocationCount: 20)]
public class EpubBenchmark
{
private const string FilePath = @"E:\Books\Invaders of the Rokujouma\Invaders of the Rokujouma - Volume 01.epub";
private readonly Regex _wordRegex = new Regex(@"\b\w+\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
[Benchmark]
public async Task GetWordCount_PassByRef()
{
using var book = await EpubReader.OpenBookAsync(FilePath, BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)
{
await GetBookWordCount_PassByRef(bookFile);
}
}
[Benchmark]
public async Task GetBookWordCount_SumEarlier()
{
using var book = await EpubReader.OpenBookAsync(FilePath, BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)
{
await GetBookWordCount_SumEarlier(bookFile);
}
}
[Benchmark]
public async Task GetBookWordCount_Regex()
{
using var book = await EpubReader.OpenBookAsync(FilePath, BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)
{
await GetBookWordCount_Regex(bookFile);
}
}
private int GetBookWordCount_PassByString(string fileContents)
{
var doc = new HtmlDocument();
doc.LoadHtml(fileContents);
var delimiter = new char[] {' '};
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
.Select(node => node.InnerText)
.Select(text => text.Split(delimiter, StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Select(words => words.Count())
.Where(wordCount => wordCount > 0)
.Sum();
}
private async Task<int> GetBookWordCount_PassByRef(EpubContentFileRef bookFile)
{
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
var delimiter = new char[] {' '};
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
if (textNodes == null) return 0;
return textNodes.Select(node => node.InnerText)
.Select(text => text.Split(delimiter, StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Select(words => words.Count())
.Where(wordCount => wordCount > 0)
.Sum();
}
private async Task<int> GetBookWordCount_SumEarlier(EpubContentFileRef bookFile)
{
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
.DefaultIfEmpty()
.Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Sum(words => words.Count());
}
private async Task<int> GetBookWordCount_Regex(EpubContentFileRef bookFile)
{
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
.Sum(node => _wordRegex.Matches(node.InnerText).Count);
}
}

View file

@ -7,11 +7,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="NSubstitute" Version="4.4.0" /> <PackageReference Include="NSubstitute" Version="4.4.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.11" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.29" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.11" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.29" />
<PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -39,4 +39,5 @@ public class BookParserTests
// var actual = API.Parser.Parser.CssImportUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3"); // var actual = API.Parser.Parser.CssImportUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3");
// Assert.Equal(expected, actual); // Assert.Equal(expected, actual);
// } // }
} }

View file

@ -197,6 +197,7 @@ public class MangaParserTests
[InlineData("Esquire 6권 2021년 10월호", "Esquire")] [InlineData("Esquire 6권 2021년 10월호", "Esquire")]
[InlineData("Accel World: Vol 1", "Accel World")] [InlineData("Accel World: Vol 1", "Accel World")]
[InlineData("Accel World Chapter 001 Volume 002", "Accel World")] [InlineData("Accel World Chapter 001 Volume 002", "Accel World")]
[InlineData("Bleach 001-003", "Bleach")]
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)); Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename));
@ -281,6 +282,7 @@ public class MangaParserTests
[InlineData("Манга 2 Глава", "2")] [InlineData("Манга 2 Глава", "2")]
[InlineData("Манга Том 1 2 Глава", "2")] [InlineData("Манга Том 1 2 Глава", "2")]
[InlineData("Accel World Chapter 001 Volume 002", "1")] [InlineData("Accel World Chapter 001 Volume 002", "1")]
[InlineData("Bleach 001-003", "1-3")]
public void ParseChaptersTest(string filename, string expected) public void ParseChaptersTest(string filename, string expected)
{ {
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename)); Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename));

View file

@ -5,7 +5,9 @@ using System.IO.Abstractions.TestingHelpers;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using API.Archive; using API.Archive;
using API.Entities.Enums;
using API.Services; using API.Services;
using EasyCaching.Core;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NetVips; using NetVips;
using NSubstitute; using NSubstitute;
@ -26,7 +28,9 @@ public class ArchiveServiceTests
public ArchiveServiceTests(ITestOutputHelper testOutputHelper) public ArchiveServiceTests(ITestOutputHelper testOutputHelper)
{ {
_testOutputHelper = testOutputHelper; _testOutputHelper = testOutputHelper;
_archiveService = new ArchiveService(_logger, _directoryService, new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService)); _archiveService = new ArchiveService(_logger, _directoryService,
new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService, Substitute.For<IEasyCachingProviderFactory>()),
Substitute.For<IMediaErrorService>());
} }
[Theory] [Theory]
@ -163,8 +167,8 @@ public class ArchiveServiceTests
public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile) public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile)
{ {
var ds = Substitute.For<DirectoryService>(_directoryServiceLogger, new FileSystem()); var ds = Substitute.For<DirectoryService>(_directoryServiceLogger, new FileSystem());
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), ds); var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), ds, Substitute.For<IEasyCachingProviderFactory>());
var archiveService = Substitute.For<ArchiveService>(_logger, ds, imageService); var archiveService = Substitute.For<ArchiveService>(_logger, ds, imageService, Substitute.For<IMediaErrorService>());
var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")); var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"));
var expectedBytes = Image.Thumbnail(Path.Join(testDirectory, expectedOutputFile), 320).WriteToBuffer(".png"); var expectedBytes = Image.Thumbnail(Path.Join(testDirectory, expectedOutputFile), 320).WriteToBuffer(".png");
@ -176,7 +180,7 @@ public class ArchiveServiceTests
_directoryService.ExistOrCreate(outputDir); _directoryService.ExistOrCreate(outputDir);
var coverImagePath = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), var coverImagePath = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile),
Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir); Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir, EncodeFormat.PNG);
var actual = File.ReadAllBytes(Path.Join(outputDir, coverImagePath)); var actual = File.ReadAllBytes(Path.Join(outputDir, coverImagePath));
@ -194,9 +198,10 @@ public class ArchiveServiceTests
[InlineData("sorting.zip", "sorting.expected.png")] [InlineData("sorting.zip", "sorting.expected.png")]
public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile)
{ {
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService); var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService, Substitute.For<IEasyCachingProviderFactory>());
var archiveService = Substitute.For<ArchiveService>(_logger, var archiveService = Substitute.For<ArchiveService>(_logger,
new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService); new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService,
Substitute.For<IMediaErrorService>());
var testDirectory = API.Services.Tasks.Scanner.Parser.Parser.NormalizePath(Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"))); var testDirectory = API.Services.Tasks.Scanner.Parser.Parser.NormalizePath(Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")));
var outputDir = Path.Join(testDirectory, "output"); var outputDir = Path.Join(testDirectory, "output");
@ -205,7 +210,7 @@ public class ArchiveServiceTests
archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress); archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress);
var coverOutputFile = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), var coverOutputFile = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile),
Path.GetFileNameWithoutExtension(inputFile), outputDir); Path.GetFileNameWithoutExtension(inputFile), outputDir, EncodeFormat.PNG);
var actualBytes = File.ReadAllBytes(Path.Join(outputDir, coverOutputFile)); var actualBytes = File.ReadAllBytes(Path.Join(outputDir, coverOutputFile));
var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
Assert.Equal(expectedBytes, actualBytes); Assert.Equal(expectedBytes, actualBytes);
@ -219,13 +224,14 @@ public class ArchiveServiceTests
public void CanParseCoverImage(string inputFile) public void CanParseCoverImage(string inputFile)
{ {
var imageService = Substitute.For<IImageService>(); var imageService = Substitute.For<IImageService>();
imageService.WriteCoverThumbnail(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<string>()).Returns(x => "cover.jpg"); imageService.WriteCoverThumbnail(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<EncodeFormat>())
var archiveService = new ArchiveService(_logger, _directoryService, imageService); .Returns(x => "cover.jpg");
var archiveService = new ArchiveService(_logger, _directoryService, imageService, Substitute.For<IMediaErrorService>());
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/"); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/");
var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile)); var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile));
var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output"); var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output");
new DirectoryInfo(outputPath).Create(); new DirectoryInfo(outputPath).Create();
var expectedImage = archiveService.GetCoverImage(inputPath, inputFile, outputPath); var expectedImage = archiveService.GetCoverImage(inputPath, inputFile, outputPath, EncodeFormat.PNG);
Assert.Equal("cover.jpg", expectedImage); Assert.Equal("cover.jpg", expectedImage);
new DirectoryInfo(outputPath).Delete(); new DirectoryInfo(outputPath).Delete();
} }

View file

@ -1,6 +1,7 @@
using System.IO; using System.IO;
using System.IO.Abstractions; using System.IO.Abstractions;
using API.Services; using API.Services;
using EasyCaching.Core;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@ -15,7 +16,9 @@ public class BookServiceTests
public BookServiceTests() public BookServiceTests()
{ {
var directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem()); var directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
_bookService = new BookService(_logger, directoryService, new ImageService(Substitute.For<ILogger<ImageService>>(), directoryService)); _bookService = new BookService(_logger, directoryService,
new ImageService(Substitute.For<ILogger<ImageService>>(), directoryService, Substitute.For<IEasyCachingProviderFactory>())
, Substitute.For<IMediaErrorService>());
} }
[Theory] [Theory]

View file

@ -55,7 +55,7 @@ public class BookmarkServiceTests
private BookmarkService Create(IDirectoryService ds) private BookmarkService Create(IDirectoryService ds)
{ {
return new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds, return new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds,
Substitute.For<IImageService>(), Substitute.For<IEventHub>()); Substitute.For<IMediaConversionService>());
} }
#region Setup #region Setup

View file

@ -42,7 +42,7 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
return 1; return 1;
} }
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, bool saveAsWebP) public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat)
{ {
return string.Empty; return string.Empty;
} }

View file

@ -43,7 +43,7 @@ internal class MockReadingItemService : IReadingItemService
return 1; return 1;
} }
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, bool saveAsWebP) public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat)
{ {
return string.Empty; return string.Empty;
} }

View file

@ -1986,6 +1986,184 @@ public class ReaderServiceTests
Assert.Equal(4, nextChapter.VolumeId); Assert.Equal(4, nextChapter.VolumeId);
} }
/// <summary>
/// Volume 1-10 are fully read (single volumes),
/// Special 1 is fully read
/// Chapters 56-90 are read
/// Chapter 91 has partial progress on
/// </summary>
[Fact]
public async Task GetContinuePoint_ShouldReturnLastLooseChapter()
{
await ResetDb();
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("2")
.WithChapter(new ChapterBuilder("21").WithPages(1).Build())
.WithChapter(new ChapterBuilder("22").WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("0")
.WithChapter(new ChapterBuilder("51").WithPages(1).Build())
.WithChapter(new ChapterBuilder("52").WithPages(1).Build())
.WithChapter(new ChapterBuilder("91").WithPages(2).Build())
.WithChapter(new ChapterBuilder("Special").WithIsSpecial(true).WithPages(1).Build())
.Build())
.Build();
series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build();
_context.Series.Add(series);
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 1,
SeriesId = 1,
VolumeId = 1
}, 1);
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 2,
SeriesId = 1,
VolumeId = 1
}, 1);
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 3,
SeriesId = 1,
VolumeId = 2
}, 1);
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 4,
SeriesId = 1,
VolumeId = 2
}, 1);
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 5,
SeriesId = 1,
VolumeId = 2
}, 1);
// Chapter 91 has partial progress, hence it should resume there
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 6,
SeriesId = 1,
VolumeId = 2
}, 1);
// Special is fully read
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 7,
SeriesId = 1,
VolumeId = 2
}, 1);
await _context.SaveChangesAsync();
var nextChapter = await _readerService.GetContinuePoint(1, 1);
Assert.Equal("91", nextChapter.Range);
}
[Fact]
public async Task GetContinuePoint_DuplicateIssueNumberBetweenChapters()
{
await ResetDb();
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.WithChapter(new ChapterBuilder("2").WithPages(1).Build())
.WithChapter(new ChapterBuilder("21").WithPages(1).Build())
.WithChapter(new ChapterBuilder("22").WithPages(1).Build())
.WithChapter(new ChapterBuilder("32").WithPages(1).Build())
.Build())
.WithVolume(new VolumeBuilder("2")
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.WithChapter(new ChapterBuilder("2").WithPages(1).Build())
.WithChapter(new ChapterBuilder("21").WithPages(1).Build())
.WithChapter(new ChapterBuilder("22").WithPages(1).Build())
.WithChapter(new ChapterBuilder("32").WithPages(1).Build())
.Build())
.Build();
series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build();
_context.Series.Add(series);
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 1,
SeriesId = 1,
VolumeId = 1
}, 1);
await _context.SaveChangesAsync();
var nextChapter = await _readerService.GetContinuePoint(1, 1);
Assert.Equal("2", nextChapter.Range);
Assert.Equal(1, nextChapter.VolumeId);
// Mark chapter 2 as read
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 2,
SeriesId = 1,
VolumeId = 1
}, 1);
await _context.SaveChangesAsync();
nextChapter = await _readerService.GetContinuePoint(1, 1);
Assert.Equal("21", nextChapter.Range);
Assert.Equal(1, nextChapter.VolumeId);
// Mark chapter 21 as read
await _readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 1,
ChapterId = 3,
SeriesId = 1,
VolumeId = 1
}, 1);
await _context.SaveChangesAsync();
nextChapter = await _readerService.GetContinuePoint(1, 1);
Assert.Equal("22", nextChapter.Range);
Assert.Equal(1, nextChapter.VolumeId);
}
#endregion #endregion
#region MarkChaptersUntilAsRead #region MarkChaptersUntilAsRead

View file

@ -343,7 +343,7 @@ public class SeriesServiceTests : AbstractDbTest
Assert.True(result); Assert.True(result);
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings)) var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))!
.Ratings; .Ratings;
Assert.NotEmpty(ratings); Assert.NotEmpty(ratings);
Assert.Equal(3, ratings.First().Rating); Assert.Equal(3, ratings.First().Rating);
@ -780,7 +780,7 @@ public class SeriesServiceTests : AbstractDbTest
{ {
var series = CreateSeriesMock(); var series = CreateSeriesMock();
var firstChapter = SeriesService.GetFirstChapterForMetadata(series, true); var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
Assert.Same("1", firstChapter.Range); Assert.Same("1", firstChapter.Range);
} }
@ -789,7 +789,7 @@ public class SeriesServiceTests : AbstractDbTest
{ {
var series = CreateSeriesMock(); var series = CreateSeriesMock();
var firstChapter = SeriesService.GetFirstChapterForMetadata(series, false); var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
Assert.Same("1", firstChapter.Range); Assert.Same("1", firstChapter.Range);
} }
@ -808,10 +808,35 @@ public class SeriesServiceTests : AbstractDbTest
new ChapterBuilder("1.2").WithFiles(files).WithPages(1).Build(), new ChapterBuilder("1.2").WithFiles(files).WithPages(1).Build(),
}; };
var firstChapter = SeriesService.GetFirstChapterForMetadata(series, false); var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
Assert.Same("1.1", firstChapter.Range); Assert.Same("1.1", firstChapter.Range);
} }
[Fact]
public void GetFirstChapterForMetadata_NonBook_ShouldReturnChapter1_WhenFirstVolumeIs3()
{
var file = new MangaFileBuilder("Test.cbz", MangaFormat.Archive, 1).Build();
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("0")
.WithChapter(new ChapterBuilder("1").WithPages(1).WithFile(file).Build())
.WithChapter(new ChapterBuilder("2").WithPages(1).WithFile(file).Build())
.Build())
.WithVolume(new VolumeBuilder("2")
.WithChapter(new ChapterBuilder("21").WithPages(1).WithFile(file).Build())
.WithChapter(new ChapterBuilder("22").WithPages(1).WithFile(file).Build())
.Build())
.WithVolume(new VolumeBuilder("3")
.WithChapter(new ChapterBuilder("31").WithPages(1).WithFile(file).Build())
.WithChapter(new ChapterBuilder("32").WithPages(1).WithFile(file).Build())
.Build())
.Build();
series.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build();
var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
Assert.Same("1", firstChapter.Range);
}
#endregion #endregion
#region SeriesRelation #region SeriesRelation

View file

@ -56,15 +56,16 @@
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" /> <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="CsvHelper" Version="30.0.1" /> <PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="Docnet.Core" Version="2.4.0-alpha.4" /> <PackageReference Include="Docnet.Core" Version="2.4.0-alpha.4" />
<PackageReference Include="EasyCaching.InMemory" Version="1.9.0" />
<PackageReference Include="ExCSS" Version="4.1.0" /> <PackageReference Include="ExCSS" Version="4.1.0" />
<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.7.34" /> <PackageReference Include="Hangfire" Version="1.8.1" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.34" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.1" />
<PackageReference Include="Hangfire.InMemory" Version="0.3.7" /> <PackageReference Include="Hangfire.InMemory" Version="0.4.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" /> <PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" /> <PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.3" /> <PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.4" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.46" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.46" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" /> <PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.5" />
@ -79,30 +80,31 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" /> <PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.3.0" /> <PackageReference Include="NetVips" Version="2.3.0" />
<PackageReference Include="NetVips.Native" Version="8.14.2" /> <PackageReference Include="NetVips.Native" Version="8.14.2" />
<PackageReference Include="NReco.Logging.File" Version="1.1.6" /> <PackageReference Include="NReco.Logging.File" Version="1.1.6" />
<PackageReference Include="Serilog" Version="2.12.0" /> <PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="6.1.0" /> <PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
<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="5.0.1" /> <PackageReference Include="Serilog.Extensions.Hosting" Version="7.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" /> <PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<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.33.0" /> <PackageReference Include="SharpCompress" Version="0.33.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.55.0.65544"> <PackageReference Include="SonarAnalyzer.CSharp" Version="9.0.0.68202">
<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.5.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" /> <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.29.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.30.1" />
<PackageReference Include="System.IO.Abstractions" Version="19.2.11" /> <PackageReference Include="System.IO.Abstractions" Version="19.2.29" />
<PackageReference Include="System.Drawing.Common" Version="7.0.0" /> <PackageReference Include="System.Drawing.Common" Version="7.0.0" />
<PackageReference Include="VersOne.Epub" Version="3.3.0-alpha1" /> <PackageReference Include="VersOne.Epub" Version="3.3.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,10 @@
namespace API.Constants;
public static class EasyCacheProfiles
{
/// <summary>
/// Not in use
/// </summary>
public const string RevokedJwt = "revokedJWT";
public const string Favicon = "favicon";
}

View file

@ -0,0 +1,6 @@
namespace API.Constants;
public abstract class ControllerConstants
{
public const int MaxUploadSizeBytes = 8_000_000;
}

View file

@ -18,6 +18,7 @@ using API.Middleware.RateLimit;
using API.Services; using API.Services;
using API.SignalR; using API.SignalR;
using AutoMapper; using AutoMapper;
using EasyCaching.Core;
using Hangfire; using Hangfire;
using Kavita.Common; using Kavita.Common;
using Kavita.Common.EnvironmentInfo; using Kavita.Common.EnvironmentInfo;
@ -44,6 +45,7 @@ public class AccountController : BaseApiController
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly IEmailService _emailService; private readonly IEmailService _emailService;
private readonly IEventHub _eventHub; private readonly IEventHub _eventHub;
private readonly IEasyCachingProviderFactory _cacheFactory;
/// <inheritdoc /> /// <inheritdoc />
public AccountController(UserManager<AppUser> userManager, public AccountController(UserManager<AppUser> userManager,
@ -51,7 +53,8 @@ public class AccountController : BaseApiController
ITokenService tokenService, IUnitOfWork unitOfWork, ITokenService tokenService, IUnitOfWork unitOfWork,
ILogger<AccountController> logger, ILogger<AccountController> logger,
IMapper mapper, IAccountService accountService, IMapper mapper, IAccountService accountService,
IEmailService emailService, IEventHub eventHub) IEmailService emailService, IEventHub eventHub,
IEasyCachingProviderFactory cacheFactory)
{ {
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
@ -62,6 +65,7 @@ public class AccountController : BaseApiController
_accountService = accountService; _accountService = accountService;
_emailService = emailService; _emailService = emailService;
_eventHub = eventHub; _eventHub = eventHub;
_cacheFactory = cacheFactory;
} }
/// <summary> /// <summary>
@ -187,8 +191,9 @@ public class AccountController : BaseApiController
var result = await _signInManager var result = await _signInManager
.CheckPasswordSignInAsync(user, loginDto.Password, true); .CheckPasswordSignInAsync(user, loginDto.Password, true);
if (result.IsLockedOut) if (result.IsLockedOut) // result.IsLockedOut
{ {
await _userManager.UpdateSecurityStampAsync(user);
return Unauthorized("You've been locked out from too many authorization attempts. Please wait 10 minutes."); return Unauthorized("You've been locked out from too many authorization attempts. Please wait 10 minutes.");
} }
@ -443,6 +448,9 @@ public class AccountController : BaseApiController
if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); if (!roleResult.Succeeded) return BadRequest(roleResult.Errors);
} }
// We might want to check if they had admin and no longer, if so:
// await _userManager.UpdateSecurityStampAsync(user); to force them to re-authenticate
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
List<Library> libraries; List<Library> libraries;

View file

@ -98,9 +98,10 @@ public class BookController : BaseApiController
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions);
var key = BookService.CoalesceKeyForAnyFile(book, file); var key = BookService.CoalesceKeyForAnyFile(book, file);
if (!book.Content.AllFiles.ContainsKey(key)) return BadRequest("File was not found in book");
var bookFile = book.Content.AllFiles[key]; if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest("File was not found in book");
var bookFile = book.Content.AllFiles.GetLocalFileRefByKey(key);
var content = await bookFile.ReadContentAsBytesAsync(); var content = await bookFile.ReadContentAsBytesAsync();
var contentType = BookService.GetContentType(bookFile.ContentType); var contentType = BookService.GetContentType(bookFile.ContentType);

View file

@ -117,7 +117,7 @@ public class DownloadController : BaseApiController
private ActionResult GetFirstFileDownload(IEnumerable<MangaFile> files) private ActionResult GetFirstFileDownload(IEnumerable<MangaFile> files)
{ {
var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files); var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files);
return PhysicalFile(zipFile, contentType, fileDownloadName, true); return PhysicalFile(zipFile, contentType, Uri.EscapeDataString(fileDownloadName), true);
} }
/// <summary> /// <summary>
@ -163,7 +163,7 @@ public class DownloadController : BaseApiController
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(), MessageFactory.DownloadProgressEvent(User.GetUsername(),
Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
return PhysicalFile(filePath, DefaultContentType, downloadName, true); return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -220,7 +220,7 @@ public class DownloadController : BaseApiController
MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 1F)); MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 1F));
return PhysicalFile(filePath, DefaultContentType, filename, true); return PhysicalFile(filePath, DefaultContentType, System.Web.HttpUtility.UrlEncode(filename), true);
} }
} }

View file

@ -1,4 +1,5 @@
using System.IO; using System;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
@ -20,12 +21,14 @@ public class ImageController : BaseApiController
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService;
/// <inheritdoc /> /// <inheritdoc />
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService) public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService, IImageService imageService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_directoryService = directoryService; _directoryService = directoryService;
_imageService = imageService;
} }
/// <summary> /// <summary>
@ -157,6 +160,42 @@ public class ImageController : BaseApiController
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName)); return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
} }
/// <summary>
/// Returns the image associated with a web-link
/// </summary>
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpGet("web-link")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = new []{"url", "apiKey"})]
public async Task<ActionResult> GetWebLinkImage(string url, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
if (string.IsNullOrEmpty(url)) return BadRequest("Url cannot be null");
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
// Check if the domain exists
var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, ImageService.GetWebLinkFormat(url, encodeFormat));
if (!_directoryService.FileSystem.File.Exists(domainFilePath))
{
// We need to request the favicon and save it
try
{
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory,
await _imageService.DownloadFaviconAsync(url, encodeFormat));
}
catch (Exception)
{
return BadRequest("There was an issue fetching favicon for domain");
}
}
var file = new FileInfo(domainFilePath);
var format = Path.GetExtension(file.FullName);
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
}
/// <summary> /// <summary>
/// Returns a temp coverupload image /// Returns a temp coverupload image
/// </summary> /// </summary>

View file

@ -50,22 +50,28 @@ public class LibraryController : BaseApiController
/// <summary> /// <summary>
/// Creates a new Library. Upon library creation, adds new library to all Admin accounts. /// Creates a new Library. Upon library creation, adds new library to all Admin accounts.
/// </summary> /// </summary>
/// <param name="createLibraryDto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[HttpPost("create")] [HttpPost("create")]
public async Task<ActionResult> AddLibrary(CreateLibraryDto createLibraryDto) public async Task<ActionResult> AddLibrary(UpdateLibraryDto dto)
{ {
if (await _unitOfWork.LibraryRepository.LibraryExists(createLibraryDto.Name)) if (await _unitOfWork.LibraryRepository.LibraryExists(dto.Name))
{ {
return BadRequest("Library name already exists. Please choose a unique name to the server."); return BadRequest("Library name already exists. Please choose a unique name to the server.");
} }
var library = new Library var library = new Library
{ {
Name = createLibraryDto.Name, Name = dto.Name,
Type = createLibraryDto.Type, Type = dto.Type,
Folders = createLibraryDto.Folders.Select(x => new FolderPath {Path = x}).ToList() Folders = dto.Folders.Select(x => new FolderPath {Path = x}).Distinct().ToList(),
FolderWatching = dto.FolderWatching,
IncludeInDashboard = dto.IncludeInDashboard,
IncludeInRecommended = dto.IncludeInRecommended,
IncludeInSearch = dto.IncludeInSearch,
ManageCollections = dto.ManageCollections,
ManageReadingLists = dto.ManageReadingLists,
}; };
_unitOfWork.LibraryRepository.Add(library); _unitOfWork.LibraryRepository.Add(library);
@ -335,9 +341,8 @@ public class LibraryController : BaseApiController
[HttpGet("name-exists")] [HttpGet("name-exists")]
public async Task<ActionResult<bool>> IsLibraryNameValid(string name) public async Task<ActionResult<bool>> IsLibraryNameValid(string name)
{ {
var trimmed = name.Trim(); if (string.IsNullOrWhiteSpace(name)) return Ok(true);
if (string.IsNullOrEmpty(trimmed)) return Ok(true); return Ok(await _unitOfWork.LibraryRepository.LibraryExists(name.Trim()));
return Ok(await _unitOfWork.LibraryRepository.LibraryExists(trimmed));
} }
/// <summary> /// <summary>
@ -360,7 +365,7 @@ public class LibraryController : BaseApiController
var originalFolders = library.Folders.Select(x => x.Path).ToList(); var originalFolders = library.Folders.Select(x => x.Path).ToList();
library.Name = newName; library.Name = newName;
library.Folders = dto.Folders.Select(s => new FolderPath() {Path = s}).ToList(); library.Folders = dto.Folders.Select(s => new FolderPath() {Path = s}).Distinct().ToList();
var typeUpdate = library.Type != dto.Type; var typeUpdate = library.Type != dto.Type;
var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching; var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching;

View file

@ -35,7 +35,7 @@ public class MetadataController : BaseApiController
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds) public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0) if (ids != null && ids.Count > 0)
{ {
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, userId)); return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, userId));
@ -56,7 +56,7 @@ public class MetadataController : BaseApiController
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(string? libraryIds) public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(string? libraryIds)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0) if (ids != null && ids.Count > 0)
{ {
return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, userId)); return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, userId));
@ -74,7 +74,7 @@ public class MetadataController : BaseApiController
public async Task<ActionResult<IList<TagDto>>> GetAllTags(string? libraryIds) public async Task<ActionResult<IList<TagDto>>> GetAllTags(string? libraryIds)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0) if (ids != null && ids.Count > 0)
{ {
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, userId)); return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, userId));
@ -92,7 +92,7 @@ public class MetadataController : BaseApiController
[HttpGet("age-ratings")] [HttpGet("age-ratings")]
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds) public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
{ {
var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0) if (ids != null && ids.Count > 0)
{ {
return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids)); return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
@ -115,7 +115,7 @@ public class MetadataController : BaseApiController
[HttpGet("publication-status")] [HttpGet("publication-status")]
public ActionResult<IList<AgeRatingDto>> GetAllPublicationStatus(string? libraryIds) public ActionResult<IList<AgeRatingDto>> GetAllPublicationStatus(string? libraryIds)
{ {
var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids is {Count: > 0}) if (ids is {Count: > 0})
{ {
return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids)); return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
@ -138,7 +138,7 @@ public class MetadataController : BaseApiController
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds) public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
{ {
var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); var ids = libraryIds?.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids is {Count: > 0}) if (ids is {Count: > 0})
{ {
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids)); return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));

View file

@ -62,7 +62,7 @@ public class ReaderController : BaseApiController
{ {
if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest();
var chapter = await _cacheService.Ensure(chapterId); var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("There was an issue finding pdf file for reading"); if (chapter == null) return NoContent();
// Validate the user has access to the PDF // Validate the user has access to the PDF
var series = await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapter.Id, var series = await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapter.Id,
@ -101,7 +101,7 @@ public class ReaderController : BaseApiController
if (page < 0) page = 0; if (page < 0) page = 0;
if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest();
var chapter = await _cacheService.Ensure(chapterId, extractPdf); var chapter = await _cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return BadRequest("There was an issue finding image file for reading"); if (chapter == null) return NoContent();
try try
{ {
@ -125,7 +125,7 @@ public class ReaderController : BaseApiController
{ {
if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest();
var chapter = await _cacheService.Ensure(chapterId, true); var chapter = await _cacheService.Ensure(chapterId, true);
if (chapter == null) return BadRequest("There was an issue extracting images from chapter"); if (chapter == null) return NoContent();
var images = _cacheService.GetCachedPages(chapterId); var images = _cacheService.GetCachedPages(chapterId);
var path = await _readerService.GetThumbnail(chapter, pageNum, images); var path = await _readerService.GetThumbnail(chapter, pageNum, images);
@ -148,7 +148,7 @@ public class ReaderController : BaseApiController
{ {
if (page < 0) page = 0; if (page < 0) page = 0;
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest(); if (userId == 0) return Unauthorized();
var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId); var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId);
if (page > totalPages) if (page > totalPages)
@ -185,7 +185,7 @@ public class ReaderController : BaseApiController
{ {
if (chapterId <= 0) return ArraySegment<FileDimensionDto>.Empty; if (chapterId <= 0) return ArraySegment<FileDimensionDto>.Empty;
var chapter = await _cacheService.Ensure(chapterId, extractPdf); var chapter = await _cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return BadRequest("Could not find Chapter"); if (chapter == null) return NoContent();
return Ok(_cacheService.GetCachedFileDimensions(_cacheService.GetCachePath(chapterId))); return Ok(_cacheService.GetCachedFileDimensions(_cacheService.GetCachePath(chapterId)));
} }
@ -203,7 +203,7 @@ public class ReaderController : BaseApiController
{ {
if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore
var chapter = await _cacheService.Ensure(chapterId, extractPdf); var chapter = await _cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return BadRequest("Could not find Chapter"); if (chapter == null) return NoContent();
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
if (dto == null) return BadRequest("Please perform a scan on this series or library and try again"); if (dto == null) return BadRequest("Please perform a scan on this series or library and try again");

View file

@ -64,16 +64,9 @@ public class SeriesController : BaseApiController
public async Task<ActionResult<SeriesDto>> GetSeries(int seriesId) public async Task<ActionResult<SeriesDto>> GetSeries(int seriesId)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
try var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
{ if (series == null) return NoContent();
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId)); return Ok(series);
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue fetching {SeriesId}", seriesId);
throw new KavitaException("This series does not exist");
}
} }
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
@ -114,13 +107,16 @@ public class SeriesController : BaseApiController
public async Task<ActionResult<VolumeDto?>> GetVolume(int volumeId) public async Task<ActionResult<VolumeDto?>> GetVolume(int volumeId)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId)); var vol = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId);
if (vol == null) return NoContent();
return Ok(vol);
} }
[HttpGet("chapter")] [HttpGet("chapter")]
public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId) public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId)
{ {
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
if (chapter == null) return NoContent();
return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter)); return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter));
} }

View file

@ -3,10 +3,14 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data;
using API.DTOs.Jobs; using API.DTOs.Jobs;
using API.DTOs.MediaErrors;
using API.DTOs.Stats; using API.DTOs.Stats;
using API.DTOs.Update; using API.DTOs.Update;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Helpers;
using API.Services; using API.Services;
using API.Services.Tasks; using API.Services.Tasks;
using Hangfire; using Hangfire;
@ -14,7 +18,6 @@ using Hangfire.Storage;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler; using TaskScheduler = API.Services.TaskScheduler;
@ -23,7 +26,6 @@ namespace API.Controllers;
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
public class ServerController : BaseApiController public class ServerController : BaseApiController
{ {
private readonly IHostApplicationLifetime _applicationLifetime;
private readonly ILogger<ServerController> _logger; private readonly ILogger<ServerController> _logger;
private readonly IBackupService _backupService; private readonly IBackupService _backupService;
private readonly IArchiveService _archiveService; private readonly IArchiveService _archiveService;
@ -34,13 +36,13 @@ public class ServerController : BaseApiController
private readonly IScannerService _scannerService; private readonly IScannerService _scannerService;
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly ITaskScheduler _taskScheduler; private readonly ITaskScheduler _taskScheduler;
private readonly IUnitOfWork _unitOfWork;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, public ServerController(ILogger<ServerController> logger,
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
ICleanupService cleanupService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService, ICleanupService cleanupService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService,
ITaskScheduler taskScheduler) ITaskScheduler taskScheduler, IUnitOfWork unitOfWork)
{ {
_applicationLifetime = applicationLifetime;
_logger = logger; _logger = logger;
_backupService = backupService; _backupService = backupService;
_archiveService = archiveService; _archiveService = archiveService;
@ -51,6 +53,7 @@ public class ServerController : BaseApiController
_scannerService = scannerService; _scannerService = scannerService;
_accountService = accountService; _accountService = accountService;
_taskScheduler = taskScheduler; _taskScheduler = taskScheduler;
_unitOfWork = unitOfWork;
} }
/// <summary> /// <summary>
@ -117,29 +120,22 @@ public class ServerController : BaseApiController
return Ok(await _statsService.GetServerInfo()); return Ok(await _statsService.GetServerInfo());
} }
/// <summary>
/// Triggers the scheduling of the convert bookmarks job. Only one job will run at a time.
/// </summary>
/// <returns></returns>
[HttpPost("convert-bookmarks")]
public ActionResult ScheduleConvertBookmarks()
{
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToWebP", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) return Ok();
BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllBookmarkToWebP());
return Ok();
}
/// <summary> /// <summary>
/// Triggers the scheduling of the convert covers job. Only one job will run at a time. /// Triggers the scheduling of the convert media job. This will convert all media to the target encoding (except for PNG). Only one job will run at a time.
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[HttpPost("convert-covers")] [HttpPost("convert-media")]
public ActionResult ScheduleConvertCovers() public async Task<ActionResult> ScheduleConvertCovers()
{ {
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(), var encoding = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
TaskScheduler.DefaultQueue, true)) return Ok(); if (encoding == EncodeFormat.PNG)
BackgroundJob.Enqueue(() => _taskScheduler.CovertAllCoversToWebP()); {
return BadRequest(
"You cannot convert to PNG. For covers, use Refresh Covers. Bookmarks and favicons cannot be encoded back.");
}
BackgroundJob.Enqueue(() => _taskScheduler.CovertAllCoversToEncoding());
return Ok(); return Ok();
} }
@ -154,7 +150,8 @@ public class ServerController : BaseApiController
try try
{ {
var zipPath = _archiveService.CreateZipForDownload(files, "logs"); var zipPath = _archiveService.CreateZipForDownload(files, "logs");
return PhysicalFile(zipPath, "application/zip", Path.GetFileName(zipPath), true); return PhysicalFile(zipPath, "application/zip",
System.Web.HttpUtility.UrlEncode(Path.GetFileName(zipPath)), true);
} }
catch (KavitaException ex) catch (KavitaException ex)
{ {
@ -213,5 +210,28 @@ public class ServerController : BaseApiController
return Ok(recurringJobs); return Ok(recurringJobs);
} }
/// <summary>
/// Returns a list of issues found during scanning or reading in which files may have corruption or bad metadata (structural metadata)
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("media-errors")]
public ActionResult<PagedList<MediaErrorDto>> GetMediaErrors()
{
return Ok(_unitOfWork.MediaErrorRepository.GetAllErrorDtosAsync());
}
/// <summary>
/// Deletes all media errors
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpPost("clear-media-alerts")]
public async Task<ActionResult> ClearMediaErrors()
{
await _unitOfWork.MediaErrorRepository.DeleteAll();
return Ok();
}
} }

View file

@ -15,6 +15,7 @@ using API.Services.Tasks.Scanner;
using AutoMapper; using AutoMapper;
using Flurl.Http; using Flurl.Http;
using Kavita.Common; using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Extensions; using Kavita.Common.Extensions;
using Kavita.Common.Helpers; using Kavita.Common.Helpers;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -183,6 +184,7 @@ public class SettingsController : BaseApiController
if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value)
{ {
if (OsInfo.IsDocker) continue;
setting.Value = updateSettingsDto.Port + string.Empty; setting.Value = updateSettingsDto.Port + string.Empty;
// Port is managed in appSetting.json // Port is managed in appSetting.json
Configuration.Port = updateSettingsDto.Port; Configuration.Port = updateSettingsDto.Port;
@ -191,8 +193,9 @@ public class SettingsController : BaseApiController
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
{ {
if (OsInfo.IsDocker) continue;
// Validate IP addresses // Validate IP addresses
foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',')) foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{ {
if (!IPAddress.TryParse(ipAddress.Trim(), out _)) { if (!IPAddress.TryParse(ipAddress.Trim(), out _)) {
return BadRequest($"IP Address '{ipAddress}' is invalid"); return BadRequest($"IP Address '{ipAddress}' is invalid");
@ -231,15 +234,9 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
} }
if (setting.Key == ServerSettingKey.ConvertBookmarkToWebP && updateSettingsDto.ConvertBookmarkToWebP + string.Empty != setting.Value) if (setting.Key == ServerSettingKey.EncodeMediaAs && updateSettingsDto.EncodeMediaAs + string.Empty != setting.Value)
{ {
setting.Value = updateSettingsDto.ConvertBookmarkToWebP + string.Empty; setting.Value = updateSettingsDto.EncodeMediaAs + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.ConvertCoverToWebP && updateSettingsDto.ConvertCoverToWebP + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.ConvertCoverToWebP + string.Empty;
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
} }

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants;
using API.Data; using API.Data;
using API.DTOs.Uploads; using API.DTOs.Uploads;
using API.Extensions; using API.Extensions;
@ -78,7 +79,7 @@ public class UploadController : BaseApiController
/// <param name="uploadFileDto"></param> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("series")] [HttpPost("series")]
public async Task<ActionResult> UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto) public async Task<ActionResult> UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto)
{ {
@ -126,7 +127,7 @@ public class UploadController : BaseApiController
/// <param name="uploadFileDto"></param> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("collection")] [HttpPost("collection")]
public async Task<ActionResult> UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto) public async Task<ActionResult> UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto)
{ {
@ -174,7 +175,7 @@ public class UploadController : BaseApiController
/// <remarks>This is the only API that can be called by non-admins, but the authenticated user must have a readinglist permission</remarks> /// <remarks>This is the only API that can be called by non-admins, but the authenticated user must have a readinglist permission</remarks>
/// <param name="uploadFileDto"></param> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("reading-list")] [HttpPost("reading-list")]
public async Task<ActionResult> UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) public async Task<ActionResult> UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto)
{ {
@ -221,15 +222,15 @@ public class UploadController : BaseApiController
private async Task<string> CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0) private async Task<string> CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0)
{ {
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (thumbnailSize > 0) if (thumbnailSize > 0)
{ {
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
filename, convertToWebP, thumbnailSize); filename, encodeFormat, thumbnailSize);
} }
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
filename, convertToWebP); filename, encodeFormat);
} }
/// <summary> /// <summary>
@ -238,7 +239,7 @@ public class UploadController : BaseApiController
/// <param name="uploadFileDto"></param> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("chapter")] [HttpPost("chapter")]
public async Task<ActionResult> UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto) public async Task<ActionResult> UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto)
{ {
@ -294,7 +295,7 @@ public class UploadController : BaseApiController
/// <param name="uploadFileDto"></param> /// <param name="uploadFileDto"></param>
/// <returns></returns> /// <returns></returns>
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("library")] [HttpPost("library")]
public async Task<ActionResult> UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto) public async Task<ActionResult> UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto)
{ {

View file

@ -33,6 +33,9 @@ public class UsersController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username);
_unitOfWork.UserRepository.Delete(user); _unitOfWork.UserRepository.Delete(user);
//(TODO: After updating a role or removing a user, delete their token)
// await _userManager.RemoveAuthenticationTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName);
if (await _unitOfWork.CommitAsync()) return Ok(); if (await _unitOfWork.CommitAsync()) return Ok();
return BadRequest("Could not delete the user."); return BadRequest("Could not delete the user.");

View file

@ -93,4 +93,13 @@ public class ChapterDto : IHasReadTimeEstimate, IEntityDate
public int MaxHoursToRead { get; set; } public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/> /// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; } public int AvgHoursToRead { get; set; }
/// <summary>
/// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary>
public string WebLinks { get; set; }
/// <summary>
/// ISBN-13 (usually) of the Chapter
/// </summary>
/// <remarks>This is guaranteed to be Valid</remarks>
public string ISBN { get; set; }
} }

View file

@ -0,0 +1,25 @@
using System;
namespace API.DTOs.MediaErrors;
public class MediaErrorDto
{
/// <summary>
/// Format Type (RAR, ZIP, 7Zip, Epub, PDF)
/// </summary>
public required string Extension { get; set; }
/// <summary>
/// Full Filepath to the file that has some issue
/// </summary>
public required string FilePath { get; set; }
/// <summary>
/// Developer defined string
/// </summary>
public string Comment { get; set; }
/// <summary>
/// Exception message
/// </summary>
public string Details { get; set; }
public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; }
}

View file

@ -58,6 +58,10 @@ public class SeriesMetadataDto
/// Publication status of the Series /// Publication status of the Series
/// </summary> /// </summary>
public PublicationStatus PublicationStatus { get; set; } public PublicationStatus PublicationStatus { get; set; }
/// <summary>
/// A comma-separated list of Urls
/// </summary>
public string WebLinks { get; set; }
public bool LanguageLocked { get; set; } public bool LanguageLocked { get; set; }
public bool SummaryLocked { get; set; } public bool SummaryLocked { get; set; }

View file

@ -1,9 +1,11 @@
using API.Services; using API.Entities.Enums;
using API.Services;
namespace API.DTOs.Settings; namespace API.DTOs.Settings;
public class ServerSettingDto public class ServerSettingDto
{ {
public string CacheDirectory { get; set; } = default!; public string CacheDirectory { get; set; } = default!;
public string TaskScan { get; set; } = default!; public string TaskScan { get; set; } = default!;
/// <summary> /// <summary>
@ -47,9 +49,11 @@ public class ServerSettingDto
/// </summary> /// </summary>
public string InstallId { get; set; } = default!; public string InstallId { get; set; } = default!;
/// <summary> /// <summary>
/// If the server should save bookmarks as WebP encoding /// The format that should be used when saving media for Kavita
/// </summary> /// </summary>
public bool ConvertBookmarkToWebP { get; set; } /// <example>This includes things like: Covers, Bookmarks, Favicons</example>
public EncodeFormat EncodeMediaAs { get; set; }
/// <summary> /// <summary>
/// The amount of Backups before cleanup /// The amount of Backups before cleanup
/// </summary> /// </summary>
@ -65,10 +69,6 @@ public class ServerSettingDto
/// <remarks>Value should be between 1 and 30</remarks> /// <remarks>Value should be between 1 and 30</remarks>
public int TotalLogs { get; set; } public int TotalLogs { get; set; }
/// <summary> /// <summary>
/// If the server should save covers as WebP encoding
/// </summary>
public bool ConvertCoverToWebP { get; set; }
/// <summary>
/// The Host name (ie Reverse proxy domain name) for the server /// The Host name (ie Reverse proxy domain name) for the server
/// </summary> /// </summary>
public string HostName { get; set; } public string HostName { get; set; }

View file

@ -85,11 +85,6 @@ public class ServerInfoDto
/// <remarks>Introduced in v0.5.4</remarks> /// <remarks>Introduced in v0.5.4</remarks>
public int TotalPeople { get; set; } public int TotalPeople { get; set; }
/// <summary> /// <summary>
/// Is this instance storing bookmarks as WebP
/// </summary>
/// <remarks>Introduced in v0.5.4</remarks>
public bool StoreBookmarksAsWebP { get; set; }
/// <summary>
/// Number of users on this instance using Card Layout /// Number of users on this instance using Card Layout
/// </summary> /// </summary>
/// <remarks>Introduced in v0.5.4</remarks> /// <remarks>Introduced in v0.5.4</remarks>
@ -175,8 +170,8 @@ public class ServerInfoDto
/// <remarks>Introduced in v0.7.0</remarks> /// <remarks>Introduced in v0.7.0</remarks>
public long TotalReadingHours { get; set; } public long TotalReadingHours { get; set; }
/// <summary> /// <summary>
/// Is the Server saving covers as WebP /// The encoding the server is using to save media
/// </summary> /// </summary>
/// <remarks>Added in v0.7.0</remarks> /// <remarks>Added in v0.7.3</remarks>
public bool StoreCoversAsWebP { get; set; } public EncodeFormat EncodeMediaAs { get; set; }
} }

View file

@ -47,6 +47,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<FolderPath> FolderPath { get; set; } = null!; public DbSet<FolderPath> FolderPath { get; set; } = null!;
public DbSet<Device> Device { get; set; } = null!; public DbSet<Device> Device { get; set; } = null!;
public DbSet<ServerStatistics> ServerStatistics { get; set; } = null!; public DbSet<ServerStatistics> ServerStatistics { get; set; } = null!;
public DbSet<MediaError> MediaError { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@ -113,6 +114,17 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<Library>() builder.Entity<Library>()
.Property(b => b.ManageReadingLists) .Property(b => b.ManageReadingLists)
.HasDefaultValue(true); .HasDefaultValue(true);
builder.Entity<Chapter>()
.Property(b => b.WebLinks)
.HasDefaultValue(string.Empty);
builder.Entity<SeriesMetadata>()
.Property(b => b.WebLinks)
.HasDefaultValue(string.Empty);
builder.Entity<Chapter>()
.Property(b => b.ISBN)
.HasDefaultValue(string.Empty);
} }

View file

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// v0.7 introduced UTC dates and GMT+1 users would sometimes have dates stored as '0000-12-31 23:00:00'. /// v0.7 introduced UTC dates and GMT+1 users would sometimes have dates stored as '0000-12-31 23:00:00'.

View file

@ -3,7 +3,7 @@ using API.Constants;
using API.Entities; using API.Entities;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// New role introduced in v0.5.1. Adds the role to all users. /// New role introduced in v0.5.1. Adds the role to all users.

View file

@ -4,7 +4,7 @@ using API.Entities;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// New role introduced in v0.6. Adds the role to all users. /// New role introduced in v0.6. Adds the role to all users.

View file

@ -4,7 +4,7 @@ using API.Entities;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// Added in v0.7.1.18 /// Added in v0.7.1.18

View file

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// v0.6.0 introduced a change in how Normalization works and hence every normalized field needs to be re-calculated /// v0.6.0 introduced a change in how Normalization works and hence every normalized field needs to be re-calculated

View file

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// v0.5.6 introduced Normalized Localized Name, which allows for faster lookups and less memory usage. This migration will calculate them once /// v0.5.6 introduced Normalized Localized Name, which allows for faster lookups and less memory usage. This migration will calculate them once

View file

@ -4,7 +4,7 @@ using API.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// New role introduced in v0.6. Calculates the Age Rating on all Reading Lists /// New role introduced in v0.6. Calculates the Age Rating on all Reading Lists

View file

@ -3,7 +3,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Services.Tasks; using API.Services.Tasks;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// In v0.5.3, we removed Light and E-Ink themes. This migration will remove the themes from the DB and default anyone on /// In v0.5.3, we removed Light and E-Ink themes. This migration will remove the themes from the DB and default anyone on

View file

@ -0,0 +1,31 @@
using System.Threading.Tasks;
using API.Entities.Enums;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// Added in v0.7.2.7/v0.7.3 in which the ConvertXToWebP Setting keys were removed. This migration will remove them.
/// </summary>
public static class MigrateRemoveWebPSettingRows
{
public static async Task Migrate(IUnitOfWork unitOfWork, ILogger<Program> logger)
{
logger.LogCritical("Running MigrateRemoveWebPSettingRows migration - Please be patient, this may take some time. This is not an error");
var key = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.ConvertBookmarkToWebP);
var key2 = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.ConvertCoverToWebP);
if (key == null && key2 == null)
{
logger.LogCritical("Running MigrateRemoveWebPSettingRows migration - complete. Nothing to do");
return;
}
unitOfWork.SettingsRepository.Remove(key);
unitOfWork.SettingsRepository.Remove(key2);
await unitOfWork.CommitAsync();
logger.LogCritical("Running MigrateRemoveWebPSettingRows migration - Completed. This is not an error");
}
}

View file

@ -10,7 +10,7 @@ using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
internal sealed class SeriesRelationMigrationOutput internal sealed class SeriesRelationMigrationOutput
{ {

View file

@ -8,7 +8,7 @@ using CsvHelper;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// Introduced in v0.6.1.2 and v0.7, this imports to a temp file the existing series relationships. It is a 3 part migration. /// Introduced in v0.6.1.2 and v0.7, this imports to a temp file the existing series relationships. It is a 3 part migration.

View file

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// Introduced in v0.6.1.38 or v0.7.0, /// Introduced in v0.6.1.38 or v0.7.0,

View file

@ -1,7 +1,7 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// Introduced in v0.6.1.8 and v0.7, this adds library ids to all User Progress to allow for easier queries against progress /// Introduced in v0.6.1.8 and v0.7, this adds library ids to all User Progress to allow for easier queries against progress

View file

@ -2,7 +2,9 @@
using System.Linq; using System.Linq;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Services;
using Kavita.Common.Extensions; using Kavita.Common.Extensions;
using Nager.ArticleNumber;
namespace API.Data.Metadata; namespace API.Data.Metadata;
@ -35,9 +37,21 @@ public class ComicInfo
/// IETF BCP 47 Code to represent the language of the content /// IETF BCP 47 Code to represent the language of the content
/// </summary> /// </summary>
public string LanguageISO { get; set; } = string.Empty; public string LanguageISO { get; set; } = string.Empty;
// ReSharper disable once InconsistentNaming
/// <summary>
/// ISBN for the underlying document
/// </summary>
/// <remarks>ComicInfo.xml will actually output a GTIN (Global Trade Item Number) and it is the responsibility of the Parser to extract the ISBN. EPub will return ISBN.</remarks>
public string Isbn { get; set; } = string.Empty;
/// <summary>
/// This is only for deserialization and used within <see cref="ArchiveService"/>. Use <see cref="Isbn"/> for the actual value.
/// </summary>
public string GTIN { get; set; } = string.Empty;
/// <summary> /// <summary>
/// This is the link to where the data was scraped from /// This is the link to where the data was scraped from
/// </summary> /// </summary>
/// <remarks>This can be comma-separated</remarks>
public string Web { get; set; } = string.Empty; public string Web { get; set; } = string.Empty;
[System.ComponentModel.DefaultValueAttribute(0)] [System.ComponentModel.DefaultValueAttribute(0)]
public int Day { get; set; } = 0; public int Day { get; set; } = 0;
@ -137,6 +151,23 @@ public class ComicInfo
info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters); info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters);
info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator); info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator);
info.CoverArtist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.CoverArtist); info.CoverArtist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.CoverArtist);
// We need to convert GTIN to ISBN
if (!string.IsNullOrEmpty(info.GTIN))
{
// This is likely a valid ISBN
if (info.GTIN[0] == '0')
{
var potentialISBN = info.GTIN.Substring(1, info.GTIN.Length - 1);
if (ArticleNumberHelper.IsValidIsbn13(potentialISBN))
{
info.Isbn = potentialISBN;
}
} else if (ArticleNumberHelper.IsValidIsbn10(info.GTIN) || ArticleNumberHelper.IsValidIsbn13(info.GTIN))
{
info.Isbn = info.GTIN;
}
}
} }
/// <summary> /// <summary>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class MediaError : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MediaError",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Extension = table.Column<string>(type: "TEXT", nullable: true),
FilePath = table.Column<string>(type: "TEXT", nullable: true),
Comment = table.Column<string>(type: "TEXT", nullable: true),
Details = table.Column<string>(type: "TEXT", nullable: true),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MediaError", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MediaError");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class WebLinksForChapter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "WebLinks",
table: "Chapter",
type: "TEXT",
nullable: true,
defaultValue: string.Empty);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "WebLinks",
table: "Chapter");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class WebLinksForSeries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "WebLinks",
table: "SeriesMetadata",
type: "TEXT",
nullable: true,
defaultValue: string.Empty);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "WebLinks",
table: "SeriesMetadata");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ChapterISBN : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ISBN",
table: "Chapter",
type: "TEXT",
nullable: true,
defaultValue: string.Empty);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ISBN",
table: "Chapter");
}
}
}

View file

@ -177,7 +177,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark"); b.ToTable("AppUserBookmark", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserPreferences", b => modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
@ -282,7 +282,7 @@ namespace API.Data.Migrations
b.HasIndex("ThemeId"); b.HasIndex("ThemeId");
b.ToTable("AppUserPreferences"); b.ToTable("AppUserPreferences", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserProgress", b => modelBuilder.Entity("API.Entities.AppUserProgress", b =>
@ -332,7 +332,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserProgresses"); b.ToTable("AppUserProgresses", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserRating", b => modelBuilder.Entity("API.Entities.AppUserRating", b =>
@ -359,7 +359,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserRating"); b.ToTable("AppUserRating", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserRole", b => modelBuilder.Entity("API.Entities.AppUserRole", b =>
@ -413,6 +413,11 @@ namespace API.Data.Migrations
b.Property<DateTime>("CreatedUtc") b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ISBN")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("IsSpecial") b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -467,6 +472,11 @@ namespace API.Data.Migrations
b.Property<int>("VolumeId") b.Property<int>("VolumeId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("WebLinks")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<long>("WordCount") b.Property<long>("WordCount")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -474,7 +484,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId"); b.HasIndex("VolumeId");
b.ToTable("Chapter"); b.ToTable("Chapter", (string)null);
}); });
modelBuilder.Entity("API.Entities.CollectionTag", b => modelBuilder.Entity("API.Entities.CollectionTag", b =>
@ -509,7 +519,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "Promoted") b.HasIndex("Id", "Promoted")
.IsUnique(); .IsUnique();
b.ToTable("CollectionTag"); b.ToTable("CollectionTag", (string)null);
}); });
modelBuilder.Entity("API.Entities.Device", b => modelBuilder.Entity("API.Entities.Device", b =>
@ -555,7 +565,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("Device"); b.ToTable("Device", (string)null);
}); });
modelBuilder.Entity("API.Entities.FolderPath", b => modelBuilder.Entity("API.Entities.FolderPath", b =>
@ -577,7 +587,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId"); b.HasIndex("LibraryId");
b.ToTable("FolderPath"); b.ToTable("FolderPath", (string)null);
}); });
modelBuilder.Entity("API.Entities.Genre", b => modelBuilder.Entity("API.Entities.Genre", b =>
@ -597,7 +607,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle") b.HasIndex("NormalizedTitle")
.IsUnique(); .IsUnique();
b.ToTable("Genre"); b.ToTable("Genre", (string)null);
}); });
modelBuilder.Entity("API.Entities.Library", b => modelBuilder.Entity("API.Entities.Library", b =>
@ -662,7 +672,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Library"); b.ToTable("Library", (string)null);
}); });
modelBuilder.Entity("API.Entities.MangaFile", b => modelBuilder.Entity("API.Entities.MangaFile", b =>
@ -711,7 +721,42 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId"); b.HasIndex("ChapterId");
b.ToTable("MangaFile"); b.ToTable("MangaFile", (string)null);
});
modelBuilder.Entity("API.Entities.MediaError", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Comment")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("Details")
.HasColumnType("TEXT");
b.Property<string>("Extension")
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("MediaError", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
@ -796,6 +841,11 @@ namespace API.Data.Migrations
b.Property<bool>("TranslatorLocked") b.Property<bool>("TranslatorLocked")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("WebLinks")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WriterLocked") b.Property<bool>("WriterLocked")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -807,7 +857,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "SeriesId") b.HasIndex("Id", "SeriesId")
.IsUnique(); .IsUnique();
b.ToTable("SeriesMetadata"); b.ToTable("SeriesMetadata", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
@ -831,7 +881,7 @@ namespace API.Data.Migrations
b.HasIndex("TargetSeriesId"); b.HasIndex("TargetSeriesId");
b.ToTable("SeriesRelation"); b.ToTable("SeriesRelation", (string)null);
}); });
modelBuilder.Entity("API.Entities.Person", b => modelBuilder.Entity("API.Entities.Person", b =>
@ -851,7 +901,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Person"); b.ToTable("Person", (string)null);
}); });
modelBuilder.Entity("API.Entities.ReadingList", b => modelBuilder.Entity("API.Entities.ReadingList", b =>
@ -912,7 +962,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("ReadingList"); b.ToTable("ReadingList", (string)null);
}); });
modelBuilder.Entity("API.Entities.ReadingListItem", b => modelBuilder.Entity("API.Entities.ReadingListItem", b =>
@ -946,7 +996,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId"); b.HasIndex("VolumeId");
b.ToTable("ReadingListItem"); b.ToTable("ReadingListItem", (string)null);
}); });
modelBuilder.Entity("API.Entities.Series", b => modelBuilder.Entity("API.Entities.Series", b =>
@ -1045,7 +1095,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId"); b.HasIndex("LibraryId");
b.ToTable("Series"); b.ToTable("Series", (string)null);
}); });
modelBuilder.Entity("API.Entities.ServerSetting", b => modelBuilder.Entity("API.Entities.ServerSetting", b =>
@ -1062,7 +1112,7 @@ namespace API.Data.Migrations
b.HasKey("Key"); b.HasKey("Key");
b.ToTable("ServerSetting"); b.ToTable("ServerSetting", (string)null);
}); });
modelBuilder.Entity("API.Entities.ServerStatistics", b => modelBuilder.Entity("API.Entities.ServerStatistics", b =>
@ -1100,7 +1150,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("ServerStatistics"); b.ToTable("ServerStatistics", (string)null);
}); });
modelBuilder.Entity("API.Entities.SiteTheme", b => modelBuilder.Entity("API.Entities.SiteTheme", b =>
@ -1138,7 +1188,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("SiteTheme"); b.ToTable("SiteTheme", (string)null);
}); });
modelBuilder.Entity("API.Entities.Tag", b => modelBuilder.Entity("API.Entities.Tag", b =>
@ -1158,7 +1208,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle") b.HasIndex("NormalizedTitle")
.IsUnique(); .IsUnique();
b.ToTable("Tag"); b.ToTable("Tag", (string)null);
}); });
modelBuilder.Entity("API.Entities.Volume", b => modelBuilder.Entity("API.Entities.Volume", b =>
@ -1210,7 +1260,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("Volume"); b.ToTable("Volume", (string)null);
}); });
modelBuilder.Entity("AppUserLibrary", b => modelBuilder.Entity("AppUserLibrary", b =>
@ -1225,7 +1275,7 @@ namespace API.Data.Migrations
b.HasIndex("LibrariesId"); b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary"); b.ToTable("AppUserLibrary", (string)null);
}); });
modelBuilder.Entity("ChapterGenre", b => modelBuilder.Entity("ChapterGenre", b =>
@ -1240,7 +1290,7 @@ namespace API.Data.Migrations
b.HasIndex("GenresId"); b.HasIndex("GenresId");
b.ToTable("ChapterGenre"); b.ToTable("ChapterGenre", (string)null);
}); });
modelBuilder.Entity("ChapterPerson", b => modelBuilder.Entity("ChapterPerson", b =>
@ -1255,7 +1305,7 @@ namespace API.Data.Migrations
b.HasIndex("PeopleId"); b.HasIndex("PeopleId");
b.ToTable("ChapterPerson"); b.ToTable("ChapterPerson", (string)null);
}); });
modelBuilder.Entity("ChapterTag", b => modelBuilder.Entity("ChapterTag", b =>
@ -1270,7 +1320,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId"); b.HasIndex("TagsId");
b.ToTable("ChapterTag"); b.ToTable("ChapterTag", (string)null);
}); });
modelBuilder.Entity("CollectionTagSeriesMetadata", b => modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
@ -1285,7 +1335,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId"); b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata"); b.ToTable("CollectionTagSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("GenreSeriesMetadata", b => modelBuilder.Entity("GenreSeriesMetadata", b =>
@ -1300,7 +1350,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId"); b.HasIndex("SeriesMetadatasId");
b.ToTable("GenreSeriesMetadata"); b.ToTable("GenreSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
@ -1399,7 +1449,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId"); b.HasIndex("SeriesMetadatasId");
b.ToTable("PersonSeriesMetadata"); b.ToTable("PersonSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("SeriesMetadataTag", b => modelBuilder.Entity("SeriesMetadataTag", b =>
@ -1414,7 +1464,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId"); b.HasIndex("TagsId");
b.ToTable("SeriesMetadataTag"); b.ToTable("SeriesMetadataTag", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserBookmark", b => modelBuilder.Entity("API.Entities.AppUserBookmark", b =>

View file

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data.ManualMigrations;
using API.DTOs; using API.DTOs;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -25,6 +26,7 @@ public interface IAppUserProgressRepository
Task<IEnumerable<AppUserProgress>> GetUserProgressForSeriesAsync(int seriesId, int userId); Task<IEnumerable<AppUserProgress>> GetUserProgressForSeriesAsync(int seriesId, int userId);
Task<IEnumerable<AppUserProgress>> GetAllProgress(); Task<IEnumerable<AppUserProgress>> GetAllProgress();
Task<ProgressDto> GetUserProgressDtoAsync(int chapterId, int userId); Task<ProgressDto> GetUserProgressDtoAsync(int chapterId, int userId);
Task<bool> AnyUserProgressForSeriesAsync(int seriesId, int userId);
} }
public class AppUserProgressRepository : IAppUserProgressRepository public class AppUserProgressRepository : IAppUserProgressRepository
@ -128,6 +130,13 @@ public class AppUserProgressRepository : IAppUserProgressRepository
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public async Task<bool> AnyUserProgressForSeriesAsync(int seriesId, int userId)
{
return await _context.AppUserProgresses
.Where(p => p.SeriesId == seriesId && p.AppUserId == userId && p.PagesRead > 0)
.AnyAsync();
}
public async Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId) public async Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId)
{ {
return await _context.AppUserProgresses return await _context.AppUserProgresses

View file

@ -6,6 +6,7 @@ using API.DTOs;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using AutoMapper; using AutoMapper;
@ -36,7 +37,7 @@ public interface IChapterRepository
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds); Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
Task<string?> GetChapterCoverImageAsync(int chapterId); Task<string?> GetChapterCoverImageAsync(int chapterId);
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<IList<Chapter>> GetAllChaptersWithNonWebPCovers(); Task<IList<Chapter>> GetAllChaptersWithCoversInDifferentEncoding(EncodeFormat format);
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync(); Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter); Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter);
} }
@ -208,10 +209,11 @@ public class ChapterRepository : IChapterRepository
.ToListAsync())!; .ToListAsync())!;
} }
public async Task<IList<Chapter>> GetAllChaptersWithNonWebPCovers() public async Task<IList<Chapter>> GetAllChaptersWithCoversInDifferentEncoding(EncodeFormat format)
{ {
var extension = format.GetExtension();
return await _context.Chapter return await _context.Chapter
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }

View file

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using API.Data.Misc; using API.Data.Misc;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using AutoMapper; using AutoMapper;
@ -34,7 +35,7 @@ public interface ICollectionTagRepository
Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None); Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None);
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> TagExists(string title); Task<bool> TagExists(string title);
Task<IList<CollectionTag>> GetAllWithNonWebPCovers(); Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
} }
public class CollectionTagRepository : ICollectionTagRepository public class CollectionTagRepository : ICollectionTagRepository
{ {
@ -108,10 +109,11 @@ public class CollectionTagRepository : ICollectionTagRepository
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
} }
public async Task<IList<CollectionTag>> GetAllWithNonWebPCovers() public async Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension();
return await _context.CollectionTag return await _context.CollectionTag
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }

View file

@ -52,7 +52,7 @@ public interface ILibraryRepository
Task<string?> GetLibraryCoverImageAsync(int libraryId); Task<string?> GetLibraryCoverImageAsync(int libraryId);
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds); Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds);
Task<IList<Library>> GetAllWithNonWebPCovers(); Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
} }
public class LibraryRepository : ILibraryRepository public class LibraryRepository : ILibraryRepository
@ -170,10 +170,7 @@ public class LibraryRepository : ILibraryRepository
var c = sortChar; var c = sortChar;
var isAlpha = char.IsLetter(sortChar); var isAlpha = char.IsLetter(sortChar);
if (!isAlpha) c = '#'; if (!isAlpha) c = '#';
if (!firstCharacterMap.ContainsKey(c)) firstCharacterMap.TryAdd(c, 0);
{
firstCharacterMap[c] = 0;
}
firstCharacterMap[c] += 1; firstCharacterMap[c] += 1;
} }
@ -371,10 +368,11 @@ public class LibraryRepository : ILibraryRepository
return dict; return dict;
} }
public async Task<IList<Library>> GetAllWithNonWebPCovers() public async Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension();
return await _context.Library return await _context.Library
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }
} }

View file

@ -0,0 +1,83 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.MediaErrors;
using API.Entities;
using API.Helpers;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IMediaErrorRepository
{
void Attach(MediaError error);
void Remove(MediaError error);
Task<MediaError> Find(string filename);
Task<PagedList<MediaErrorDto>> GetAllErrorDtosAsync(UserParams userParams);
IEnumerable<MediaErrorDto> GetAllErrorDtosAsync();
Task<bool> ExistsAsync(MediaError error);
Task DeleteAll();
}
public class MediaErrorRepository : IMediaErrorRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public MediaErrorRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Attach(MediaError? error)
{
if (error == null) return;
_context.MediaError.Attach(error);
}
public void Remove(MediaError? error)
{
if (error == null) return;
_context.MediaError.Remove(error);
}
public Task<MediaError?> Find(string filename)
{
return _context.MediaError.Where(e => e.FilePath == filename).SingleOrDefaultAsync();
}
public Task<PagedList<MediaErrorDto>> GetAllErrorDtosAsync(UserParams userParams)
{
var query = _context.MediaError
.OrderByDescending(m => m.Created)
.ProjectTo<MediaErrorDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
return PagedList<MediaErrorDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public IEnumerable<MediaErrorDto> GetAllErrorDtosAsync()
{
var query = _context.MediaError
.OrderByDescending(m => m.Created)
.ProjectTo<MediaErrorDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
return query.AsEnumerable();
}
public Task<bool> ExistsAsync(MediaError error)
{
return _context.MediaError.AnyAsync(m => m.FilePath.Equals(error.FilePath)
&& m.Comment.Equals(error.Comment)
&& m.Details.Equals(error.Details)
);
}
public async Task DeleteAll()
{
_context.MediaError.RemoveRange(await _context.MediaError.ToListAsync());
await _context.SaveChangesAsync();
}
}

View file

@ -45,7 +45,7 @@ public interface IReadingListRepository
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> ReadingListExists(string name); Task<bool> ReadingListExists(string name);
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId); IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId);
Task<IList<ReadingList>> GetAllWithNonWebPCovers(); Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<IList<string>> GetFirstFourCoverImagesByReadingListId(int readingListId); Task<IList<string>> GetFirstFourCoverImagesByReadingListId(int readingListId);
Task<int> RemoveReadingListsWithoutSeries(); Task<int> RemoveReadingListsWithoutSeries();
Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items);
@ -110,10 +110,11 @@ public class ReadingListRepository : IReadingListRepository
.AsEnumerable(); .AsEnumerable();
} }
public async Task<IList<ReadingList>> GetAllWithNonWebPCovers() public async Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension();
return await _context.ReadingList return await _context.ReadingList
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }

View file

@ -4,6 +4,7 @@ using System.Drawing;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data.ManualMigrations;
using API.Data.Misc; using API.Data.Misc;
using API.Data.Scanner; using API.Data.Scanner;
using API.DTOs; using API.DTOs;
@ -79,7 +80,7 @@ public interface ISeriesRepository
/// <returns></returns> /// <returns></returns>
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery); Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery);
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None); Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None);
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId); Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId);
Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata); Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds); Task<IList<Series>> GetSeriesByIdsAsync(IList<int> seriesIds);
Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds); Task<int[]> GetChapterIdsForSeriesAsync(IList<int> seriesIds);
@ -132,7 +133,7 @@ public interface ISeriesRepository
Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync(); Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds); Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds);
Task<IList<Series>> GetAllWithNonWebPCovers(bool customOnly = true); Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat, bool customOnly = true);
} }
public class SeriesRepository : ISeriesRepository public class SeriesRepository : ISeriesRepository
@ -347,11 +348,11 @@ public class SeriesRepository : ISeriesRepository
result.Series = _context.Series result.Series = _context.Series
.Where(s => libraryIds.Contains(s.LibraryId)) .Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => (EF.Functions.Like(s.Name, $"%{searchQuery}%") .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|| (s.OriginalName != null && EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")) || (s.OriginalName != null && EF.Functions.Like(s.OriginalName, $"%{searchQuery}%"))
|| (s.LocalizedName != null && EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")) || (s.LocalizedName != null && EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
|| (EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%")) || (EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%"))
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))) || (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
.RestrictAgainstAgeRestriction(userRating) .RestrictAgainstAgeRestriction(userRating)
.Include(s => s.Library) .Include(s => s.Library)
.OrderBy(s => s.SortName!.ToLower()) .OrderBy(s => s.SortName!.ToLower())
@ -429,7 +430,9 @@ public class SeriesRepository : ISeriesRepository
result.Chapters = await _context.Chapter result.Chapters = await _context.Chapter
.Include(c => c.Files) .Include(c => c.Files)
.Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%")) .Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%")
|| EF.Functions.Like(c.ISBN, $"%{searchQuery}%")
)
.Where(c => c.Files.All(f => fileIds.Contains(f.Id))) .Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
.AsSplitQuery() .AsSplitQuery()
.Take(maxRecords) .Take(maxRecords)
@ -439,11 +442,13 @@ public class SeriesRepository : ISeriesRepository
return result; return result;
} }
public async Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId) public async Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId)
{ {
var series = await _context.Series.Where(x => x.Id == seriesId) var series = await _context.Series.Where(x => x.Id == seriesId)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.SingleAsync(); .SingleOrDefaultAsync();
if (series == null) return null;
var seriesList = new List<SeriesDto>() {series}; var seriesList = new List<SeriesDto>() {series};
await AddSeriesModifiers(userId, seriesList); await AddSeriesModifiers(userId, seriesList);
@ -565,12 +570,14 @@ public class SeriesRepository : ISeriesRepository
/// Returns custom images only /// Returns custom images only
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public async Task<IList<Series>> GetAllWithNonWebPCovers(bool customOnly = true) public async Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat,
bool customOnly = true)
{ {
var extension = encodeFormat.GetExtension();
var prefix = ImageService.GetSeriesFormat(0).Replace("0", string.Empty); var prefix = ImageService.GetSeriesFormat(0).Replace("0", string.Empty);
return await _context.Series return await _context.Series
.Where(c => !string.IsNullOrEmpty(c.CoverImage) .Where(c => !string.IsNullOrEmpty(c.CoverImage)
&& !c.CoverImage.EndsWith(".webp") && !c.CoverImage.EndsWith(extension)
&& (!customOnly || c.CoverImage.StartsWith(prefix))) && (!customOnly || c.CoverImage.StartsWith(prefix)))
.ToListAsync(); .ToListAsync();
} }

View file

@ -15,6 +15,7 @@ public interface ISettingsRepository
Task<ServerSettingDto> GetSettingsDtoAsync(); Task<ServerSettingDto> GetSettingsDtoAsync();
Task<ServerSetting> GetSettingAsync(ServerSettingKey key); Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
Task<IEnumerable<ServerSetting>> GetSettingsAsync(); Task<IEnumerable<ServerSetting>> GetSettingsAsync();
void Remove(ServerSetting setting);
} }
public class SettingsRepository : ISettingsRepository public class SettingsRepository : ISettingsRepository
{ {
@ -32,6 +33,11 @@ public class SettingsRepository : ISettingsRepository
_context.Entry(settings).State = EntityState.Modified; _context.Entry(settings).State = EntityState.Modified;
} }
public void Remove(ServerSetting setting)
{
_context.Remove(setting);
}
public async Task<ServerSettingDto> GetSettingsDtoAsync() public async Task<ServerSettingDto> GetSettingsDtoAsync()
{ {
var settings = await _context.ServerSetting var settings = await _context.ServerSetting

View file

@ -4,10 +4,12 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs; using API.DTOs;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Services; using API.Services;
using AutoMapper; using AutoMapper;
using AutoMapper.QueryableExtensions; using AutoMapper.QueryableExtensions;
using Kavita.Common;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories; namespace API.Data.Repositories;
@ -26,7 +28,7 @@ public interface IVolumeRepository
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false); Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
Task<IEnumerable<Volume>> GetVolumes(int seriesId); Task<IEnumerable<Volume>> GetVolumes(int seriesId);
Task<Volume?> GetVolumeByIdAsync(int volumeId); Task<Volume?> GetVolumeByIdAsync(int volumeId);
Task<IList<Volume>> GetAllWithNonWebPCovers(); Task<IList<Volume>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
} }
public class VolumeRepository : IVolumeRepository public class VolumeRepository : IVolumeRepository
{ {
@ -200,10 +202,11 @@ public class VolumeRepository : IVolumeRepository
return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId);
} }
public async Task<IList<Volume>> GetAllWithNonWebPCovers() public async Task<IList<Volume>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension();
return await _context.Volume return await _context.Volume
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }

View file

@ -101,12 +101,11 @@ public static class Seed
new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()},
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl},
new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"},
new() {Key = ServerSettingKey.TotalBackups, Value = "30"}, new() {Key = ServerSettingKey.TotalBackups, Value = "30"},
new() {Key = ServerSettingKey.TotalLogs, Value = "30"}, new() {Key = ServerSettingKey.TotalLogs, Value = "30"},
new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"}, new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"},
new() {Key = ServerSettingKey.ConvertCoverToWebP, Value = "false"},
new() {Key = ServerSettingKey.HostName, Value = string.Empty}, new() {Key = ServerSettingKey.HostName, Value = string.Empty},
new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()},
}.ToArray()); }.ToArray());
foreach (var defaultSetting in DefaultSettings) foreach (var defaultSetting in DefaultSettings)

View file

@ -25,6 +25,7 @@ public interface IUnitOfWork
ISiteThemeRepository SiteThemeRepository { get; } ISiteThemeRepository SiteThemeRepository { get; }
IMangaFileRepository MangaFileRepository { get; } IMangaFileRepository MangaFileRepository { get; }
IDeviceRepository DeviceRepository { get; } IDeviceRepository DeviceRepository { get; }
IMediaErrorRepository MediaErrorRepository { get; }
bool Commit(); bool Commit();
Task<bool> CommitAsync(); Task<bool> CommitAsync();
bool HasChanges(); bool HasChanges();
@ -62,6 +63,7 @@ public class UnitOfWork : IUnitOfWork
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper); public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context); public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context);
public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper); public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper);
public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper);
/// <summary> /// <summary>
/// Commits changes to the DB. Completes the open transaction. /// Commits changes to the DB. Completes the open transaction.

View file

@ -100,7 +100,11 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
public int MaxHoursToRead { get; set; } public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate"/> /// <inheritdoc cref="IHasReadTimeEstimate"/>
public int AvgHoursToRead { get; set; } public int AvgHoursToRead { get; set; }
/// <summary>
/// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary>
public string WebLinks { get; set; } = string.Empty;
public string ISBN { get; set; } = string.Empty;
/// <summary> /// <summary>
/// All people attached at a Chapter level. Usually Comics will have different people per issue. /// All people attached at a Chapter level. Usually Comics will have different people per issue.
@ -115,7 +119,6 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
public ICollection<AppUserProgress> UserProgress { get; set; } public ICollection<AppUserProgress> UserProgress { get; set; }
// Relationships // Relationships
public Volume Volume { get; set; } = null!; public Volume Volume { get; set; } = null!;
public int VolumeId { get; set; } public int VolumeId { get; set; }

View file

@ -0,0 +1,13 @@
using System.ComponentModel;
namespace API.Entities.Enums;
public enum EncodeFormat
{
[Description("PNG")]
PNG = 0,
[Description("WebP")]
WEBP = 1,
[Description("AVIF")]
AVIF = 2
}

View file

@ -1,4 +1,5 @@
using System.ComponentModel; using System;
using System.ComponentModel;
namespace API.Entities.Enums; namespace API.Entities.Enums;
@ -82,6 +83,7 @@ public enum ServerSettingKey
/// <summary> /// <summary>
/// If Kavita should save bookmarks as WebP images /// If Kavita should save bookmarks as WebP images
/// </summary> /// </summary>
[Obsolete("Use EncodeMediaAs instead")]
[Description("ConvertBookmarkToWebP")] [Description("ConvertBookmarkToWebP")]
ConvertBookmarkToWebP = 14, ConvertBookmarkToWebP = 14,
/// <summary> /// <summary>
@ -102,6 +104,7 @@ public enum ServerSettingKey
/// <summary> /// <summary>
/// If Kavita should save covers as WebP images /// If Kavita should save covers as WebP images
/// </summary> /// </summary>
[Obsolete("Use EncodeMediaAs instead")]
[Description("ConvertCoverToWebP")] [Description("ConvertCoverToWebP")]
ConvertCoverToWebP = 19, ConvertCoverToWebP = 19,
/// <summary> /// <summary>
@ -114,4 +117,11 @@ public enum ServerSettingKey
/// </summary> /// </summary>
[Description("IpAddresses")] [Description("IpAddresses")]
IpAddresses = 21, IpAddresses = 21,
/// <summary>
/// Encode all media as PNG/WebP/AVIF/etc.
/// </summary>
/// <remarks>As of v0.7.3 this replaced ConvertCoverToWebP and ConvertBookmarkToWebP</remarks>
[Description("EncodeMediaAs")]
EncodeMediaAs = 22,
} }

View file

@ -0,0 +1,36 @@
using System;
using API.Entities.Interfaces;
namespace API.Entities;
/// <summary>
/// Represents issues found during scanning or interacting with media. For example) Can't open file, corrupt media, missing content in epub.
/// </summary>
public class MediaError : IEntityDate
{
public int Id { get; set; }
/// <summary>
/// Format Type (RAR, ZIP, 7Zip, Epub, PDF)
/// </summary>
public required string Extension { get; set; }
/// <summary>
/// Full Filepath to the file that has some issue
/// </summary>
public required string FilePath { get; set; }
/// <summary>
/// Developer defined string
/// </summary>
public string Comment { get; set; }
/// <summary>
/// Exception message
/// </summary>
public string Details { get; set; }
/// <summary>
/// Was the file imported or not
/// </summary>
//public bool Imported { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Interfaces; using API.Entities.Interfaces;
@ -43,6 +44,11 @@ public class SeriesMetadata : IHasConcurrencyToken
/// </summary> /// </summary>
public int MaxCount { get; set; } = 0; public int MaxCount { get; set; } = 0;
public PublicationStatus PublicationStatus { get; set; } public PublicationStatus PublicationStatus { get; set; }
/// <summary>
/// A Comma-separated list of strings representing links from the series
/// </summary>
/// <remarks>This is not populated from Chapters of the Series</remarks>
public string WebLinks { get; set; } = string.Empty;
// Locks // Locks
public bool LanguageLocked { get; set; } public bool LanguageLocked { get; set; }

View file

@ -1,4 +1,5 @@
using System.IO.Abstractions; using System.IO.Abstractions;
using API.Constants;
using API.Data; using API.Data;
using API.Helpers; using API.Helpers;
using API.Services; using API.Services;
@ -22,9 +23,7 @@ public static class ApplicationServiceExtensions
services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly); services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly);
services.AddScoped<IUnitOfWork, UnitOfWork>(); services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IDirectoryService, DirectoryService>();
services.AddScoped<ITokenService, TokenService>(); services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IFileSystem, FileSystem>();
services.AddScoped<IFileService, FileService>(); services.AddScoped<IFileService, FileService>();
services.AddScoped<ICacheHelper, CacheHelper>(); services.AddScoped<ICacheHelper, CacheHelper>();
@ -35,7 +34,6 @@ public static class ApplicationServiceExtensions
services.AddScoped<IBackupService, BackupService>(); services.AddScoped<IBackupService, BackupService>();
services.AddScoped<ICleanupService, CleanupService>(); services.AddScoped<ICleanupService, CleanupService>();
services.AddScoped<IBookService, BookService>(); services.AddScoped<IBookService, BookService>();
services.AddScoped<IImageService, ImageService>();
services.AddScoped<IVersionUpdaterService, VersionUpdaterService>(); services.AddScoped<IVersionUpdaterService, VersionUpdaterService>();
services.AddScoped<IDownloadService, DownloadService>(); services.AddScoped<IDownloadService, DownloadService>();
services.AddScoped<IReaderService, ReaderService>(); services.AddScoped<IReaderService, ReaderService>();
@ -49,6 +47,8 @@ public static class ApplicationServiceExtensions
services.AddScoped<IReadingListService, ReadingListService>(); services.AddScoped<IReadingListService, ReadingListService>();
services.AddScoped<IDeviceService, DeviceService>(); services.AddScoped<IDeviceService, DeviceService>();
services.AddScoped<IStatisticService, StatisticService>(); services.AddScoped<IStatisticService, StatisticService>();
services.AddScoped<IMediaErrorService, MediaErrorService>();
services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IScannerService, ScannerService>(); services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>(); services.AddScoped<IMetadataService, MetadataService>();
@ -57,11 +57,20 @@ public static class ApplicationServiceExtensions
services.AddScoped<ITachiyomiService, TachiyomiService>(); services.AddScoped<ITachiyomiService, TachiyomiService>();
services.AddScoped<ICollectionTagService, CollectionTagService>(); services.AddScoped<ICollectionTagService, CollectionTagService>();
services.AddScoped<IPresenceTracker, PresenceTracker>(); services.AddScoped<IFileSystem, FileSystem>();
services.AddScoped<IDirectoryService, DirectoryService>();
services.AddScoped<IEventHub, EventHub>(); services.AddScoped<IEventHub, EventHub>();
services.AddScoped<IPresenceTracker, PresenceTracker>();
services.AddScoped<IImageService, ImageService>();
services.AddSqLite(env); services.AddSqLite(env);
services.AddSignalR(opt => opt.EnableDetailedErrors = true); services.AddSignalR(opt => opt.EnableDetailedErrors = true);
services.AddEasyCaching(options =>
{
options.UseInMemory(EasyCacheProfiles.Favicon);
});
} }
private static void AddSqLite(this IServiceCollection services, IHostEnvironment env) private static void AddSqLite(this IServiceCollection services, IHostEnvironment env)

View file

@ -0,0 +1,18 @@
using System;
using API.Entities.Enums;
namespace API.Extensions;
public static class EncodeFormatExtensions
{
public static string GetExtension(this EncodeFormat encodeFormat)
{
return encodeFormat switch
{
EncodeFormat.PNG => ".png",
EncodeFormat.WEBP => ".webp",
EncodeFormat.AVIF => ".avif",
_ => throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null)
};
}
}

View file

@ -4,6 +4,7 @@ using API.DTOs;
using API.DTOs.Account; using API.DTOs.Account;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Device; using API.DTOs.Device;
using API.DTOs.MediaErrors;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;
@ -33,6 +34,7 @@ public class AutoMapperProfiles : Profile
CreateMap<Tag, TagDto>(); CreateMap<Tag, TagDto>();
CreateMap<AgeRating, AgeRatingDto>(); CreateMap<AgeRating, AgeRatingDto>();
CreateMap<PublicationStatus, PublicationStatusDto>(); CreateMap<PublicationStatus, PublicationStatusDto>();
CreateMap<MediaError, MediaErrorDto>();
CreateMap<AppUserProgress, ProgressDto>() CreateMap<AppUserProgress, ProgressDto>()
.ForMember(dest => dest.PageNum, .ForMember(dest => dest.PageNum,

View file

@ -0,0 +1,31 @@
using System.IO;
using API.Entities;
namespace API.Helpers.Builders;
public class MediaErrorBuilder : IEntityBuilder<MediaError>
{
private readonly MediaError _mediaError;
public MediaError Build() => _mediaError;
public MediaErrorBuilder(string filePath)
{
_mediaError = new MediaError()
{
FilePath = filePath,
Extension = Path.GetExtension(filePath).Replace(".", string.Empty).ToUpperInvariant()
};
}
public MediaErrorBuilder WithComment(string comment)
{
_mediaError.Comment = comment.Trim();
return this;
}
public MediaErrorBuilder WithDetails(string details)
{
_mediaError.Details = details.Trim();
return this;
}
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using API.DTOs.Settings; using API.DTOs.Settings;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -51,11 +52,8 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
case ServerSettingKey.InstallVersion: case ServerSettingKey.InstallVersion:
destination.InstallVersion = row.Value; destination.InstallVersion = row.Value;
break; break;
case ServerSettingKey.ConvertBookmarkToWebP: case ServerSettingKey.EncodeMediaAs:
destination.ConvertBookmarkToWebP = bool.Parse(row.Value); destination.EncodeMediaAs = Enum.Parse<EncodeFormat>(row.Value);
break;
case ServerSettingKey.ConvertCoverToWebP:
destination.ConvertCoverToWebP = bool.Parse(row.Value);
break; break;
case ServerSettingKey.TotalBackups: case ServerSettingKey.TotalBackups:
destination.TotalBackups = int.Parse(row.Value); destination.TotalBackups = int.Parse(row.Value);

View file

@ -115,21 +115,21 @@ public static class PersonHelper
/// For a given role and people dtos, update a series /// For a given role and people dtos, update a series
/// </summary> /// </summary>
/// <param name="role"></param> /// <param name="role"></param>
/// <param name="tags"></param> /// <param name="people"></param>
/// <param name="series"></param> /// <param name="series"></param>
/// <param name="allTags"></param> /// <param name="allPeople"></param>
/// <param name="handleAdd">This will call with an existing or new tag, but the method does not update the series Metadata</param> /// <param name="handleAdd">This will call with an existing or new tag, but the method does not update the series Metadata</param>
/// <param name="onModified"></param> /// <param name="onModified"></param>
public static void UpdatePeopleList(PersonRole role, ICollection<PersonDto>? tags, Series series, IReadOnlyCollection<Person> allTags, public static void UpdatePeopleList(PersonRole role, ICollection<PersonDto>? people, Series series, IReadOnlyCollection<Person> allPeople,
Action<Person> handleAdd, Action onModified) Action<Person> handleAdd, Action onModified)
{ {
if (tags == null) return; if (people == null) return;
var isModified = false; var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList(); var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList();
foreach (var existing in existingTags) foreach (var existing in existingTags)
{ {
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role
{ {
// Remove tag // Remove tag
series.Metadata.People.Remove(existing); series.Metadata.People.Remove(existing);
@ -138,9 +138,9 @@ public static class PersonHelper
} }
// At this point, all tags that aren't in dto have been removed. // At this point, all tags that aren't in dto have been removed.
foreach (var tag in tags) foreach (var tag in people)
{ {
var existingTag = allTags.SingleOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role);
if (existingTag != null) if (existingTag != null)
{ {
if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name))) if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name)))

View file

@ -0,0 +1,57 @@
using System.Threading.Tasks;
using API.Constants;
using EasyCaching.Core;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Middleware;
/// <summary>
/// Responsible for maintaining an in-memory. Not in use
/// </summary>
public class JwtRevocationMiddleware
{
private readonly RequestDelegate _next;
private readonly IEasyCachingProviderFactory _cacheFactory;
private readonly ILogger<JwtRevocationMiddleware> _logger;
public JwtRevocationMiddleware(RequestDelegate next, IEasyCachingProviderFactory cacheFactory, ILogger<JwtRevocationMiddleware> logger)
{
_next = next;
_cacheFactory = cacheFactory;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.User.Identity is {IsAuthenticated: false})
{
await _next(context);
return;
}
// Get the JWT from the request headers or wherever you store it
var token = context.Request.Headers["Authorization"].ToString()?.Replace("Bearer ", string.Empty);
// Check if the token is revoked
if (await IsTokenRevoked(token))
{
_logger.LogWarning("Revoked token detected: {Token}", token);
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await _next(context);
}
private async Task<bool> IsTokenRevoked(string token)
{
// Check if the token exists in the revocation list stored in the cache
var isRevoked = await _cacheFactory.GetCachingProvider(EasyCacheProfiles.RevokedJwt)
.GetAsync<string>(token);
return isRevoked.HasValue;
}
}

View file

@ -4,6 +4,7 @@ using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.ManualMigrations;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Logging; using API.Logging;
@ -49,7 +50,7 @@ public class Program
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development) Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development)
{ {
Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions..."); Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions...");
var rBytes = new byte[128]; var rBytes = new byte[256];
RandomNumberGenerator.Create().GetBytes(rBytes); RandomNumberGenerator.Create().GetBytes(rBytes);
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
} }
@ -173,13 +174,13 @@ public class Program
webBuilder.UseKestrel((opts) => webBuilder.UseKestrel((opts) =>
{ {
var ipAddresses = Configuration.IpAddresses; var ipAddresses = Configuration.IpAddresses;
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker || string.IsNullOrEmpty(ipAddresses) || ipAddresses.Equals(Configuration.DefaultIpAddresses)) if (OsInfo.IsDocker || string.IsNullOrEmpty(ipAddresses) || ipAddresses.Equals(Configuration.DefaultIpAddresses))
{ {
opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; }); opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; });
} }
else else
{ {
foreach (var ipAddress in ipAddresses.Split(',')) foreach (var ipAddress in ipAddresses.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{ {
try try
{ {
@ -194,9 +195,6 @@ public class Program
} }
}); });
webBuilder.UseStartup<Startup>(); webBuilder.UseStartup<Startup>();
}); });
} }

View file

@ -4,9 +4,11 @@ using System.Diagnostics;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Xml.Linq;
using System.Xml.Serialization; using System.Xml.Serialization;
using API.Archive; using API.Archive;
using API.Data.Metadata; using API.Data.Metadata;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Services.Tasks; using API.Services.Tasks;
using Kavita.Common; using Kavita.Common;
@ -20,7 +22,7 @@ public interface IArchiveService
{ {
void ExtractArchive(string archivePath, string extractPath); void ExtractArchive(string archivePath, string extractPath);
int GetNumberOfPagesFromArchive(string archivePath); int GetNumberOfPagesFromArchive(string archivePath);
string GetCoverImage(string archivePath, string fileName, string outputDirectory, bool saveAsWebP = false); string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format);
bool IsValidArchive(string archivePath); bool IsValidArchive(string archivePath);
ComicInfo? GetComicInfo(string archivePath); ComicInfo? GetComicInfo(string archivePath);
ArchiveLibrary CanOpen(string archivePath); ArchiveLibrary CanOpen(string archivePath);
@ -44,13 +46,16 @@ public class ArchiveService : IArchiveService
private readonly ILogger<ArchiveService> _logger; private readonly ILogger<ArchiveService> _logger;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService; private readonly IImageService _imageService;
private readonly IMediaErrorService _mediaErrorService;
private const string ComicInfoFilename = "ComicInfo.xml"; private const string ComicInfoFilename = "ComicInfo.xml";
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService, IImageService imageService) public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService,
IImageService imageService, IMediaErrorService mediaErrorService)
{ {
_logger = logger; _logger = logger;
_directoryService = directoryService; _directoryService = directoryService;
_imageService = imageService; _imageService = imageService;
_mediaErrorService = mediaErrorService;
} }
/// <summary> /// <summary>
@ -120,6 +125,8 @@ public class ArchiveService : IArchiveService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); _logger.LogWarning(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath);
_mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService,
"This archive cannot be read or not supported", ex);
return 0; return 0;
} }
} }
@ -196,9 +203,9 @@ public class ArchiveService : IArchiveService
/// <param name="archivePath"></param> /// <param name="archivePath"></param>
/// <param name="fileName">File name to use based on context of entity.</param> /// <param name="fileName">File name to use based on context of entity.</param>
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param> /// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
/// <param name="saveAsWebP">When saving the file, use WebP encoding instead of PNG</param> /// <param name="encodeFormat">When saving the file, use encoding</param>
/// <returns></returns> /// <returns></returns>
public string GetCoverImage(string archivePath, string fileName, string outputDirectory, bool saveAsWebP = false) public string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty; if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty;
try try
@ -214,7 +221,7 @@ public class ArchiveService : IArchiveService
var entry = archive.Entries.Single(e => e.FullName == entryName); var entry = archive.Entries.Single(e => e.FullName == entryName);
using var stream = entry.Open(); using var stream = entry.Open();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
} }
case ArchiveLibrary.SharpCompress: case ArchiveLibrary.SharpCompress:
{ {
@ -225,7 +232,7 @@ public class ArchiveService : IArchiveService
var entry = archive.Entries.Single(e => e.Key == entryName); var entry = archive.Entries.Single(e => e.Key == entryName);
using var stream = entry.OpenEntryStream(); using var stream = entry.OpenEntryStream();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
} }
case ArchiveLibrary.NotSupported: case ArchiveLibrary.NotSupported:
_logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); _logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath);
@ -238,6 +245,8 @@ public class ArchiveService : IArchiveService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); _logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath);
_mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService,
"This archive cannot be read or not supported", ex);
} }
return string.Empty; return string.Empty;
@ -364,10 +373,7 @@ public class ArchiveService : IArchiveService
if (entry != null) if (entry != null)
{ {
using var stream = entry.Open(); using var stream = entry.Open();
var serializer = new XmlSerializer(typeof(ComicInfo)); return Deserialize(stream);
var info = (ComicInfo?) serializer.Deserialize(stream);
ComicInfo.CleanComicInfo(info);
return info;
} }
break; break;
@ -382,9 +388,7 @@ public class ArchiveService : IArchiveService
if (entry != null) if (entry != null)
{ {
using var stream = entry.OpenEntryStream(); using var stream = entry.OpenEntryStream();
var serializer = new XmlSerializer(typeof(ComicInfo)); var info = Deserialize(stream);
var info = (ComicInfo?) serializer.Deserialize(stream);
ComicInfo.CleanComicInfo(info);
return info; return info;
} }
@ -403,11 +407,35 @@ public class ArchiveService : IArchiveService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[GetComicInfo] There was an exception when reading archive stream: {Filepath}", archivePath); _logger.LogWarning(ex, "[GetComicInfo] There was an exception when reading archive stream: {Filepath}", archivePath);
_mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService,
"This archive cannot be read or not supported", ex);
} }
return null; return null;
} }
/// <summary>
/// Strips out empty tags before deserializing
/// </summary>
/// <param name="stream"></param>
/// <returns></returns>
private static ComicInfo? Deserialize(Stream stream)
{
var comicInfoXml = XDocument.Load(stream);
comicInfoXml.Descendants()
.Where(e => e.IsEmpty || string.IsNullOrWhiteSpace(e.Value))
.Remove();
var serializer = new XmlSerializer(typeof(ComicInfo));
using var reader = comicInfoXml.Root?.CreateReader();
if (reader == null) return null;
var info = (ComicInfo?) serializer.Deserialize(reader);
ComicInfo.CleanComicInfo(info);
return info;
}
private void ExtractArchiveEntities(IEnumerable<IArchiveEntry> entries, string extractPath) private void ExtractArchiveEntities(IEnumerable<IArchiveEntry> entries, string extractPath)
{ {
@ -417,7 +445,7 @@ public class ArchiveService : IArchiveService
{ {
entry.WriteToDirectory(extractPath, new ExtractionOptions() entry.WriteToDirectory(extractPath, new ExtractionOptions()
{ {
ExtractFullPath = true, // Don't flatten, let the flatterner ensure correct order of nested folders ExtractFullPath = true, // Don't flatten, let the flattener ensure correct order of nested folders
Overwrite = false Overwrite = false
}); });
} }
@ -485,9 +513,11 @@ public class ArchiveService : IArchiveService
} }
} }
catch (Exception e) catch (Exception ex)
{ {
_logger.LogWarning(e, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath); _logger.LogWarning(ex, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath);
_mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService,
"This archive cannot be read or not supported", ex);
throw new KavitaException( throw new KavitaException(
$"There was an error when extracting {archivePath}. Check the file exists, has read permissions or the server OS can support all path characters."); $"There was an error when extracting {archivePath}. Check the file exists, has read permissions or the server OS can support all path characters.");
} }

View file

@ -21,17 +21,19 @@ using HtmlAgilityPack;
using Kavita.Common; using Kavita.Common;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.IO; using Microsoft.IO;
using Nager.ArticleNumber;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using VersOne.Epub; using VersOne.Epub;
using VersOne.Epub.Options; using VersOne.Epub.Options;
using VersOne.Epub.Schema;
namespace API.Services; namespace API.Services;
public interface IBookService public interface IBookService
{ {
int GetNumberOfPages(string filePath); int GetNumberOfPages(string filePath);
string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false); string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat);
ComicInfo? GetComicInfo(string filePath); ComicInfo? GetComicInfo(string filePath);
ParserInfo? ParseInfo(string filePath); ParserInfo? ParseInfo(string filePath);
/// <summary> /// <summary>
@ -60,6 +62,7 @@ public class BookService : IBookService
private readonly ILogger<BookService> _logger; private readonly ILogger<BookService> _logger;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService; private readonly IImageService _imageService;
private readonly IMediaErrorService _mediaErrorService;
private readonly StylesheetParser _cssParser = new (); private readonly StylesheetParser _cssParser = new ();
private static readonly RecyclableMemoryStreamManager StreamManager = new (); private static readonly RecyclableMemoryStreamManager StreamManager = new ();
private const string CssScopeClass = ".book-content"; private const string CssScopeClass = ".book-content";
@ -72,11 +75,12 @@ public class BookService : IBookService
} }
}; };
public BookService(ILogger<BookService> logger, IDirectoryService directoryService, IImageService imageService) public BookService(ILogger<BookService> logger, IDirectoryService directoryService, IImageService imageService, IMediaErrorService mediaErrorService)
{ {
_logger = logger; _logger = logger;
_directoryService = directoryService; _directoryService = directoryService;
_imageService = imageService; _imageService = imageService;
_mediaErrorService = mediaErrorService;
} }
private static bool HasClickableHrefPart(HtmlNode anchor) private static bool HasClickableHrefPart(HtmlNode anchor)
@ -123,7 +127,7 @@ public class BookService : IBookService
var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty)) var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty))
.Split("#"); .Split("#");
// Some keys get uri encoded when parsed, so replace any of those characters with original // Some keys get uri encoded when parsed, so replace any of those characters with original
var mappingKey = HttpUtility.UrlDecode(hrefParts[0]); var mappingKey = Uri.UnescapeDataString(hrefParts[0]);
if (!mappings.ContainsKey(mappingKey)) if (!mappings.ContainsKey(mappingKey))
{ {
@ -132,6 +136,15 @@ public class BookService : IBookService
var part = hrefParts.Length > 1 var part = hrefParts.Length > 1
? hrefParts[1] ? hrefParts[1]
: anchor.GetAttributeValue("href", string.Empty); : anchor.GetAttributeValue("href", string.Empty);
// hrefParts[0] might not have path from mappings
var pageKey = mappings.Keys.FirstOrDefault(mKey => mKey.EndsWith(hrefParts[0]));
if (!string.IsNullOrEmpty(pageKey))
{
mappings.TryGetValue(pageKey, out currentPage);
}
anchor.Attributes.Add("kavita-page", $"{currentPage}"); anchor.Attributes.Add("kavita-page", $"{currentPage}");
anchor.Attributes.Add("kavita-part", part); anchor.Attributes.Add("kavita-part", part);
anchor.Attributes.Remove("href"); anchor.Attributes.Remove("href");
@ -171,20 +184,20 @@ public class BookService : IBookService
// @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be Scoped // @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be Scoped
var prepend = filename.Length > 0 ? filename.Replace(Path.GetFileName(filename), string.Empty) : string.Empty; var prepend = filename.Length > 0 ? filename.Replace(Path.GetFileName(filename), string.Empty) : string.Empty;
var importBuilder = new StringBuilder(); var importBuilder = new StringBuilder();
//foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex().Matches(stylesheetHtml))
foreach (Match match in Parser.CssImportUrlRegex.Matches(stylesheetHtml)) foreach (Match match in Parser.CssImportUrlRegex.Matches(stylesheetHtml))
{ {
if (!match.Success) continue; if (!match.Success) continue;
var importFile = match.Groups["Filename"].Value; var importFile = match.Groups["Filename"].Value;
var key = CleanContentKeys(importFile); var key = CleanContentKeys(importFile); // Validate if CoalesceKey works well here
if (!key.Contains(prepend)) if (!key.Contains(prepend))
{ {
key = prepend + key; key = prepend + key;
} }
if (!book.Content.AllFiles.ContainsKey(key)) continue; if (!book.Content.AllFiles.TryGetLocalFileRefByKey(key, out var bookFile)) continue;
var bookFile = book.Content.AllFiles[key]; //var bookFile = book.Content.AllFiles.Local[key];
var content = await bookFile.ReadContentAsBytesAsync(); var content = await bookFile.ReadContentAsBytesAsync();
importBuilder.Append(Encoding.UTF8.GetString(content)); importBuilder.Append(Encoding.UTF8.GetString(content));
} }
@ -218,12 +231,20 @@ public class BookService : IBookService
} }
styleRule.Text = $"{CssScopeClass} " + styleRule.Text; styleRule.Text = $"{CssScopeClass} " + styleRule.Text;
} }
return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss());
try
{
return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss());
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue escaping css, likely due to an unsupported css rule");
}
return RemoveWhiteSpaceFromStylesheets($"{CssScopeClass} {styleContent}");
} }
private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend) private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend)
{ {
//foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex().Matches(stylesheetHtml))
foreach (Match match in Parser.CssImportUrlRegex.Matches(stylesheetHtml)) foreach (Match match in Parser.CssImportUrlRegex.Matches(stylesheetHtml))
{ {
if (!match.Success) continue; if (!match.Success) continue;
@ -234,7 +255,6 @@ public class BookService : IBookService
private static void EscapeFontFamilyReferences(ref string stylesheetHtml, string apiBase, string prepend) private static void EscapeFontFamilyReferences(ref string stylesheetHtml, string apiBase, string prepend)
{ {
//foreach (Match match in Tasks.Scanner.Parser.Parser.FontSrcUrlRegex().Matches(stylesheetHtml))
foreach (Match match in Parser.FontSrcUrlRegex.Matches(stylesheetHtml)) foreach (Match match in Parser.FontSrcUrlRegex.Matches(stylesheetHtml))
{ {
if (!match.Success) continue; if (!match.Success) continue;
@ -245,7 +265,6 @@ public class BookService : IBookService
private static void EscapeCssImageReferences(ref string stylesheetHtml, string apiBase, EpubBookRef book) private static void EscapeCssImageReferences(ref string stylesheetHtml, string apiBase, EpubBookRef book)
{ {
//var matches = Tasks.Scanner.Parser.Parser.CssImageUrlRegex().Matches(stylesheetHtml);
var matches = Parser.CssImageUrlRegex.Matches(stylesheetHtml); var matches = Parser.CssImageUrlRegex.Matches(stylesheetHtml);
foreach (Match match in matches) foreach (Match match in matches)
{ {
@ -253,7 +272,7 @@ public class BookService : IBookService
var importFile = match.Groups["Filename"].Value; var importFile = match.Groups["Filename"].Value;
var key = CleanContentKeys(importFile); var key = CleanContentKeys(importFile);
if (!book.Content.AllFiles.ContainsKey(key)) continue; if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) continue;
stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + key); stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + key);
} }
@ -286,7 +305,7 @@ public class BookService : IBookService
var imageFile = GetKeyForImage(book, image.Attributes[key].Value); var imageFile = GetKeyForImage(book, image.Attributes[key].Value);
image.Attributes.Remove(key); image.Attributes.Remove(key);
// UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx
image.Attributes.Add(key, $"{apiBase}" + HttpUtility.UrlEncode(imageFile)); image.Attributes.Add(key, $"{apiBase}" + Uri.EscapeDataString(imageFile));
// Add a custom class that the reader uses to ensure images stay within reader // Add a custom class that the reader uses to ensure images stay within reader
parent.AddClass("kavita-scale-width-container"); parent.AddClass("kavita-scale-width-container");
@ -303,9 +322,9 @@ public class BookService : IBookService
/// <returns></returns> /// <returns></returns>
private static string GetKeyForImage(EpubBookRef book, string imageFile) private static string GetKeyForImage(EpubBookRef book, string imageFile)
{ {
if (book.Content.Images.ContainsKey(imageFile)) return imageFile; if (book.Content.Images.ContainsLocalFileRefWithKey(imageFile)) return imageFile;
var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); var correctedKey = book.Content.Images.Local.Select(s => s.Key).SingleOrDefault(s => s.EndsWith(imageFile));
if (correctedKey != null) if (correctedKey != null)
{ {
imageFile = correctedKey; imageFile = correctedKey;
@ -314,13 +333,14 @@ public class BookService : IBookService
{ {
// There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg // There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg
correctedKey = correctedKey =
book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty))); book.Content.Images.Local.Select(s => s.Key).SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty)));
if (correctedKey != null) if (correctedKey != null)
{ {
imageFile = correctedKey; imageFile = correctedKey;
} }
} }
return imageFile; return imageFile;
} }
@ -338,6 +358,7 @@ public class BookService : IBookService
} }
private static void RewriteAnchors(int page, HtmlDocument doc, Dictionary<string, int> mappings) private static void RewriteAnchors(int page, HtmlDocument doc, Dictionary<string, int> mappings)
{ {
var anchors = doc.DocumentNode.SelectNodes("//a"); var anchors = doc.DocumentNode.SelectNodes("//a");
if (anchors == null) return; if (anchors == null) return;
@ -368,9 +389,9 @@ public class BookService : IBookService
var key = CleanContentKeys(styleLinks.Attributes["href"].Value); var key = CleanContentKeys(styleLinks.Attributes["href"].Value);
// Some epubs are malformed the key in content.opf might be: content/resources/filelist_0_0.xml but the actual html links to resources/filelist_0_0.xml // Some epubs are malformed the key in content.opf might be: content/resources/filelist_0_0.xml but the actual html links to resources/filelist_0_0.xml
// In this case, we will do a search for the key that ends with // In this case, we will do a search for the key that ends with
if (!book.Content.Css.ContainsKey(key)) if (!book.Content.Css.ContainsLocalFileRefWithKey(key))
{ {
var correctedKey = book.Content.Css.Keys.SingleOrDefault(s => s.EndsWith(key)); var correctedKey = book.Content.Css.Local.Select(s => s.Key).SingleOrDefault(s => s.EndsWith(key));
if (correctedKey == null) if (correctedKey == null)
{ {
_logger.LogError("Epub is Malformed, key: {Key} is not matching OPF file", key); _logger.LogError("Epub is Malformed, key: {Key} is not matching OPF file", key);
@ -382,10 +403,11 @@ public class BookService : IBookService
try try
{ {
var cssFile = book.Content.Css[key]; var cssFile = book.Content.Css.GetLocalFileRefByKey(key);
var styleContent = await ScopeStyles(await cssFile.ReadContentAsync(), apiBase, var stylesheetHtml = await cssFile.ReadContentAsync();
cssFile.FileName, book); var styleContent = await ScopeStyles(stylesheetHtml, apiBase,
cssFile.FilePath, book);
if (styleContent != null) if (styleContent != null)
{ {
body.PrependChild(HtmlNode.CreateNode($"<style>{styleContent}</style>")); body.PrependChild(HtmlNode.CreateNode($"<style>{styleContent}</style>"));
@ -394,6 +416,8 @@ public class BookService : IBookService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "There was an error reading css file for inlining likely due to a key mismatch in metadata"); _logger.LogError(ex, "There was an error reading css file for inlining likely due to a key mismatch in metadata");
await _mediaErrorService.ReportMediaIssueAsync(book.FilePath, MediaErrorProducer.BookService,
"There was an error reading css file for inlining likely due to a key mismatch in metadata", ex);
} }
} }
} }
@ -415,20 +439,35 @@ public class BookService : IBookService
} }
var (year, month, day) = GetPublicationDate(publicationDate); var (year, month, day) = GetPublicationDate(publicationDate);
var summary = epubBook.Schema.Package.Metadata.Descriptions.FirstOrDefault();
var info = new ComicInfo var info = new ComicInfo
{ {
Summary = epubBook.Schema.Package.Metadata.Description, Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description,
Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators.Select(c => Parser.CleanAuthor(c.Creator))), Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers.Select(p => p.Publisher)),
Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers),
Month = month, Month = month,
Day = day, Day = day,
Year = year, Year = year,
Title = epubBook.Title, Title = epubBook.Title,
Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.ToLower().Trim())), Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())),
LanguageISO = ValidateLanguage(epubBook.Schema.Package.Metadata.Languages.FirstOrDefault()) LanguageISO = ValidateLanguage(epubBook.Schema.Package.Metadata.Languages
.Select(l => l.Language)
.FirstOrDefault())
}; };
ComicInfo.CleanComicInfo(info); ComicInfo.CleanComicInfo(info);
foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers.Where(id => !string.IsNullOrEmpty(id.Scheme) && id.Scheme.Equals("ISBN")))
{
if (string.IsNullOrEmpty(identifier.Identifier)) continue;
var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty).Replace("isbn:", string.Empty);
if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn))
{
_logger.LogDebug("[BookService] {File} has invalid ISBN number", filePath);
continue;
}
info.Isbn = isbn;
break;
}
// Parse tags not exposed via Library // Parse tags not exposed via Library
foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems)
{ {
@ -443,13 +482,17 @@ public class BookService : IBookService
break; break;
case "calibre:series": case "calibre:series":
info.Series = metadataItem.Content; info.Series = metadataItem.Content;
info.SeriesSort = metadataItem.Content; if (string.IsNullOrEmpty(info.SeriesSort))
{
info.SeriesSort = metadataItem.Content;
}
break; break;
case "calibre:series_index": case "calibre:series_index":
info.Volume = metadataItem.Content; info.Volume = metadataItem.Content;
break; break;
} }
// EPUB 3.2+ only // EPUB 3.2+ only
switch (metadataItem.Property) switch (metadataItem.Property)
{ {
@ -458,14 +501,47 @@ public class BookService : IBookService
break; break;
case "belongs-to-collection": case "belongs-to-collection":
info.Series = metadataItem.Content; info.Series = metadataItem.Content;
info.SeriesSort = metadataItem.Content; if (string.IsNullOrEmpty(info.SeriesSort))
{
info.SeriesSort = metadataItem.Content;
}
break; break;
case "collection-type": case "collection-type":
// These look to be genres from https://manual.calibre-ebook.com/sub_groups.html or can be "series" // These look to be genres from https://manual.calibre-ebook.com/sub_groups.html or can be "series"
break;
case "role":
if (metadataItem.Scheme != null && !metadataItem.Scheme.Equals("marc:relators")) break;
var creatorId = metadataItem.Refines?.Replace("#", string.Empty);
var person = epubBook.Schema.Package.Metadata.Creators
.SingleOrDefault(c => c.Id == creatorId);
if (person == null) break;
PopulatePerson(metadataItem, info, person);
break;
case "title-type":
if (metadataItem.Content.Equals("collection"))
{
ExtractCollectionOrReadingList(metadataItem, epubBook, info);
}
if (metadataItem.Content.Equals("main"))
{
ExtractSortTitle(metadataItem, epubBook, info);
}
break; break;
} }
} }
// Check if there is a SortTitle
// Include regular Writer as well, for cases where there is no special tag
info.Writer = string.Join(",",
epubBook.Schema.Package.Metadata.Creators.Select(c => Parser.CleanAuthor(c.Creator)));
var hasVolumeInSeries = !Parser.ParseVolume(info.Title) var hasVolumeInSeries = !Parser.ParseVolume(info.Title)
.Equals(Parser.DefaultVolume); .Equals(Parser.DefaultVolume);
@ -480,12 +556,94 @@ public class BookService : IBookService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[GetComicInfo] There was an exception getting metadata"); _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata");
_mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
"There was an exception parsing metadata", ex);
} }
return null; return null;
} }
private static void ExtractSortTitle(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info)
{
var titleId = metadataItem.Refines?.Replace("#", string.Empty);
var titleElem = epubBook.Schema.Package.Metadata.Titles
.FirstOrDefault(item => item.Id == titleId);
if (titleElem == null) return;
var sortTitleElem = epubBook.Schema.Package.Metadata.MetaItems
.FirstOrDefault(item =>
item.Property == "file-as" && item.Refines == metadataItem.Refines);
if (sortTitleElem == null || string.IsNullOrWhiteSpace(sortTitleElem.Content)) return;
info.SeriesSort = sortTitleElem.Content;
}
private static void ExtractCollectionOrReadingList(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info)
{
var titleId = metadataItem.Refines?.Replace("#", string.Empty);
var readingListElem = epubBook.Schema.Package.Metadata.Titles
.FirstOrDefault(item => item.Id == titleId);
if (readingListElem == null) return;
var count = epubBook.Schema.Package.Metadata.MetaItems
.FirstOrDefault(item =>
item.Property == "display-seq" && item.Refines == metadataItem.Refines);
if (count == null || count.Content == "0")
{
// TODO: Rewrite this to use a StringBuilder
// Treat this as a Collection
info.SeriesGroup += (string.IsNullOrEmpty(info.StoryArc) ? string.Empty : ",") +
readingListElem.Title.Replace(",", "_");
}
else
{
// Treat as a reading list
info.AlternateSeries += (string.IsNullOrEmpty(info.AlternateSeries) ? string.Empty : ",") +
readingListElem.Title.Replace(",", "_");
info.AlternateNumber += (string.IsNullOrEmpty(info.AlternateNumber) ? string.Empty : ",") + count.Content;
}
}
private static void PopulatePerson(EpubMetadataMeta metadataItem, ComicInfo info, EpubMetadataCreator person)
{
switch (metadataItem.Content)
{
case "art":
case "artist":
info.CoverArtist += AppendAuthor(person);
return;
case "aut":
case "author":
info.Writer += AppendAuthor(person);
return;
case "pbl":
case "publisher":
info.Publisher += AppendAuthor(person);
return;
case "trl":
case "translator":
info.Translator += AppendAuthor(person);
return;
case "edt":
case "editor":
info.Editor += AppendAuthor(person);
return;
case "ill":
case "illustrator":
info.Letterer += AppendAuthor(person);
return;
case "clr":
case "colorist":
info.Colorist += AppendAuthor(person);
return;
}
}
private static string AppendAuthor(EpubMetadataCreator person)
{
return Parser.CleanAuthor(person.Creator) + ",";
}
private static (int year, int month, int day) GetPublicationDate(string publicationDate) private static (int year, int month, int day) GetPublicationDate(string publicationDate)
{ {
var dateParsed = DateTime.TryParse(publicationDate, out var date); var dateParsed = DateTime.TryParse(publicationDate, out var date);
@ -553,6 +711,8 @@ public class BookService : IBookService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[BookService] There was an exception getting number of pages, defaulting to 0"); _logger.LogWarning(ex, "[BookService] There was an exception getting number of pages, defaulting to 0");
_mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
"There was an exception getting number of pages, defaulting to 0", ex);
} }
return 0; return 0;
@ -584,7 +744,9 @@ public class BookService : IBookService
foreach (var contentFileRef in await book.GetReadingOrderAsync()) foreach (var contentFileRef in await book.GetReadingOrderAsync())
{ {
if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) continue; if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) continue;
dict.Add(contentFileRef.FileName, pageCount); // Some keys are different than FilePath, so we add both to ease loookup
dict.Add(contentFileRef.FilePath, pageCount); // FileName -> FilePath
dict.TryAdd(contentFileRef.Key, pageCount); // FileName -> FilePath
pageCount += 1; pageCount += 1;
} }
@ -697,6 +859,8 @@ public class BookService : IBookService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[BookService] There was an exception when opening epub book: {FileName}", filePath); _logger.LogWarning(ex, "[BookService] There was an exception when opening epub book: {FileName}", filePath);
_mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
"There was an exception when opening epub book", ex);
} }
return null; return null;
@ -751,33 +915,47 @@ public class BookService : IBookService
/// <param name="mappings"></param> /// <param name="mappings"></param>
/// <param name="key"></param> /// <param name="key"></param>
/// <returns></returns> /// <returns></returns>
private static string CoalesceKey(EpubBookRef book, IDictionary<string, int> mappings, string key) private static string CoalesceKey(EpubBookRef book, IReadOnlyDictionary<string, int> mappings, string key)
{ {
if (mappings.ContainsKey(CleanContentKeys(key))) return key; if (mappings.ContainsKey(CleanContentKeys(key))) return key;
// Fallback to searching for key (bad epub metadata) // Fallback to searching for key (bad epub metadata)
var correctedKey = book.Content.Html.Keys.FirstOrDefault(s => s.EndsWith(key)); var correctedKey = book.Content.Html.Local.Select(s => s.Key).FirstOrDefault(s => s.EndsWith(key));
if (!string.IsNullOrEmpty(correctedKey)) if (!string.IsNullOrEmpty(correctedKey))
{ {
key = correctedKey; key = correctedKey;
} }
var stepsBack = CountParentDirectory(book.Content.NavigationHtmlFile?.FilePath); // FileName -> FilePath
if (mappings.TryGetValue(key, out _))
{
return key;
}
var modifiedKey = RemovePathSegments(key, stepsBack);
if (mappings.TryGetValue(modifiedKey, out _))
{
return modifiedKey;
}
return key; return key;
} }
public static string CoalesceKeyForAnyFile(EpubBookRef book, string key) public static string CoalesceKeyForAnyFile(EpubBookRef book, string key)
{ {
if (book.Content.AllFiles.ContainsKey(key)) return key; if (book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return key;
var cleanedKey = CleanContentKeys(key); var cleanedKey = CleanContentKeys(key);
if (book.Content.AllFiles.ContainsKey(cleanedKey)) return cleanedKey; if (book.Content.AllFiles.ContainsLocalFileRefWithKey(cleanedKey)) return cleanedKey;
// TODO: Figure this out
// Fallback to searching for key (bad epub metadata) // Fallback to searching for key (bad epub metadata)
var correctedKey = book.Content.AllFiles.Keys.SingleOrDefault(s => s.EndsWith(key)); // var correctedKey = book.Content.AllFiles.Keys.SingleOrDefault(s => s.EndsWith(key));
if (!string.IsNullOrEmpty(correctedKey)) // if (!string.IsNullOrEmpty(correctedKey))
{ // {
key = correctedKey; // key = correctedKey;
} // }
return key; return key;
} }
@ -796,42 +974,48 @@ public class BookService : IBookService
var navItems = await book.GetNavigationAsync(); var navItems = await book.GetNavigationAsync();
var chaptersList = new List<BookChapterItem>(); var chaptersList = new List<BookChapterItem>();
foreach (var navigationItem in navItems) if (navItems != null)
{ {
if (navigationItem.NestedItems.Count == 0) foreach (var navigationItem in navItems)
{ {
CreateToCChapter(navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings); if (navigationItem.NestedItems.Count == 0)
continue;
}
var nestedChapters = new List<BookChapterItem>();
foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
{
var key = CoalesceKey(book, mappings, nestedChapter.Link.ContentFileName);
if (mappings.ContainsKey(key))
{ {
nestedChapters.Add(new BookChapterItem CreateToCChapter(book, navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings);
{ continue;
Title = nestedChapter.Title,
Page = mappings[key],
Part = nestedChapter.Link.Anchor ?? string.Empty,
Children = new List<BookChapterItem>()
});
} }
}
CreateToCChapter(navigationItem, nestedChapters, chaptersList, mappings); var nestedChapters = new List<BookChapterItem>();
foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
{
var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFilePath);
if (mappings.TryGetValue(key, out var mapping))
{
nestedChapters.Add(new BookChapterItem
{
Title = nestedChapter.Title,
Page = mapping,
Part = nestedChapter.Link?.Anchor ?? string.Empty,
Children = new List<BookChapterItem>()
});
}
}
CreateToCChapter(book, navigationItem, nestedChapters, chaptersList, mappings);
}
} }
if (chaptersList.Count != 0) return chaptersList; if (chaptersList.Count != 0) return chaptersList;
// Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist) // Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
var tocPage = book.Content.Html.Keys.FirstOrDefault(k => k.ToUpper().Contains("TOC")); var tocPage = book.Content.Html.Local.Select(s => s.Key).FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
if (tocPage == null) return chaptersList; k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
if (string.IsNullOrEmpty(tocPage)) return chaptersList;
// Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content
if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
var content = await file.ReadContentAsync();
var doc = new HtmlDocument(); var doc = new HtmlDocument();
var content = await book.Content.Html[tocPage].ReadContentAsync();
doc.LoadHtml(content); doc.LoadHtml(content);
var anchors = doc.DocumentNode.SelectNodes("//a"); var anchors = doc.DocumentNode.SelectNodes("//a");
if (anchors == null) return chaptersList; if (anchors == null) return chaptersList;
@ -860,6 +1044,38 @@ public class BookService : IBookService
return chaptersList; return chaptersList;
} }
private static int CountParentDirectory(string path)
{
const string pattern = @"\.\./";
var matches = Regex.Matches(path, pattern);
return matches.Count;
}
/// <summary>
/// Removes paths segments from the beginning of a path. Returns original path if any issues.
/// </summary>
/// <param name="path"></param>
/// <param name="segmentsToRemove"></param>
/// <returns></returns>
private static string RemovePathSegments(string path, int segmentsToRemove)
{
if (segmentsToRemove <= 0)
return path;
var startIndex = 0;
for (var i = 0; i < segmentsToRemove; i++)
{
var slashIndex = path.IndexOf('/', startIndex);
if (slashIndex == -1)
return path; // Not enough segments to remove
startIndex = slashIndex + 1;
}
return path.Substring(startIndex);
}
/// <summary> /// <summary>
/// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader, /// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader,
/// all css is scoped, etc. /// all css is scoped, etc.
@ -916,14 +1132,15 @@ public class BookService : IBookService
} }
} catch (Exception ex) } catch (Exception ex)
{ {
// NOTE: We can log this to media analysis service
_logger.LogError(ex, "There was an issue reading one of the pages for {Book}", book.FilePath); _logger.LogError(ex, "There was an issue reading one of the pages for {Book}", book.FilePath);
await _mediaErrorService.ReportMediaIssueAsync(book.FilePath, MediaErrorProducer.BookService,
"There was an issue reading one of the pages for", ex);
} }
throw new KavitaException("Could not find the appropriate html for that page"); throw new KavitaException("Could not find the appropriate html for that page");
} }
private static void CreateToCChapter(EpubNavigationItemRef navigationItem, IList<BookChapterItem> nestedChapters, private static void CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, IList<BookChapterItem> nestedChapters,
ICollection<BookChapterItem> chaptersList, IReadOnlyDictionary<string, int> mappings) ICollection<BookChapterItem> chaptersList, IReadOnlyDictionary<string, int> mappings)
{ {
if (navigationItem.Link == null) if (navigationItem.Link == null)
@ -942,7 +1159,7 @@ public class BookService : IBookService
} }
else else
{ {
var groupKey = CleanContentKeys(navigationItem.Link.ContentFileName); var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFilePath);
if (mappings.ContainsKey(groupKey)) if (mappings.ContainsKey(groupKey))
{ {
chaptersList.Add(new BookChapterItem chaptersList.Add(new BookChapterItem
@ -962,15 +1179,15 @@ public class BookService : IBookService
/// <param name="fileFilePath"></param> /// <param name="fileFilePath"></param>
/// <param name="fileName">Name of the new file.</param> /// <param name="fileName">Name of the new file.</param>
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param> /// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
/// <param name="saveAsWebP">When saving the file, use WebP encoding instead of PNG</param> /// <param name="encodeFormat">When saving the file, use encoding</param>
/// <returns></returns> /// <returns></returns>
public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false) public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
if (!IsValidFile(fileFilePath)) return string.Empty; if (!IsValidFile(fileFilePath)) return string.Empty;
if (Parser.IsPdf(fileFilePath)) if (Parser.IsPdf(fileFilePath))
{ {
return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, saveAsWebP); return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat);
} }
using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions);
@ -979,24 +1196,26 @@ public class BookService : IBookService
{ {
// Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one. // Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one.
var coverImageContent = epubBook.Content.Cover var coverImageContent = epubBook.Content.Cover
?? epubBook.Content.Images.Values.FirstOrDefault(file => Parser.IsCoverImage(file.FileName)) ?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath)) // FileName -> FilePath
?? epubBook.Content.Images.Values.FirstOrDefault(); ?? epubBook.Content.Images.Local.FirstOrDefault();
if (coverImageContent == null) return string.Empty; if (coverImageContent == null) return string.Empty;
using var stream = coverImageContent.GetContentStream(); using var stream = coverImageContent.GetContentStream();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); _logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath);
_mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService,
"There was a critical error and prevented thumbnail generation", ex);
} }
return string.Empty; return string.Empty;
} }
private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP) private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
try try
{ {
@ -1006,7 +1225,7 @@ public class BookService : IBookService
using var stream = StreamManager.GetStream("BookService.GetPdfPage"); using var stream = StreamManager.GetStream("BookService.GetPdfPage");
GetPdfPage(docReader, 0, stream); GetPdfPage(docReader, 0, stream);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
} }
catch (Exception ex) catch (Exception ex)
@ -1014,6 +1233,8 @@ public class BookService : IBookService
_logger.LogWarning(ex, _logger.LogWarning(ex,
"[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image",
fileFilePath); fileFilePath);
_mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService,
"There was a critical error and prevented thumbnail generation", ex);
} }
return string.Empty; return string.Empty;
@ -1046,18 +1267,19 @@ public class BookService : IBookService
} }
// Remove comments from CSS // Remove comments from CSS
// body = CssComment().Replace(body, string.Empty);
//
// body = WhiteSpace1().Replace(body, "#");
// body = WhiteSpace2().Replace(body, string.Empty);
// body = WhiteSpace3().Replace(body, " ");
// body = WhiteSpace4().Replace(body, "$1");
body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty, RegexOptions.None, Parser.RegexTimeout); body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty, RegexOptions.None, Parser.RegexTimeout);
body = Regex.Replace(body, @"[a-zA-Z]+#", "#", RegexOptions.None, Parser.RegexTimeout); body = Regex.Replace(body, @"[a-zA-Z]+#", "#", RegexOptions.None, Parser.RegexTimeout);
body = Regex.Replace(body, @"[\n\r]+\s*", string.Empty, RegexOptions.None, Parser.RegexTimeout); body = Regex.Replace(body, @"[\n\r]+\s*", string.Empty, RegexOptions.None, Parser.RegexTimeout);
body = Regex.Replace(body, @"\s+", " ", RegexOptions.None, Parser.RegexTimeout); body = Regex.Replace(body, @"\s+", " ", RegexOptions.None, Parser.RegexTimeout);
body = Regex.Replace(body, @"\s?([:,;{}])\s?", "$1", RegexOptions.None, Parser.RegexTimeout); body = Regex.Replace(body, @"\s?([:,;{}])\s?", "$1", RegexOptions.None, Parser.RegexTimeout);
// Handle <!-- which some books use (but shouldn't)
body = Regex.Replace(body, "<!--.*?-->", string.Empty, RegexOptions.None, Parser.RegexTimeout);
// Handle /* */
body = Regex.Replace(body, @"/\*.*?\*/", string.Empty, RegexOptions.None, Parser.RegexTimeout);
try try
{ {
body = body.Replace(";}", "}"); body = body.Replace(";}", "}");
@ -1067,7 +1289,6 @@ public class BookService : IBookService
//Swallow exception. Some css don't have style rules ending in ';' //Swallow exception. Some css don't have style rules ending in ';'
} }
//body = UnitPadding().Replace(body, "$1");
body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1", RegexOptions.None, Parser.RegexTimeout); body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1", RegexOptions.None, Parser.RegexTimeout);
@ -1076,7 +1297,7 @@ public class BookService : IBookService
private void LogBookErrors(EpubBookRef book, EpubContentFileRef contentFileRef, HtmlDocument doc) private void LogBookErrors(EpubBookRef book, EpubContentFileRef contentFileRef, HtmlDocument doc)
{ {
_logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName); _logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.Key);
foreach (var error in doc.ParseErrors) foreach (var error in doc.ParseErrors)
{ {
_logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason); _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);

View file

@ -7,7 +7,6 @@ using API.Data;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.SignalR;
using Hangfire; using Hangfire;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -19,9 +18,6 @@ public interface IBookmarkService
Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark); Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark);
Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto); Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto);
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds); Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllBookmarkToWebP();
Task ConvertAllCoverToWebP();
} }
public class BookmarkService : IBookmarkService public class BookmarkService : IBookmarkService
@ -30,17 +26,15 @@ public class BookmarkService : IBookmarkService
private readonly ILogger<BookmarkService> _logger; private readonly ILogger<BookmarkService> _logger;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService; private readonly IMediaConversionService _mediaConversionService;
private readonly IEventHub _eventHub;
public BookmarkService(ILogger<BookmarkService> logger, IUnitOfWork unitOfWork, public BookmarkService(ILogger<BookmarkService> logger, IUnitOfWork unitOfWork,
IDirectoryService directoryService, IImageService imageService, IEventHub eventHub) IDirectoryService directoryService, IMediaConversionService mediaConversionService)
{ {
_logger = logger; _logger = logger;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_directoryService = directoryService; _directoryService = directoryService;
_imageService = imageService; _mediaConversionService = mediaConversionService;
_eventHub = eventHub;
} }
/// <summary> /// <summary>
@ -77,21 +71,25 @@ public class BookmarkService : IBookmarkService
/// This is a job that runs after a bookmark is saved /// This is a job that runs after a bookmark is saved
/// </summary> /// </summary>
/// <remarks>This must be public</remarks> /// <remarks>This must be public</remarks>
public async Task ConvertBookmarkToWebP(int bookmarkId) public async Task ConvertBookmarkToEncoding(int bookmarkId)
{ {
var bookmarkDirectory = var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var convertBookmarkToWebP = var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP; (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (!convertBookmarkToWebP) return; if (encodeFormat == EncodeFormat.PNG)
{
_logger.LogError("Cannot convert media to PNG");
return;
}
// Validate the bookmark still exists // Validate the bookmark still exists
var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId); var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId);
if (bookmark == null) return; if (bookmark == null) return;
bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName, bookmark.FileName = await _mediaConversionService.SaveAsEncodingFormat(bookmarkDirectory, bookmark.FileName,
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId)); BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId), encodeFormat);
_unitOfWork.UserRepository.Update(bookmark); _unitOfWork.UserRepository.Update(bookmark);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
@ -137,10 +135,10 @@ public class BookmarkService : IBookmarkService
_unitOfWork.UserRepository.Add(bookmark); _unitOfWork.UserRepository.Add(bookmark);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
if (settings.ConvertBookmarkToWebP) if (settings.EncodeMediaAs == EncodeFormat.WEBP)
{ {
// Enqueue a task to convert the bookmark to webP // Enqueue a task to convert the bookmark to webP
BackgroundJob.Enqueue(() => ConvertBookmarkToWebP(bookmark.Id)); BackgroundJob.Enqueue(() => ConvertBookmarkToEncoding(bookmark.Id));
} }
} }
catch (Exception ex) catch (Exception ex)
@ -192,198 +190,9 @@ public class BookmarkService : IBookmarkService
b.FileName))); b.FileName)));
} }
/// <summary>
/// This is a long-running job that will convert all bookmarks into WebP. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllBookmarkToWebP()
{
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(0F, ProgressEventType.Started));
var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync())
.Where(b => !b.FileName.EndsWith(".webp")).ToList();
var count = 1F;
foreach (var bookmark in bookmarks)
{
bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName,
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId));
_unitOfWork.UserRepository.Update(bookmark);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(count / bookmarks.Count, ProgressEventType.Started));
count++;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[BookmarkService] Converted bookmarks to WebP");
}
/// <summary>
/// This is a long-running job that will convert all covers into WebP. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllCoverToWebP()
{
_logger.LogInformation("[BookmarkService] Starting conversion of all covers to webp");
var coverDirectory = _directoryService.CoverImageDirectory;
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(0F, ProgressEventType.Started));
var chapterCovers = await _unitOfWork.ChapterRepository.GetAllChaptersWithNonWebPCovers();
var seriesCovers = await _unitOfWork.SeriesRepository.GetAllWithNonWebPCovers();
var readingListCovers = await _unitOfWork.ReadingListRepository.GetAllWithNonWebPCovers();
var libraryCovers = await _unitOfWork.LibraryRepository.GetAllWithNonWebPCovers();
var collectionCovers = await _unitOfWork.CollectionTagRepository.GetAllWithNonWebPCovers();
var totalCount = chapterCovers.Count + seriesCovers.Count + readingListCovers.Count +
libraryCovers.Count + collectionCovers.Count;
var count = 1F;
_logger.LogInformation("[BookmarkService] Starting conversion of chapters");
foreach (var chapter in chapterCovers)
{
if (string.IsNullOrEmpty(chapter.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, chapter.CoverImage, coverDirectory);
chapter.CoverImage = Path.GetFileName(newFile);
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
_logger.LogInformation("[BookmarkService] Starting conversion of series");
foreach (var series in seriesCovers)
{
if (string.IsNullOrEmpty(series.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, series.CoverImage, coverDirectory);
series.CoverImage = Path.GetFileName(newFile);
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
_logger.LogInformation("[BookmarkService] Starting conversion of libraries");
foreach (var library in libraryCovers)
{
if (string.IsNullOrEmpty(library.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, library.CoverImage, coverDirectory);
library.CoverImage = Path.GetFileName(newFile);
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
_logger.LogInformation("[BookmarkService] Starting conversion of reading lists");
foreach (var readingList in readingListCovers)
{
if (string.IsNullOrEmpty(readingList.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, readingList.CoverImage, coverDirectory);
readingList.CoverImage = Path.GetFileName(newFile);
_unitOfWork.ReadingListRepository.Update(readingList);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
_logger.LogInformation("[BookmarkService] Starting conversion of collections");
foreach (var collection in collectionCovers)
{
if (string.IsNullOrEmpty(collection.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, collection.CoverImage, coverDirectory);
collection.CoverImage = Path.GetFileName(newFile);
_unitOfWork.CollectionTagRepository.Update(collection);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
// Now null out all series and volumes that aren't webp or custom
var nonCustomOrConvertedVolumeCovers = await _unitOfWork.VolumeRepository.GetAllWithNonWebPCovers();
foreach (var volume in nonCustomOrConvertedVolumeCovers)
{
if (string.IsNullOrEmpty(volume.CoverImage)) continue;
volume.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
_unitOfWork.VolumeRepository.Update(volume);
await _unitOfWork.CommitAsync();
}
var nonCustomOrConvertedSeriesCovers = await _unitOfWork.SeriesRepository.GetAllWithNonWebPCovers(false);
foreach (var series in nonCustomOrConvertedSeriesCovers)
{
if (string.IsNullOrEmpty(series.CoverImage)) continue;
series.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[BookmarkService] Converted covers to WebP");
}
/// <summary> public static string BookmarkStem(int userId, int seriesId, int chapterId)
/// Converts an image file, deletes original and returns the new path back
/// </summary>
/// <param name="imageDirectory">Full Path to where files are stored</param>
/// <param name="filename">The file to convert</param>
/// <param name="targetFolder">Full path to where files should be stored or any stem</param>
/// <returns></returns>
public async Task<string> SaveAsWebP(string imageDirectory, string filename, string targetFolder)
{
// This must be Public as it's used in via Hangfire as a background task
var fullSourcePath = _directoryService.FileSystem.Path.Join(imageDirectory, filename);
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(filename).Name, string.Empty);
var newFilename = string.Empty;
_logger.LogDebug("Converting {Source} image into WebP at {Target}", fullSourcePath, fullTargetDirectory);
try
{
// Convert target file to webp then delete original target file and update bookmark
try
{
var targetFile = await _imageService.ConvertToWebP(fullSourcePath, fullTargetDirectory);
var targetName = new FileInfo(targetFile).Name;
newFilename = Path.Join(targetFolder, targetName);
_directoryService.DeleteFiles(new[] {fullSourcePath});
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert image {FilePath}", filename);
newFilename = filename;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert image to WebP");
}
return newFilename;
}
private static string BookmarkStem(int userId, int seriesId, int chapterId)
{ {
return Path.Join($"{userId}", $"{seriesId}", $"{chapterId}"); return Path.Join($"{userId}", $"{seriesId}", $"{chapterId}");
} }

View file

@ -22,6 +22,7 @@ public interface IDirectoryService
string TempDirectory { get; } string TempDirectory { get; }
string ConfigDirectory { get; } string ConfigDirectory { get; }
string SiteThemeDirectory { get; } string SiteThemeDirectory { get; }
string FaviconDirectory { get; }
/// <summary> /// <summary>
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path. /// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
/// </summary> /// </summary>
@ -75,6 +76,7 @@ public class DirectoryService : IDirectoryService
public string ConfigDirectory { get; } public string ConfigDirectory { get; }
public string BookmarkDirectory { get; } public string BookmarkDirectory { get; }
public string SiteThemeDirectory { get; } public string SiteThemeDirectory { get; }
public string FaviconDirectory { get; }
private readonly ILogger<DirectoryService> _logger; private readonly ILogger<DirectoryService> _logger;
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
@ -98,6 +100,7 @@ public class DirectoryService : IDirectoryService
ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config"); ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config");
BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks");
SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes");
FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons");
ExistOrCreate(SiteThemeDirectory); ExistOrCreate(SiteThemeDirectory);
ExistOrCreate(CoverImageDirectory); ExistOrCreate(CoverImageDirectory);
@ -105,6 +108,7 @@ public class DirectoryService : IDirectoryService
ExistOrCreate(LogDirectory); ExistOrCreate(LogDirectory);
ExistOrCreate(TempDirectory); ExistOrCreate(TempDirectory);
ExistOrCreate(BookmarkDirectory); ExistOrCreate(BookmarkDirectory);
ExistOrCreate(FaviconDirectory);
} }
/// <summary> /// <summary>

View file

@ -1,7 +1,16 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants;
using API.Entities.Enums;
using API.Extensions;
using EasyCaching.Core;
using Flurl;
using Flurl.Http;
using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NetVips; using NetVips;
using Image = NetVips.Image; using Image = NetVips.Image;
@ -11,55 +20,58 @@ namespace API.Services;
public interface IImageService public interface IImageService
{ {
void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1); void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1);
string GetCoverImage(string path, string fileName, string outputDirectory, bool saveAsWebP = false); string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat);
/// <summary> /// <summary>
/// Creates a Thumbnail version of a base64 image /// Creates a Thumbnail version of a base64 image
/// </summary> /// </summary>
/// <param name="encodedImage">base64 encoded image</param> /// <param name="encodedImage">base64 encoded image</param>
/// <param name="fileName"></param> /// <param name="fileName"></param>
/// <param name="saveAsWebP">Convert and save as webp</param> /// <param name="encodeFormat">Convert and save as encoding format</param>
/// <param name="thumbnailWidth">Width of thumbnail</param> /// <param name="thumbnailWidth">Width of thumbnail</param>
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns> /// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
string CreateThumbnailFromBase64(string encodedImage, string fileName, bool saveAsWebP = false, int thumbnailWidth = 320); string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = 320);
/// <summary> /// <summary>
/// Writes out a thumbnail by stream input /// Writes out a thumbnail by stream input
/// </summary> /// </summary>
/// <param name="stream"></param> /// <param name="stream"></param>
/// <param name="fileName"></param> /// <param name="fileName"></param>
/// <param name="outputDirectory"></param> /// <param name="outputDirectory"></param>
/// <param name="saveAsWebP"></param> /// <param name="encodeFormat"></param>
/// <returns></returns> /// <returns></returns>
string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, bool saveAsWebP = false); string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat);
/// <summary> /// <summary>
/// Writes out a thumbnail by file path input /// Writes out a thumbnail by file path input
/// </summary> /// </summary>
/// <param name="sourceFile"></param> /// <param name="sourceFile"></param>
/// <param name="fileName"></param> /// <param name="fileName"></param>
/// <param name="outputDirectory"></param> /// <param name="outputDirectory"></param>
/// <param name="saveAsWebP"></param> /// <param name="encodeFormat"></param>
/// <returns></returns> /// <returns></returns>
string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, bool saveAsWebP = false); string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat);
/// <summary> /// <summary>
/// Converts the passed image to webP and outputs it in the same directory /// Converts the passed image to encoding and outputs it in the same directory
/// </summary> /// </summary>
/// <param name="filePath">Full path to the image to convert</param> /// <param name="filePath">Full path to the image to convert</param>
/// <param name="outputPath">Where to output the file</param> /// <param name="outputPath">Where to output the file</param>
/// <returns>File of written webp image</returns> /// <returns>File of written encoded image</returns>
Task<string> ConvertToWebP(string filePath, string outputPath); Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
Task<bool> IsImage(string filePath); Task<bool> IsImage(string filePath);
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
} }
public class ImageService : IImageService public class ImageService : IImageService
{ {
public const string Name = "BookmarkService";
private readonly ILogger<ImageService> _logger; private readonly ILogger<ImageService> _logger;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IEasyCachingProviderFactory _cacheFactory;
public const string ChapterCoverImageRegex = @"v\d+_c\d+"; public const string ChapterCoverImageRegex = @"v\d+_c\d+";
public const string SeriesCoverImageRegex = @"series\d+"; public const string SeriesCoverImageRegex = @"series\d+";
public const string CollectionTagCoverImageRegex = @"tag\d+"; public const string CollectionTagCoverImageRegex = @"tag\d+";
public const string ReadingListCoverImageRegex = @"readinglist\d+"; public const string ReadingListCoverImageRegex = @"readinglist\d+";
/// <summary> /// <summary>
/// Width of the Thumbnail generation /// Width of the Thumbnail generation
/// </summary> /// </summary>
@ -69,10 +81,26 @@ public class ImageService : IImageService
/// </summary> /// </summary>
public const int LibraryThumbnailWidth = 32; public const int LibraryThumbnailWidth = 32;
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService) private static readonly string[] ValidIconRelations = {
"icon",
"apple-touch-icon",
"apple-touch-icon-precomposed",
"apple-touch-icon icon-precomposed" // ComicVine has it combined
};
/// <summary>
/// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon)
/// </summary>
private static readonly IDictionary<string, string> FaviconUrlMapper = new Dictionary<string, string>
{
["https://app.plex.tv"] = "https://plex.tv"
};
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService, IEasyCachingProviderFactory cacheFactory)
{ {
_logger = logger; _logger = logger;
_directoryService = directoryService; _directoryService = directoryService;
_cacheFactory = cacheFactory;
} }
public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1) public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1)
@ -90,14 +118,14 @@ public class ImageService : IImageService
} }
} }
public string GetCoverImage(string path, string fileName, string outputDirectory, bool saveAsWebP = false) public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
if (string.IsNullOrEmpty(path)) return string.Empty; if (string.IsNullOrEmpty(path)) return string.Empty;
try try
{ {
using var thumbnail = Image.Thumbnail(path, ThumbnailWidth); using var thumbnail = Image.Thumbnail(path, ThumbnailWidth);
var filename = fileName + (saveAsWebP ? ".webp" : ".png"); var filename = fileName + encodeFormat.GetExtension();
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename; return filename;
} }
@ -116,12 +144,12 @@ public class ImageService : IImageService
/// <param name="stream">Stream to write to disk. Ensure this is rewinded.</param> /// <param name="stream">Stream to write to disk. Ensure this is rewinded.</param>
/// <param name="fileName">filename to save as without extension</param> /// <param name="fileName">filename to save as without extension</param>
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param> /// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
/// <param name="saveAsWebP">Export the file as webP otherwise will default to png</param> /// <param name="encodeFormat">Export the file as the passed encoding</param>
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns> /// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, bool saveAsWebP = false) public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth); using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth);
var filename = fileName + (saveAsWebP ? ".webp" : ".png"); var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory); _directoryService.ExistOrCreate(outputDirectory);
try try
{ {
@ -131,10 +159,10 @@ public class ImageService : IImageService
return filename; return filename;
} }
public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, bool saveAsWebP = false) public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
using var thumbnail = Image.Thumbnail(sourceFile, ThumbnailWidth); using var thumbnail = Image.Thumbnail(sourceFile, ThumbnailWidth);
var filename = fileName + (saveAsWebP ? ".webp" : ".png"); var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory); _directoryService.ExistOrCreate(outputDirectory);
try try
{ {
@ -144,11 +172,11 @@ public class ImageService : IImageService
return filename; return filename;
} }
public Task<string> ConvertToWebP(string filePath, string outputPath) public Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat)
{ {
var file = _directoryService.FileSystem.FileInfo.New(filePath); var file = _directoryService.FileSystem.FileInfo.New(filePath);
var fileName = file.Name.Replace(file.Extension, string.Empty); var fileName = file.Name.Replace(file.Extension, string.Empty);
var outputFile = Path.Join(outputPath, fileName + ".webp"); var outputFile = Path.Join(outputPath, fileName + encodeFormat.GetExtension());
using var sourceImage = Image.NewFromFile(filePath, false, Enums.Access.SequentialUnbuffered); using var sourceImage = Image.NewFromFile(filePath, false, Enums.Access.SequentialUnbuffered);
sourceImage.WriteToFile(outputFile); sourceImage.WriteToFile(outputFile);
@ -177,14 +205,133 @@ public class ImageService : IImageService
return false; return false;
} }
public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
{
// Parse the URL to get the domain (including subdomain)
var uri = new Uri(url);
var domain = uri.Host;
var baseUrl = uri.Scheme + "://" + uri.Host;
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon);
var res = await provider.GetAsync<string>(baseUrl);
if (res.HasValue)
{
_logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", baseUrl);
throw new KavitaException($"Kavita has already tried to fetch from {baseUrl} and failed. Skipping duplicate check");
}
await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10));
if (FaviconUrlMapper.TryGetValue(baseUrl, out var value))
{
url = value;
}
var correctSizeLink = string.Empty;
try
{
var htmlContent = url.GetStringAsync().Result;
var htmlDocument = new HtmlDocument();
htmlDocument.LoadHtml(htmlContent);
var pngLinks = htmlDocument.DocumentNode.Descendants("link")
.Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty)))
.Select(link => link.GetAttributeValue("href", string.Empty))
.Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase))
.ToList();
correctSizeLink = (pngLinks?.FirstOrDefault(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault());
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain);
}
try
{
correctSizeLink = FallbackToKavitaReaderFavicon(baseUrl);
if (string.IsNullOrEmpty(correctSizeLink))
{
throw new KavitaException($"Could not grab favicon from {baseUrl}");
}
var finalUrl = correctSizeLink;
// If starts with //, it's coming usually from an offsite cdn
if (correctSizeLink.StartsWith("//"))
{
finalUrl = "https:" + correctSizeLink;
}
else if (!correctSizeLink.StartsWith(uri.Scheme))
{
finalUrl = Url.Combine(baseUrl, correctSizeLink);
}
_logger.LogTrace("Fetching favicon from {Url}", finalUrl);
// Download the favicon.ico file using Flurl
var faviconStream = await finalUrl
.AllowHttpStatus("2xx,304")
.GetStreamAsync();
// Create the destination file path
using var image = Image.PngloadStream(faviconStream);
var filename = $"{domain}{encodeFormat.GetExtension()}";
switch (encodeFormat)
{
case EncodeFormat.PNG:
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
break;
case EncodeFormat.WEBP:
image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename));
break;
case EncodeFormat.AVIF:
image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename));
break;
default:
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
}
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
return filename;
}catch (Exception ex)
{
_logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain);
throw;
}
}
private static string FallbackToKavitaReaderFavicon(string baseUrl)
{
var correctSizeLink = string.Empty;
var allOverrides = "https://kavitareader.com/assets/favicons/urls.txt".GetStringAsync().Result;
if (!string.IsNullOrEmpty(allOverrides))
{
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
var externalFile = allOverrides
.Split("\n")
.FirstOrDefault(url =>
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
));
if (string.IsNullOrEmpty(externalFile))
{
throw new KavitaException($"Could not grab favicon from {baseUrl}");
}
correctSizeLink = "https://kavitareader.com/assets/favicons/" + externalFile;
}
return correctSizeLink;
}
/// <inheritdoc /> /// <inheritdoc />
public string CreateThumbnailFromBase64(string encodedImage, string fileName, bool saveAsWebP = false, int thumbnailWidth = ThumbnailWidth) public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth)
{ {
try try
{ {
using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth); using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth);
fileName += (saveAsWebP ? ".webp" : ".png"); fileName += encodeFormat.GetExtension();
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName)); thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName));
return fileName; return fileName;
} }
@ -244,6 +391,7 @@ public class ImageService : IImageService
/// <returns></returns> /// <returns></returns>
public static string GetReadingListFormat(int readingListId) public static string GetReadingListFormat(int readingListId)
{ {
// ReSharper disable once StringLiteralTypo
return $"readinglist{readingListId}"; return $"readinglist{readingListId}";
} }
@ -257,6 +405,11 @@ public class ImageService : IImageService
return $"thumbnail{chapterId}"; return $"thumbnail{chapterId}";
} }
public static string GetWebLinkFormat(string url, EncodeFormat encodeFormat)
{
return $"{new Uri(url).Host}{encodeFormat.GetExtension()}";
}
public static string CreateMergedImage(List<string> coverImages, string dest) public static string CreateMergedImage(List<string> coverImages, string dest)
{ {

View file

@ -0,0 +1,312 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Entities.Enums;
using API.Extensions;
using API.SignalR;
using Hangfire;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface IMediaConversionService
{
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllBookmarkToEncoding();
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllCoversToEncoding();
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllManagedMediaToEncodingFormat();
Task<string> SaveAsEncodingFormat(string imageDirectory, string filename, string targetFolder,
EncodeFormat encodeFormat);
}
public class MediaConversionService : IMediaConversionService
{
public const string Name = "MediaConversionService";
public static readonly string[] ConversionMethods = {"ConvertAllBookmarkToEncoding", "ConvertAllCoversToEncoding", "ConvertAllManagedMediaToEncodingFormat"};
private readonly IUnitOfWork _unitOfWork;
private readonly IImageService _imageService;
private readonly IEventHub _eventHub;
private readonly IDirectoryService _directoryService;
private readonly ILogger<MediaConversionService> _logger;
public MediaConversionService(IUnitOfWork unitOfWork, IImageService imageService, IEventHub eventHub,
IDirectoryService directoryService, ILogger<MediaConversionService> logger)
{
_unitOfWork = unitOfWork;
_imageService = imageService;
_eventHub = eventHub;
_directoryService = directoryService;
_logger = logger;
}
/// <summary>
/// Converts all Kavita managed media (bookmarks, covers, favicons, etc) to the saved target encoding.
/// Do not invoke anyway except via Hangfire.
/// </summary>
/// <remarks>This is a long-running job</remarks>
/// <returns></returns>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllManagedMediaToEncodingFormat()
{
await ConvertAllBookmarkToEncoding();
await ConvertAllCoversToEncoding();
await CoverAllFaviconsToEncoding();
}
/// <summary>
/// This is a long-running job that will convert all bookmarks into a format that is not PNG. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllBookmarkToEncoding()
{
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (encodeFormat == EncodeFormat.PNG)
{
_logger.LogError("Cannot convert media to PNG");
return;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(0F, ProgressEventType.Started));
var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync())
.Where(b => !b.FileName.EndsWith(encodeFormat.GetExtension())).ToList();
var count = 1F;
foreach (var bookmark in bookmarks)
{
bookmark.FileName = await SaveAsEncodingFormat(bookmarkDirectory, bookmark.FileName,
BookmarkService.BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId), encodeFormat);
_unitOfWork.UserRepository.Update(bookmark);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(count / bookmarks.Count, ProgressEventType.Updated));
count++;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[MediaConversionService] Converted bookmarks to {Format}", encodeFormat);
}
/// <summary>
/// This is a long-running job that will convert all covers into WebP. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllCoversToEncoding()
{
var coverDirectory = _directoryService.CoverImageDirectory;
var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (encodeFormat == EncodeFormat.PNG)
{
_logger.LogError("Cannot convert media to PNG");
return;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of all covers to {Format}", encodeFormat);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(0F, ProgressEventType.Started));
var chapterCovers = await _unitOfWork.ChapterRepository.GetAllChaptersWithCoversInDifferentEncoding(encodeFormat);
var seriesCovers = await _unitOfWork.SeriesRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
var readingListCovers = await _unitOfWork.ReadingListRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
var libraryCovers = await _unitOfWork.LibraryRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
var collectionCovers = await _unitOfWork.CollectionTagRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
var totalCount = chapterCovers.Count + seriesCovers.Count + readingListCovers.Count +
libraryCovers.Count + collectionCovers.Count;
var count = 1F;
_logger.LogInformation("[MediaConversionService] Starting conversion of chapters");
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(0, ProgressEventType.Started));
foreach (var chapter in chapterCovers)
{
if (string.IsNullOrEmpty(chapter.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, chapter.CoverImage, coverDirectory, encodeFormat);
chapter.CoverImage = Path.GetFileName(newFile);
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of series");
foreach (var series in seriesCovers)
{
if (string.IsNullOrEmpty(series.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, series.CoverImage, coverDirectory, encodeFormat);
series.CoverImage = Path.GetFileName(newFile);
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of libraries");
foreach (var library in libraryCovers)
{
if (string.IsNullOrEmpty(library.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, library.CoverImage, coverDirectory, encodeFormat);
library.CoverImage = Path.GetFileName(newFile);
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of reading lists");
foreach (var readingList in readingListCovers)
{
if (string.IsNullOrEmpty(readingList.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, readingList.CoverImage, coverDirectory, encodeFormat);
readingList.CoverImage = Path.GetFileName(newFile);
_unitOfWork.ReadingListRepository.Update(readingList);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of collections");
foreach (var collection in collectionCovers)
{
if (string.IsNullOrEmpty(collection.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, collection.CoverImage, coverDirectory, encodeFormat);
collection.CoverImage = Path.GetFileName(newFile);
_unitOfWork.CollectionTagRepository.Update(collection);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
// Now null out all series and volumes that aren't webp or custom
var nonCustomOrConvertedVolumeCovers = await _unitOfWork.VolumeRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
foreach (var volume in nonCustomOrConvertedVolumeCovers)
{
if (string.IsNullOrEmpty(volume.CoverImage)) continue;
volume.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
_unitOfWork.VolumeRepository.Update(volume);
await _unitOfWork.CommitAsync();
}
var nonCustomOrConvertedSeriesCovers = await _unitOfWork.SeriesRepository.GetAllWithCoversInDifferentEncoding(encodeFormat, false);
foreach (var series in nonCustomOrConvertedSeriesCovers)
{
if (string.IsNullOrEmpty(series.CoverImage)) continue;
series.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[MediaConversionService] Converted covers to {Format}", encodeFormat);
}
private async Task CoverAllFaviconsToEncoding()
{
var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (encodeFormat == EncodeFormat.PNG)
{
_logger.LogError("Cannot convert media to PNG");
return;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of favicons to {Format}", encodeFormat);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(0F, ProgressEventType.Started));
var pngFavicons = _directoryService.GetFiles(_directoryService.FaviconDirectory)
.Where(b => !b.EndsWith(encodeFormat.GetExtension())).
ToList();
var count = 1F;
foreach (var file in pngFavicons)
{
await SaveAsEncodingFormat(_directoryService.FaviconDirectory, _directoryService.FileSystem.FileInfo.New(file).Name, _directoryService.FaviconDirectory,
encodeFormat);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(count / pngFavicons.Count, ProgressEventType.Updated));
count++;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[MediaConversionService] Converted favicons to {Format}", encodeFormat);
}
/// <summary>
/// Converts an image file, deletes original and returns the new path back
/// </summary>
/// <param name="imageDirectory">Full Path to where files are stored</param>
/// <param name="filename">The file to convert</param>
/// <param name="targetFolder">Full path to where files should be stored or any stem</param>
/// <returns></returns>
public async Task<string> SaveAsEncodingFormat(string imageDirectory, string filename, string targetFolder, EncodeFormat encodeFormat)
{
// This must be Public as it's used in via Hangfire as a background task
var fullSourcePath = _directoryService.FileSystem.Path.Join(imageDirectory, filename);
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(filename).Name, string.Empty);
var newFilename = string.Empty;
_logger.LogDebug("Converting {Source} image into {Encoding} at {Target}", fullSourcePath, encodeFormat, fullTargetDirectory);
if (!File.Exists(fullSourcePath))
{
_logger.LogError("Requested to convert {File} but it doesn't exist", fullSourcePath);
return newFilename;
}
try
{
// Convert target file to format then delete original target file
try
{
var targetFile = await _imageService.ConvertToEncodingFormat(fullSourcePath, fullTargetDirectory, encodeFormat);
var targetName = new FileInfo(targetFile).Name;
newFilename = Path.Join(targetFolder, targetName);
_directoryService.DeleteFiles(new[] {fullSourcePath});
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert image {FilePath} to {Format}", filename, encodeFormat);
newFilename = filename;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert image to {Format}", encodeFormat);
}
return newFilename;
}
}

View file

@ -0,0 +1,67 @@
using System;
using System.Threading.Tasks;
using API.Data;
using API.Helpers.Builders;
using Hangfire;
namespace API.Services;
public enum MediaErrorProducer
{
BookService = 0,
ArchiveService = 1
}
public interface IMediaErrorService
{
Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, string details);
void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, string details);
Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, Exception ex);
void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, Exception ex);
}
public class MediaErrorService : IMediaErrorService
{
private readonly IUnitOfWork _unitOfWork;
public MediaErrorService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, Exception ex)
{
await ReportMediaIssueAsync(filename, producer, errorMessage, ex.Message);
}
public void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, Exception ex)
{
// To avoid overhead on commits, do async. We don't need to wait.
BackgroundJob.Enqueue(() => ReportMediaIssueAsync(filename, producer, errorMessage, ex.Message));
}
public void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, string details)
{
// To avoid overhead on commits, do async. We don't need to wait.
BackgroundJob.Enqueue(() => ReportMediaIssueAsync(filename, producer, errorMessage, details));
}
public async Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, string details)
{
var error = new MediaErrorBuilder(filename)
.WithComment(errorMessage)
.WithDetails(details)
.Build();
if (await _unitOfWork.MediaErrorRepository.ExistsAsync(error))
{
return;
}
_unitOfWork.MediaErrorRepository.Attach(error);
await _unitOfWork.CommitAsync();
}
}

View file

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using API.Comparators; using API.Comparators;
using API.Data; using API.Data;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
using API.SignalR; using API.SignalR;
@ -32,7 +33,7 @@ public interface IMetadataService
/// <param name="forceUpdate">Overrides any cache logic and forces execution</param> /// <param name="forceUpdate">Overrides any cache logic and forces execution</param>
Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true); Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true);
Task GenerateCoversForSeries(Series series, bool convertToWebP, bool forceUpdate = false); Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, bool forceUpdate = false);
Task RemoveAbandonedMetadataKeys(); Task RemoveAbandonedMetadataKeys();
} }
@ -63,8 +64,8 @@ public class MetadataService : IMetadataService
/// </summary> /// </summary>
/// <param name="chapter"></param> /// <param name="chapter"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param> /// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
/// <param name="convertToWebPOnWrite">Convert image to WebP when extracting the cover</param> /// <param name="encodeFormat">Convert image to Encoding Format when extracting the cover</param>
private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, bool convertToWebPOnWrite) private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat)
{ {
var firstFile = chapter.Files.MinBy(x => x.Chapter); var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null) return Task.FromResult(false); if (firstFile == null) return Task.FromResult(false);
@ -78,7 +79,7 @@ public class MetadataService : IMetadataService
_logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath);
chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath,
ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, convertToWebPOnWrite); ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat);
_unitOfWork.ChapterRepository.Update(chapter); _unitOfWork.ChapterRepository.Update(chapter);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter));
return Task.FromResult(true); return Task.FromResult(true);
@ -141,8 +142,8 @@ public class MetadataService : IMetadataService
/// </summary> /// </summary>
/// <param name="series"></param> /// <param name="series"></param>
/// <param name="forceUpdate"></param> /// <param name="forceUpdate"></param>
/// <param name="convertToWebP"></param> /// <param name="encodeFormat"></param>
private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, bool convertToWebP) private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat)
{ {
_logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName); _logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName);
try try
@ -155,7 +156,7 @@ public class MetadataService : IMetadataService
var index = 0; var index = 0;
foreach (var chapter in volume.Chapters) foreach (var chapter in volume.Chapters)
{ {
var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, convertToWebP); var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat);
// If cover was update, either the file has changed or first scan and we should force a metadata update // If cover was update, either the file has changed or first scan and we should force a metadata update
UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated); UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated);
if (index == 0 && chapterUpdated) if (index == 0 && chapterUpdated)
@ -207,7 +208,7 @@ public class MetadataService : IMetadataService
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.CoverUpdateProgressEvent(library.Id, 0F, ProgressEventType.Started, $"Starting {library.Name}")); MessageFactory.CoverUpdateProgressEvent(library.Id, 0F, ProgressEventType.Started, $"Starting {library.Name}"));
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++)
{ {
@ -237,7 +238,7 @@ public class MetadataService : IMetadataService
try try
{ {
await ProcessSeriesCoverGen(series, forceUpdate, convertToWebP); await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -287,23 +288,23 @@ public class MetadataService : IMetadataService
return; return;
} }
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
await GenerateCoversForSeries(series, convertToWebP, forceUpdate); await GenerateCoversForSeries(series, encodeFormat, forceUpdate);
} }
/// <summary> /// <summary>
/// Generate Cover for a Series. This is used by Scan Loop and should not be invoked directly via User Interaction. /// Generate Cover for a Series. This is used by Scan Loop and should not be invoked directly via User Interaction.
/// </summary> /// </summary>
/// <param name="series">A full Series, with metadata, chapters, etc</param> /// <param name="series">A full Series, with metadata, chapters, etc</param>
/// <param name="convertToWebP">When saving the file, use WebP encoding instead of PNG</param> /// <param name="encodeFormat">When saving the file, what encoding should be used</param>
/// <param name="forceUpdate"></param> /// <param name="forceUpdate"></param>
public async Task GenerateCoversForSeries(Series series, bool convertToWebP, bool forceUpdate = false) public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, bool forceUpdate = false)
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name)); MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name));
await ProcessSeriesCoverGen(series, forceUpdate, convertToWebP); await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat);
if (_unitOfWork.HasChanges()) if (_unitOfWork.HasChanges())

View file

@ -236,7 +236,6 @@ public class ReaderService : IReaderService
try try
{ {
// TODO: Rewrite this code to just pull user object with progress for that particular appuserprogress, else create it
var userProgress = var userProgress =
await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId); await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId);
@ -479,10 +478,9 @@ public class ReaderService : IReaderService
/// <returns></returns> /// <returns></returns>
public async Task<ChapterDto> GetContinuePoint(int seriesId, int userId) public async Task<ChapterDto> GetContinuePoint(int seriesId, int userId)
{ {
var progress = (await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId)).ToList();
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
if (progress.Count == 0) if (!await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId))
{ {
// I think i need a way to sort volumes last // I think i need a way to sort volumes last
return volumes.OrderBy(v => double.Parse(v.Number + string.Empty), _chapterSortComparer).First().Chapters return volumes.OrderBy(v => double.Parse(v.Number + string.Empty), _chapterSortComparer).First().Chapters
@ -503,7 +501,8 @@ public class ReaderService : IReaderService
if (currentlyReadingChapter != null) return currentlyReadingChapter; if (currentlyReadingChapter != null) return currentlyReadingChapter;
// Order with volume 0 last so we prefer the natural order // Order with volume 0 last so we prefer the natural order
return FindNextReadingChapter(volumes.OrderBy(v => v.Number, SortComparerZeroLast.Default).SelectMany(v => v.Chapters).ToList()); return FindNextReadingChapter(volumes.OrderBy(v => v.Number, SortComparerZeroLast.Default)
.SelectMany(v => v.Chapters.OrderBy(c => double.Parse(c.Number))).ToList());
} }
private static ChapterDto FindNextReadingChapter(IList<ChapterDto> volumeChapters) private static ChapterDto FindNextReadingChapter(IList<ChapterDto> volumeChapters)
@ -524,13 +523,14 @@ public class ReaderService : IReaderService
return lastChapter; return lastChapter;
} }
// If the last chapter didn't fit, then we need the next chapter without any progress // If the last chapter didn't fit, then we need the next chapter without full progress
var firstChapterWithoutProgress = volumeChapters.FirstOrDefault(c => c.PagesRead == 0); var firstChapterWithoutProgress = volumeChapters.FirstOrDefault(c => c.PagesRead < c.Pages);
if (firstChapterWithoutProgress != null) if (firstChapterWithoutProgress != null)
{ {
return firstChapterWithoutProgress; return firstChapterWithoutProgress;
} }
// chaptersWithProgress are all read, then we need to get the next chapter that doesn't have progress // chaptersWithProgress are all read, then we need to get the next chapter that doesn't have progress
var lastIndexWithProgress = volumeChapters.IndexOf(lastChapter); var lastIndexWithProgress = volumeChapters.IndexOf(lastChapter);
if (lastIndexWithProgress + 1 < volumeChapters.Count) if (lastIndexWithProgress + 1 < volumeChapters.Count)
@ -667,15 +667,15 @@ public class ReaderService : IReaderService
_directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, ImageService.GetThumbnailFormat(chapter.Id)); _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, ImageService.GetThumbnailFormat(chapter.Id));
try try
{ {
var saveAsWebp = var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP; (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (!Directory.Exists(outputDirectory)) if (!Directory.Exists(outputDirectory))
{ {
var outputtedThumbnails = cachedImages var outputtedThumbnails = cachedImages
.Select((img, idx) => .Select((img, idx) =>
_directoryService.FileSystem.Path.Join(outputDirectory, _directoryService.FileSystem.Path.Join(outputDirectory,
_imageService.WriteCoverThumbnail(img, $"{idx}", outputDirectory, saveAsWebp))) _imageService.WriteCoverThumbnail(img, $"{idx}", outputDirectory, encodeFormat)))
.ToArray(); .ToArray();
return CacheService.GetPageFromFiles(outputtedThumbnails, pageNum); return CacheService.GetPageFromFiles(outputtedThumbnails, pageNum);
} }

View file

@ -9,7 +9,7 @@ public interface IReadingItemService
{ {
ComicInfo? GetComicInfo(string filePath); ComicInfo? GetComicInfo(string filePath);
int GetNumberOfPages(string filePath, MangaFormat format); int GetNumberOfPages(string filePath, MangaFormat format);
string GetCoverImage(string filePath, string fileName, MangaFormat format, bool saveAsWebP); string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat);
void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1);
ParserInfo? ParseFile(string path, string rootPath, LibraryType type); ParserInfo? ParseFile(string path, string rootPath, LibraryType type);
} }
@ -89,6 +89,7 @@ public class ReadingItemService : IReadingItemService
} }
// This is first time ComicInfo is called
info.ComicInfo = GetComicInfo(path); info.ComicInfo = GetComicInfo(path);
if (info.ComicInfo == null) return info; if (info.ComicInfo == null) return info;
@ -160,7 +161,7 @@ public class ReadingItemService : IReadingItemService
} }
} }
public string GetCoverImage(string filePath, string fileName, MangaFormat format, bool saveAsWebP) public string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat)
{ {
if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(fileName)) if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(fileName))
{ {
@ -170,10 +171,10 @@ public class ReadingItemService : IReadingItemService
return format switch return format switch
{ {
MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP), MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP), MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP), MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP), MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
_ => string.Empty _ => string.Empty
}; };
} }

View file

@ -157,19 +157,19 @@ public class ReadingListService : IReadingListService
readingList.CoverImageLocked = dto.CoverImageLocked; readingList.CoverImageLocked = dto.CoverImageLocked;
if (NumberHelper.IsValidMonth(dto.StartingMonth)) if (NumberHelper.IsValidMonth(dto.StartingMonth) || dto.StartingMonth == 0)
{ {
readingList.StartingMonth = dto.StartingMonth; readingList.StartingMonth = dto.StartingMonth;
} }
if (NumberHelper.IsValidYear(dto.StartingYear)) if (NumberHelper.IsValidYear(dto.StartingYear) || dto.StartingYear == 0)
{ {
readingList.StartingYear = dto.StartingYear; readingList.StartingYear = dto.StartingYear;
} }
if (NumberHelper.IsValidMonth(dto.EndingMonth)) if (NumberHelper.IsValidMonth(dto.EndingMonth) || dto.EndingMonth == 0)
{ {
readingList.EndingMonth = dto.EndingMonth; readingList.EndingMonth = dto.EndingMonth;
} }
if (NumberHelper.IsValidYear(dto.EndingYear)) if (NumberHelper.IsValidYear(dto.EndingYear) || dto.EndingYear == 0)
{ {
readingList.EndingYear = dto.EndingYear; readingList.EndingYear = dto.EndingYear;
} }
@ -336,7 +336,7 @@ public class ReadingListService : IReadingListService
// .Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(); // .Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList();
// //
// var combinedFile = ImageService.CreateMergedImage(fullImages, _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, $"{readingListId}.png")); // var combinedFile = ImageService.CreateMergedImage(fullImages, _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, $"{readingListId}.png"));
// // webp needs to be handled // // webp/avif needs to be handled
// return combinedFile; // return combinedFile;
} }
@ -496,12 +496,13 @@ public class ReadingListService : IReadingListService
} }
readingList.Items = items; readingList.Items = items;
if (!_unitOfWork.HasChanges()) continue;
await CalculateReadingListAgeRating(readingList); await CalculateReadingListAgeRating(readingList);
if (_unitOfWork.HasChanges()) await _unitOfWork.CommitAsync(); // TODO: See if we can avoid this extra commit by reworking bottom logic
{ await CalculateStartAndEndDates(await _unitOfWork.ReadingListRepository.GetReadingListByTitleAsync(arcPair.Item1,
await _unitOfWork.CommitAsync(); user.Id, ReadingListIncludes.Items | ReadingListIncludes.ItemChapter));
}
await CalculateStartAndEndDates(await _unitOfWork.ReadingListRepository.GetReadingListByTitleAsync(arcPair.Item1, user.Id, ReadingListIncludes.Items | ReadingListIncludes.ItemChapter));
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
} }
} }
@ -512,23 +513,24 @@ public class ReadingListService : IReadingListService
var data = new List<Tuple<string, string>>(); var data = new List<Tuple<string, string>>();
if (string.IsNullOrEmpty(storyArc)) return data; if (string.IsNullOrEmpty(storyArc)) return data;
var arcs = storyArc.Split(","); var arcs = storyArc.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var arcNumbers = storyArcNumbers.Split(","); var arcNumbers = storyArcNumbers.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (arcNumbers.Count(s => !string.IsNullOrEmpty(s)) != arcs.Length) if (arcNumbers.Count(s => !string.IsNullOrEmpty(s)) != arcs.Length)
{ {
_logger.LogWarning("There is a mismatch on StoryArc and StoryArcNumber for {FileName}. Def", filename); _logger.LogWarning("There is a mismatch on StoryArc and StoryArcNumber for {FileName}", filename);
} }
var maxPairs = Math.Min(arcs.Length, arcNumbers.Length); var maxPairs = Math.Max(arcs.Length, arcNumbers.Length);
for (var i = 0; i < maxPairs; i++) for (var i = 0; i < maxPairs; i++)
{ {
// When there is a mismatch on arcs and arc numbers, then we should default to a high number var arcNumber = int.MaxValue.ToString();
if (string.IsNullOrEmpty(arcNumbers[i]) && !string.IsNullOrEmpty(arcs[i])) if (arcNumbers.Length > i)
{ {
arcNumbers[i] = int.MaxValue.ToString(); arcNumber = arcNumbers[i];
} }
if (string.IsNullOrEmpty(arcs[i]) || !int.TryParse(arcNumbers[i], out _)) continue;
data.Add(new Tuple<string, string>(arcs[i], arcNumbers[i])); if (string.IsNullOrEmpty(arcs[i]) || !int.TryParse(arcNumber, out _)) continue;
data.Add(new Tuple<string, string>(arcs[i], arcNumber));
} }
return data; return data;

View file

@ -48,14 +48,25 @@ public class SeriesService : ISeriesService
/// <summary> /// <summary>
/// Returns the first chapter for a series to extract metadata from (ie Summary, etc) /// Returns the first chapter for a series to extract metadata from (ie Summary, etc)
/// </summary> /// </summary>
/// <param name="series"></param> /// <param name="series">The full series with all volumes and chapters on it</param>
/// <param name="isBookLibrary"></param>
/// <returns></returns> /// <returns></returns>
public static Chapter? GetFirstChapterForMetadata(Series series, bool isBookLibrary) public static Chapter? GetFirstChapterForMetadata(Series series)
{ {
return series.Volumes.OrderBy(v => v.Number, ChapterSortComparer.Default) var sortedVolumes = series.Volumes.OrderBy(v => v.Number, ChapterSortComparer.Default);
var minVolumeNumber = sortedVolumes
.Where(v => v.Number != 0)
.MinBy(v => v.Number);
var minChapter = series.Volumes
.SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default)) .SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default))
.FirstOrDefault(); .FirstOrDefault();
if (minVolumeNumber != null && minChapter != null && float.Parse(minChapter.Number) > minVolumeNumber.Number)
{
return minVolumeNumber.Chapters.MinBy(c => float.Parse(c.Number), ChapterSortComparer.Default);
}
return minChapter;
} }
public async Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) public async Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
@ -98,10 +109,10 @@ public class SeriesService : ISeriesService
} }
// This shouldn't be needed post v0.5.3 release // This shouldn't be needed post v0.5.3 release
if (string.IsNullOrEmpty(series.Metadata.Summary)) // if (string.IsNullOrEmpty(series.Metadata.Summary))
{ // {
series.Metadata.Summary = string.Empty; // series.Metadata.Summary = string.Empty;
} // }
if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata.Summary)) if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata.Summary))
{ {
@ -120,6 +131,19 @@ public class SeriesService : ISeriesService
series.Metadata.LanguageLocked = true; series.Metadata.LanguageLocked = true;
} }
if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata?.WebLinks))
{
series.Metadata.WebLinks = string.Empty;
} else
{
series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks
.Split(",")
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => s.Trim())!
);
}
series.Metadata.CollectionTags ??= new List<CollectionTag>(); series.Metadata.CollectionTags ??= new List<CollectionTag>();
UpdateCollectionsList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) => UpdateCollectionsList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) =>
{ {

View file

@ -31,7 +31,7 @@ public interface ITaskScheduler
void CancelStatsTasks(); void CancelStatsTasks();
Task RunStatCollection(); Task RunStatCollection();
void ScanSiteThemes(); void ScanSiteThemes();
Task CovertAllCoversToWebP(); Task CovertAllCoversToEncoding();
Task CleanupDbEntries(); Task CleanupDbEntries();
} }
@ -50,9 +50,9 @@ public class TaskScheduler : ITaskScheduler
private readonly IThemeService _themeService; private readonly IThemeService _themeService;
private readonly IWordCountAnalyzerService _wordCountAnalyzerService; private readonly IWordCountAnalyzerService _wordCountAnalyzerService;
private readonly IStatisticService _statisticService; private readonly IStatisticService _statisticService;
private readonly IBookmarkService _bookmarkService; private readonly IMediaConversionService _mediaConversionService;
public static BackgroundJobServer Client => new BackgroundJobServer(); public static BackgroundJobServer Client => new ();
public const string ScanQueue = "scan"; public const string ScanQueue = "scan";
public const string DefaultQueue = "default"; public const string DefaultQueue = "default";
public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read"; public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read";
@ -68,12 +68,17 @@ public class TaskScheduler : ITaskScheduler
private static readonly Random Rnd = new Random(); private static readonly Random Rnd = new Random();
private static readonly RecurringJobOptions RecurringJobOptions = new RecurringJobOptions()
{
TimeZone = TimeZoneInfo.Local
};
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService, public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
IBookmarkService bookmarkService) IMediaConversionService mediaConversionService)
{ {
_cacheService = cacheService; _cacheService = cacheService;
_logger = logger; _logger = logger;
@ -87,7 +92,7 @@ public class TaskScheduler : ITaskScheduler
_themeService = themeService; _themeService = themeService;
_wordCountAnalyzerService = wordCountAnalyzerService; _wordCountAnalyzerService = wordCountAnalyzerService;
_statisticService = statisticService; _statisticService = statisticService;
_bookmarkService = bookmarkService; _mediaConversionService = mediaConversionService;
} }
public async Task ScheduleTasks() public async Task ScheduleTasks()
@ -100,28 +105,28 @@ public class TaskScheduler : ITaskScheduler
var scanLibrarySetting = setting; var scanLibrarySetting = setting;
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false), RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false),
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local); () => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions);
} }
else else
{ {
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), Cron.Daily, RecurringJobOptions);
} }
setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value; setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value;
if (setting != null) if (setting != null)
{ {
_logger.LogDebug("Scheduling Backup Task for {Setting}", setting); _logger.LogDebug("Scheduling Backup Task for {Setting}", setting);
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local); RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), RecurringJobOptions);
} }
else else
{ {
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), Cron.Weekly, RecurringJobOptions);
} }
RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions);
} }
#region StatsTasks #region StatsTasks
@ -137,7 +142,7 @@ public class TaskScheduler : ITaskScheduler
} }
_logger.LogDebug("Scheduling stat collection daily"); _logger.LogDebug("Scheduling stat collection daily");
RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local); RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), RecurringJobOptions);
} }
public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false) public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false)
@ -182,10 +187,20 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _themeService.Scan()); BackgroundJob.Enqueue(() => _themeService.Scan());
} }
public async Task CovertAllCoversToWebP() /// <summary>
/// Do not invoke this manually, always enqueue on a background thread
/// </summary>
public async Task CovertAllCoversToEncoding()
{ {
await _bookmarkService.ConvertAllCoverToWebP(); var defaultParams = Array.Empty<object>();
_logger.LogInformation("[BookmarkService] Queuing tasks to update Series and Volume references via Cover Refresh"); if (MediaConversionService.ConversionMethods.Any(method =>
HasAlreadyEnqueuedTask(MediaConversionService.Name, method, defaultParams, DefaultQueue, true)))
{
return;
}
await _mediaConversionService.ConvertAllManagedMediaToEncodingFormat();
_logger.LogInformation("Queuing tasks to update Series and Volume references via Cover Refresh");
var libraryIds = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); var libraryIds = await _unitOfWork.LibraryRepository.GetLibrariesAsync();
foreach (var lib in libraryIds) foreach (var lib in libraryIds)
{ {
@ -200,8 +215,10 @@ public class TaskScheduler : ITaskScheduler
public void ScheduleUpdaterTasks() public void ScheduleUpdaterTasks()
{ {
_logger.LogInformation("Scheduling Auto-Update tasks"); _logger.LogInformation("Scheduling Auto-Update tasks");
// Schedule update check between noon and 6pm local time RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(5, 23)), new RecurringJobOptions()
RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local); {
TimeZone = TimeZoneInfo.Local
});
} }
public void ScanFolder(string folderPath, TimeSpan delay) public void ScanFolder(string folderPath, TimeSpan delay)
@ -399,6 +416,7 @@ public class TaskScheduler : ITaskScheduler
var scheduledJobs = JobStorage.Current.GetMonitoringApi().ScheduledJobs(0, int.MaxValue); var scheduledJobs = JobStorage.Current.GetMonitoringApi().ScheduledJobs(0, int.MaxValue);
ret = scheduledJobs.Any(j => ret = scheduledJobs.Any(j =>
j.Value.Job != null &&
j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) && j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) &&
j.Value.Job.Method.Name.Equals(methodName) && j.Value.Job.Method.Name.Equals(methodName) &&
j.Value.Job.Method.DeclaringType.Name.Equals(className)); j.Value.Job.Method.DeclaringType.Name.Equals(className));

View file

@ -120,6 +120,8 @@ public class BackupService : IBackupService
await SendProgress(0.75F, "Copying themes"); await SendProgress(0.75F, "Copying themes");
CopyThemesToBackupDirectory(tempDirectory); CopyThemesToBackupDirectory(tempDirectory);
await SendProgress(0.85F, "Copying favicons");
CopyFaviconsToBackupDirectory(tempDirectory);
try try
{ {
@ -141,6 +143,11 @@ public class BackupService : IBackupService
_directoryService.CopyFilesToDirectory(files, _directoryService.FileSystem.Path.Join(tempDirectory, "logs")); _directoryService.CopyFilesToDirectory(files, _directoryService.FileSystem.Path.Join(tempDirectory, "logs"));
} }
private void CopyFaviconsToBackupDirectory(string tempDirectory)
{
_directoryService.CopyDirectoryToDirectory(_directoryService.FaviconDirectory, tempDirectory);
}
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory) private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
{ {
var outputTempDir = Path.Join(tempDirectory, "covers"); var outputTempDir = Path.Join(tempDirectory, "covers");

View file

@ -58,14 +58,14 @@ public class CleanupService : ICleanupService
[AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)] [AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public async Task Cleanup() public async Task Cleanup()
{ {
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(), if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToEncoding", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true) || TaskScheduler.DefaultQueue, true) ||
TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToWebP", Array.Empty<object>(), TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToEncoding", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) TaskScheduler.DefaultQueue, true))
{ {
_logger.LogInformation("Cleanup put on hold as a conversion to WebP in progress"); _logger.LogInformation("Cleanup put on hold as a media conversion in progress");
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ErrorEvent("Cleanup", "Cleanup put on hold as a conversion to WebP in progress")); MessageFactory.ErrorEvent("Cleanup", "Cleanup put on hold as a media conversion in progress"));
return; return;
} }

View file

@ -172,7 +172,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
{ {
using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions); using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions);
var totalPages = book.Content.Html.Values; var totalPages = book.Content.Html.Local;
foreach (var bookPage in totalPages) foreach (var bookPage in totalPages)
{ {
var progress = Math.Max(0F, var progress = Math.Max(0F,
@ -238,10 +238,10 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
} }
private static async Task<int> GetWordCountFromHtml(EpubContentFileRef bookFile) private static async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile)
{ {
var doc = new HtmlDocument(); var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync()); doc.LoadHtml(await bookFile.ReadContentAsync());
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
if (textNodes == null) return 0; if (textNodes == null) return 0;

View file

@ -14,7 +14,7 @@ public static class Parser
private const int RegexTimeoutMs = 5000000; // 500 ms private const int RegexTimeoutMs = 5000000; // 500 ms
public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500); public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500);
public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif)"; public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)";
public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt"; public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt";
private const string BookFileExtensions = @"\.epub|\.pdf"; private const string BookFileExtensions = @"\.epub|\.pdf";
private const string XmlRegexExtensions = @"\.xml"; private const string XmlRegexExtensions = @"\.xml";
@ -321,13 +321,9 @@ public static class Parser
new Regex( new Regex(
@"(?<Series>.*)( ?- ?)Ch\.\d+-?\d*", @"(?<Series>.*)( ?- ?)Ch\.\d+-?\d*",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// [BAA]_Darker_than_Black_Omake-1.zip // [BAA]_Darker_than_Black_Omake-1, Bleach 001-002, Kodoja #001 (March 2016)
new Regex( new Regex(
@"^(?!Vol)(?<Series>.*)(-)\d+-?\d*", // This catches a lot of stuff ^(?!Vol)(?<Series>.*)( |_)(\d+) @"^(?!Vol)(?!Chapter)(?<Series>.+?)(-|_|\s|#)\d+(-\d+)?",
MatchOptions, RegexTimeout),
// Kodoja #001 (March 2016)
new Regex(
@"(?<Series>.*)(\s|_|-)#",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// Baketeriya ch01-05.zip, Akiiro Bousou Biyori - 01.jpg, Beelzebub_172_RHS.zip, Cynthia the Mission 29.rar, A Compendium of Ghosts - 031 - The Third Story_ Part 12 (Digital) (Cobalt001) // Baketeriya ch01-05.zip, Akiiro Bousou Biyori - 01.jpg, Beelzebub_172_RHS.zip, Cynthia the Mission 29.rar, A Compendium of Ghosts - 031 - The Third Story_ Part 12 (Digital) (Cobalt001)
new Regex( new Regex(
@ -1062,4 +1058,19 @@ public static class Parser
{ {
return string.IsNullOrEmpty(name) ? string.Empty : name.Replace('_', ' '); return string.IsNullOrEmpty(name) ? string.Empty : name.Replace('_', ' ');
} }
public static string? ExtractFilename(string fileUrl)
{
var matches = Parser.CssImageUrlRegex.Matches(fileUrl);
foreach (Match match in matches)
{
if (!match.Success) continue;
// NOTE: This is failing for //localhost:5000/api/book/29919/book-resources?file=OPS/images/tick1.jpg
var importFile = match.Groups["Filename"].Value;
if (!importFile.Contains("?")) return importFile;
}
return null;
}
} }

View file

@ -36,7 +36,7 @@ public interface IProcessSeries
void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false); void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false);
void UpdateChapters(Series series, Volume volume, IList<ParserInfo> parsedInfos, bool forceUpdate = false); void UpdateChapters(Series series, Volume volume, IList<ParserInfo> parsedInfos, bool forceUpdate = false);
void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false); void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false);
void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? info); void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo);
} }
/// <summary> /// <summary>
@ -230,7 +230,7 @@ public class ProcessSeries : IProcessSeries
_logger.LogError(ex, "[ScannerService] There was an exception updating series for {SeriesName}", series.Name); _logger.LogError(ex, "[ScannerService] There was an exception updating series for {SeriesName}", series.Name);
} }
await _metadataService.GenerateCoversForSeries(series, (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP); await _metadataService.GenerateCoversForSeries(series, (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs);
EnqueuePostSeriesProcessTasks(series.LibraryId, series.Id); EnqueuePostSeriesProcessTasks(series.LibraryId, series.Id);
} }
@ -266,8 +266,7 @@ public class ProcessSeries : IProcessSeries
public void UpdateSeriesMetadata(Series series, Library library) public void UpdateSeriesMetadata(Series series, Library library)
{ {
series.Metadata ??= new SeriesMetadataBuilder().Build(); series.Metadata ??= new SeriesMetadataBuilder().Build();
var isBook = library.Type == LibraryType.Book; var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
var firstChapter = SeriesService.GetFirstChapterForMetadata(series, isBook);
var firstFile = firstChapter?.Files.FirstOrDefault(); var firstFile = firstChapter?.Files.FirstOrDefault();
if (firstFile == null) return; if (firstFile == null) return;
@ -323,7 +322,7 @@ public class ProcessSeries : IProcessSeries
if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections) if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections)
{ {
_logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name); _logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name);
foreach (var collection in firstChapter.SeriesGroup.Split(',')) foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{ {
var normalizedName = Parser.Parser.Normalize(collection); var normalizedName = Parser.Parser.Normalize(collection);
if (!_collectionTags.TryGetValue(normalizedName, out var tag)) if (!_collectionTags.TryGetValue(normalizedName, out var tag))
@ -346,6 +345,8 @@ public class ProcessSeries : IProcessSeries
} }
#region People
// Handle People // Handle People
foreach (var chapter in chapters) foreach (var chapter in chapters)
{ {
@ -490,6 +491,8 @@ public class ProcessSeries : IProcessSeries
} }
}); });
#endregion
} }
public void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false) public void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
@ -657,19 +660,13 @@ public class ProcessSeries : IProcessSeries
} }
} }
public void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? info) public void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo)
{ {
if (comicInfo == null) return;
var firstFile = chapter.Files.MinBy(x => x.Chapter); var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null || if (firstFile == null ||
_cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) return; _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) return;
var comicInfo = info;
if (info == null)
{
comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath);
}
if (comicInfo == null) return;
_logger.LogTrace("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath); _logger.LogTrace("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath);
chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating); chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating);
@ -714,12 +711,24 @@ public class ProcessSeries : IProcessSeries
chapter.StoryArcNumber = comicInfo.StoryArcNumber; chapter.StoryArcNumber = comicInfo.StoryArcNumber;
} }
if (comicInfo.AlternateCount > 0) if (comicInfo.AlternateCount > 0)
{ {
chapter.AlternateCount = comicInfo.AlternateCount; chapter.AlternateCount = comicInfo.AlternateCount;
} }
if (!string.IsNullOrEmpty(comicInfo.Web))
{
chapter.WebLinks = string.Join(",", comicInfo.Web
.Split(",")
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => s.Trim())
);
}
if (!string.IsNullOrEmpty(comicInfo.Isbn))
{
chapter.ISBN = comicInfo.Isbn;
}
if (comicInfo.Count > 0) if (comicInfo.Count > 0)
{ {
@ -807,11 +816,15 @@ public class ProcessSeries : IProcessSeries
private static IList<string> GetTagValues(string comicInfoTagSeparatedByComma) private static IList<string> GetTagValues(string comicInfoTagSeparatedByComma)
{ {
// TODO: Move this to an extension and test it // TODO: Move this to an extension and test it
if (!string.IsNullOrEmpty(comicInfoTagSeparatedByComma)) if (string.IsNullOrEmpty(comicInfoTagSeparatedByComma))
{ {
return comicInfoTagSeparatedByComma.Split(",").Select(s => s.Trim()).DistinctBy(Parser.Parser.Normalize).ToList(); return ImmutableList<string>.Empty;
} }
return ImmutableList<string>.Empty;
return comicInfoTagSeparatedByComma.Split(",")
.Select(s => s.Trim())
.DistinctBy(Parser.Parser.Normalize)
.ToList();
} }
/// <summary> /// <summary>

Some files were not shown because too many files have changed in this diff Show more