diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml new file mode 100644 index 000000000..36532db40 --- /dev/null +++ b/.github/workflows/release-workflow.yml @@ -0,0 +1,175 @@ +name: Stable Workflow + +on: + push: + branches: ['release/**'] + pull_request: + branches: [ 'develop' ] + types: [ closed ] + workflow_dispatch: + +jobs: + debug: + runs-on: ubuntu-latest + steps: + - name: Debug Info + run: | + echo "Event Name: ${{ github.event_name }}" + echo "Ref: ${{ github.ref }}" + echo "Not Contains Release: ${{ !contains(github.head_ref, 'release') }}" + echo "Matches Develop: ${{ github.ref == 'refs/heads/develop' }}" + if_merged: + if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') + runs-on: ubuntu-latest + steps: + - run: | + echo The PR was merged + build: + name: Upload Kavita.Common for Version Bump + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/upload-artifact@v4 + with: + name: csproj + path: Kavita.Common/Kavita.Common.csproj + + stable: + name: Build Stable and Nightly Docker if Release + needs: [ build ] + if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Find Current Pull Request + uses: jwalton/gh-find-current-pr@v1 + id: findPr + with: + state: all + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Parse PR body + id: parse-body + run: | + body="Read full changelog: https://github.com/Kareadita/Kavita/releases/latest" + + echo $body + echo "BODY=$body" >> $GITHUB_OUTPUT + + - name: Check Out Repo + uses: actions/checkout@v4 + with: + ref: develop + + - name: NodeJS to Compile WebUI + uses: actions/setup-node@v4 + with: + node-version: 20 + - run: | + + cd UI/Web || exit + echo 'Installing web dependencies' + npm ci + + echo 'Building UI' + npm run prod + + echo 'Copying back to Kavita wwwroot' + rsync -a dist/ ../../API/wwwroot/ + + cd ../ || exit + + - name: Get csproj Version + uses: kzrnm/get-net-sdk-project-versions-action@v2 + id: get-version + with: + proj-path: Kavita.Common/Kavita.Common.csproj + + - name: Echo csproj version + run: echo "${{steps.get-version.outputs.assembly-version}}" + + - name: Parse Version + run: | + version='${{steps.get-version.outputs.assembly-version}}' + newVersion=${version%.*} + echo $newVersion + echo "VERSION=$newVersion" >> $GITHUB_OUTPUT + id: parse-version + + - name: Compile dotnet app + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Install Swashbuckle CLI + run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli + + - run: ./monorepo-build.sh + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push stable + id: docker_build_stable + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: true + tags: jvmilazz0/kavita:latest, jvmilazz0/kavita:${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:latest, ghcr.io/kareadita/kavita:${{ steps.parse-version.outputs.VERSION }} + + - name: Build and push nightly + id: docker_build_nightly + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: true + tags: jvmilazz0/kavita:nightly, jvmilazz0/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:nightly, ghcr.io/kareadita/kavita:nightly-${{ steps.parse-version.outputs.VERSION }} + + - name: Image digest + run: echo ${{ steps.docker_build_stable.outputs.digest }} + + - name: Image digest + run: echo ${{ steps.docker_build_nightly.outputs.digest }} + + - name: Notify Discord + uses: rjstone/discord-webhook-notify@v1 + with: + severity: info + description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }} + details: '${{ steps.findPr.outputs.body }}' + text: <@&939225192553644133> A new stable build has been released. + webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }} + + - name: Notify Discord + uses: rjstone/discord-webhook-notify@v1 + with: + severity: info + description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }} + details: '${{ steps.findPr.outputs.body }}' + text: <@&939225459156217917> <@&939225350775406643> A new nightly build has been released for docker. + webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }} diff --git a/.gitignore b/.gitignore index 584f0026e..612917a47 100644 --- a/.gitignore +++ b/.gitignore @@ -534,3 +534,8 @@ UI/Web/.vscode/settings.json /API.Tests/Services/Test Data/ArchiveService/CoverImages/output/* UI/Web/.angular/ BenchmarkDotNet.Artifacts + + +API.Tests/Services/Test Data/ImageService/Covers/*_output* +API.Tests/Services/Test Data/ImageService/Covers/*_baseline* +API.Tests/Services/Test Data/ImageService/Covers/index.html diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 1651d35d0..e65229ab5 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -9,8 +9,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -28,6 +28,7 @@ + diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index 3b41b429a..64d303f4d 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -211,6 +211,7 @@ public class MangaParsingTests [InlineData("Max Level Returner เล่มที่ 5", "Max Level Returner")] [InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")] [InlineData("不安の種\uff0b - 01", "不安の種\uff0b")] + [InlineData("Giant Ojou-sama - Ch. 33.5 - Volume 04 Bonus Chapter", "Giant Ojou-sama")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga)); diff --git a/API.Tests/Services/ImageServiceTests.cs b/API.Tests/Services/ImageServiceTests.cs new file mode 100644 index 000000000..c868bfce2 --- /dev/null +++ b/API.Tests/Services/ImageServiceTests.cs @@ -0,0 +1,124 @@ +using System.IO; +using System.Linq; +using System.Text; +using API.Entities.Enums; +using API.Services; +using NetVips; +using Xunit; +using Image = NetVips.Image; + +namespace API.Tests.Services; + +public class ImageServiceTests +{ + private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageService/Covers"); + private const string OutputPattern = "_output"; + private const string BaselinePattern = "_baseline"; + + /// + /// Run this once to get the baseline generation + /// + [Fact] + public void GenerateBaseline() + { + GenerateFiles(BaselinePattern); + } + + /// + /// Change the Scaling/Crop code then run this continuously + /// + [Fact] + public void TestScaling() + { + GenerateFiles(OutputPattern); + GenerateHtmlFile(); + } + + private void GenerateFiles(string outputExtension) + { + // Step 1: Delete any images that have _output in the name + var outputFiles = Directory.GetFiles(_testDirectory, "*_output.*"); + foreach (var file in outputFiles) + { + File.Delete(file); + } + + // Step 2: Scan the _testDirectory for images + var imageFiles = Directory.GetFiles(_testDirectory, "*.*") + .Where(file => !file.EndsWith("html")) + .Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern)) + .ToList(); + + // Step 3: Process each image + foreach (var imagePath in imageFiles) + { + var fileName = Path.GetFileNameWithoutExtension(imagePath); + var dims = CoverImageSize.Default.GetDimensions(); + using var sourceImage = Image.NewFromFile(imagePath, false, Enums.Access.SequentialUnbuffered); + + var size = ImageService.GetSizeForDimensions(sourceImage, dims.Width, dims.Height); + var crop = ImageService.GetCropForDimensions(sourceImage, dims.Width, dims.Height); + + using var thumbnail = Image.Thumbnail(imagePath, dims.Width, dims.Height, + size: size, + crop: crop); + + var outputFileName = fileName + outputExtension + ".png"; + thumbnail.WriteToFile(Path.Join(_testDirectory, outputFileName)); + } + } + + private void GenerateHtmlFile() + { + var imageFiles = Directory.GetFiles(_testDirectory, "*.*") + .Where(file => !file.EndsWith("html")) + .Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern)) + .ToList(); + + var htmlBuilder = new StringBuilder(); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("Image Comparison"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("
"); + + foreach (var imagePath in imageFiles) + { + var fileName = Path.GetFileNameWithoutExtension(imagePath); + var baselinePath = Path.Combine(_testDirectory, fileName + "_baseline.png"); + var outputPath = Path.Combine(_testDirectory, fileName + "_output.png"); + var dims = CoverImageSize.Default.GetDimensions(); + + using var sourceImage = Image.NewFromFile(imagePath, false, Enums.Access.SequentialUnbuffered); + htmlBuilder.AppendLine("
"); + htmlBuilder.AppendLine($"

{fileName} ({((double) sourceImage.Width / sourceImage.Height).ToString("F2")}) - {ImageService.WillScaleWell(sourceImage, dims.Width, dims.Height)}

"); + htmlBuilder.AppendLine($"\"{fileName}\""); + if (File.Exists(baselinePath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + if (File.Exists(outputPath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + htmlBuilder.AppendLine("
"); + } + + htmlBuilder.AppendLine("
"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + + File.WriteAllText(Path.Combine(_testDirectory, "index.html"), htmlBuilder.ToString()); + } + +} diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-2.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-2.jpg new file mode 100644 index 000000000..b185d6e41 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-2.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-3.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-3.jpg new file mode 100644 index 000000000..99aafb10a Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-3.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-normal.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal.jpg new file mode 100644 index 000000000..91a8f9b8e Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-square.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-square.jpg new file mode 100644 index 000000000..6ee3931b3 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-square.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg new file mode 100644 index 000000000..3442c8b32 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png b/API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png new file mode 100644 index 000000000..eae5138c6 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/spread-cover.jpg b/API.Tests/Services/Test Data/ImageService/Covers/spread-cover.jpg new file mode 100644 index 000000000..449400181 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/spread-cover.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip-2.png b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip-2.png new file mode 100644 index 000000000..e89641384 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip-2.png differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip.jpg b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip.jpg new file mode 100644 index 000000000..469cb9bc3 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png b/API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png new file mode 100644 index 000000000..2ad5103fe Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png differ diff --git a/API/API.csproj b/API/API.csproj index 1375d7b79..5d31a09f1 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -12,9 +12,9 @@ latestmajor - - - + + + false @@ -53,8 +53,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -65,44 +65,44 @@ - - + + - + - + - + - + - + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/API/Controllers/CBLController.cs b/API/Controllers/CBLController.cs index 3d1139c9f..e84ef00e2 100644 --- a/API/Controllers/CBLController.cs +++ b/API/Controllers/CBLController.cs @@ -30,25 +30,25 @@ public class CblController : BaseApiController /// The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful. /// If this returns errors, the cbl will always be rejected by Kavita. /// - /// FormBody with parameter name of cbl + /// FormBody with parameter name of cbl + /// Use comic vine matching or not. Defaults to false /// [HttpPost("validate")] - public async Task> ValidateCbl([FromForm(Name = "cbl")] IFormFile file, - [FromForm(Name = "comicVineMatching")] bool comicVineMatching = false) + public async Task> ValidateCbl(IFormFile cbl, bool comicVineMatching = false) { var userId = User.GetUserId(); try { - var cbl = await SaveAndLoadCblFile(file); - var importSummary = await _readingListService.ValidateCblFile(userId, cbl, comicVineMatching); - importSummary.FileName = file.FileName; + var cblReadingList = await SaveAndLoadCblFile(cbl); + var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, comicVineMatching); + importSummary.FileName = cbl.FileName; return Ok(importSummary); } catch (ArgumentNullException) { return Ok(new CblImportSummaryDto() { - FileName = file.FileName, + FileName = cbl.FileName, Success = CblImportResult.Fail, Results = new List() { @@ -63,7 +63,7 @@ public class CblController : BaseApiController { return Ok(new CblImportSummaryDto() { - FileName = file.FileName, + FileName = cbl.FileName, Success = CblImportResult.Fail, Results = new List() { @@ -80,25 +80,26 @@ public class CblController : BaseApiController /// /// Performs the actual import (assuming dryRun = false) /// - /// FormBody with parameter name of cbl + /// FormBody with parameter name of cbl /// If true, will only emulate the import but not perform. This should be done to preview what will happen + /// Use comic vine matching or not. Defaults to false /// [HttpPost("import")] - public async Task> ImportCbl([FromForm(Name = "cbl")] IFormFile file, - [FromForm(Name = "dryRun")] bool dryRun = false, [FromForm(Name = "comicVineMatching")] bool comicVineMatching = false) + public async Task> ImportCbl(IFormFile cbl, bool dryRun = false, bool comicVineMatching = false) { try { var userId = User.GetUserId(); - var cbl = await SaveAndLoadCblFile(file); - var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun, comicVineMatching); - importSummary.FileName = file.FileName; + var cblReadingList = await SaveAndLoadCblFile(cbl); + var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, comicVineMatching); + importSummary.FileName = cbl.FileName; + return Ok(importSummary); } catch (ArgumentNullException) { return Ok(new CblImportSummaryDto() { - FileName = file.FileName, + FileName = cbl.FileName, Success = CblImportResult.Fail, Results = new List() { @@ -113,7 +114,7 @@ public class CblController : BaseApiController { return Ok(new CblImportSummaryDto() { - FileName = file.FileName, + FileName = cbl.FileName, Success = CblImportResult.Fail, Results = new List() { diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index eb467ab9f..43058d90f 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -21,7 +21,6 @@ using AutoMapper; using EasyCaching.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.Logging; using TaskScheduler = API.Services.TaskScheduler; @@ -134,7 +133,7 @@ public class LibraryController : BaseApiController if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); await _libraryWatcher.RestartWatching(); - _taskScheduler.ScanLibrary(library.Id); + await _taskScheduler.ScanLibrary(library.Id); await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, @@ -292,7 +291,7 @@ public class LibraryController : BaseApiController public async Task Scan(int libraryId, bool force = false) { if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId")); - _taskScheduler.ScanLibrary(libraryId, force); + await _taskScheduler.ScanLibrary(libraryId, force); return Ok(); } @@ -500,7 +499,7 @@ public class LibraryController : BaseApiController if (originalFoldersCount != dto.Folders.Count() || typeUpdate) { await _libraryWatcher.RestartWatching(); - _taskScheduler.ScanLibrary(library.Id); + await _taskScheduler.ScanLibrary(library.Id); } if (folderWatchingUpdate) diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 354cae2d4..509f8fda3 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -591,9 +591,22 @@ public class OpdsController : BaseApiController var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList(); foreach (var item in items) { - feed.Entries.Add( - CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}", - item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl)); + var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId); + + // If there is only one file underneath, add a direct acquisition link, otherwise add a subsection + if (chapterDto != null && chapterDto.Files.Count == 1) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId); + feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId, + chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl)); + } + else + { + feed.Entries.Add( + CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}", + item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl)); + } + } return CreateXmlResult(SerializeXml(feed)); } @@ -855,6 +868,7 @@ public class OpdsController : BaseApiController SetFeedId(feed, $"series-{series.Id}"); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}")); + var chapterDict = new Dictionary(); var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId); foreach (var volume in seriesDetail.Volumes) { @@ -866,6 +880,7 @@ public class OpdsController : BaseApiController var chapterDto = _mapper.Map(chapter); foreach (var mangaFile in chapter.Files) { + chapterDict.Add(chapterId, 0); feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map(mangaFile), series, chapterDto, apiKey, prefix, baseUrl)); } @@ -879,7 +894,7 @@ public class OpdsController : BaseApiController chapters = seriesDetail.Chapters; } - foreach (var chapter in chapters.Where(c => !c.IsSpecial)) + foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id))) { var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); var chapterDto = _mapper.Map(chapter); @@ -914,15 +929,15 @@ public class OpdsController : BaseApiController var (baseUrl, prefix) = await GetPrefix(); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); - var chapters = - (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)) - .OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); + // var chapters = + // (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)) + // .OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast); var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s"); - foreach (var chapter in chapters) + foreach (var chapter in volume.Chapters) { var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id, ChapterIncludes.Files | ChapterIncludes.People); foreach (var mangaFile in chapterDto.Files) @@ -1111,7 +1126,8 @@ public class OpdsController : BaseApiController }; } - private async Task CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) + private async Task CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, + MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) { var fileSize = mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) : @@ -1120,7 +1136,7 @@ public class OpdsController : BaseApiController var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath); var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath)); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); - var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey)); + var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId); var title = $"{series.Name}"; diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 11a50d614..db0b134ef 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -491,4 +491,59 @@ public class ReadingListController : BaseApiController if (string.IsNullOrEmpty(name)) return true; return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name)); } + + + + /// + /// Promote/UnPromote multiple reading lists in one go. Will only update the authenticated user's reading lists and will only work if the user has promotion role + /// + /// + /// + [HttpPost("promote-multiple")] + public async Task PromoteMultipleReadingLists(PromoteReadingListsDto dto) + { + // This needs to take into account owner as I can select other users cards + var userId = User.GetUserId(); + if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole)) + { + return BadRequest(await _localizationService.Translate(userId, "permission-denied")); + } + + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsByIds(dto.ReadingListIds); + + foreach (var readingList in readingLists) + { + if (readingList.AppUserId != userId) continue; + readingList.Promoted = dto.Promoted; + _unitOfWork.ReadingListRepository.Update(readingList); + } + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + + + /// + /// Delete multiple reading lists in one go + /// + /// + /// + [HttpPost("delete-multiple")] + public async Task DeleteMultipleReadingLists(DeleteReadingListsDto dto) + { + // This needs to take into account owner as I can select other users cards + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ReadingLists); + if (user == null) return Unauthorized(); + + user.ReadingLists = user.ReadingLists.Where(uc => !dto.ReadingListIds.Contains(uc.Id)).ToList(); + _unitOfWork.UserRepository.Update(user); + + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } } diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index a003551a1..87080312a 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Constants; using API.Data; @@ -7,11 +10,15 @@ using API.DTOs.Statistics; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Services; using API.Services.Plus; +using API.Services.Tasks.Scanner.Parser; +using CsvHelper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using MimeTypes; namespace API.Controllers; @@ -24,15 +31,18 @@ public class StatsController : BaseApiController private readonly UserManager _userManager; private readonly ILocalizationService _localizationService; private readonly ILicenseService _licenseService; + private readonly IDirectoryService _directoryService; public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, - UserManager userManager, ILocalizationService localizationService, ILicenseService licenseService) + UserManager userManager, ILocalizationService localizationService, + ILicenseService licenseService, IDirectoryService directoryService) { _statService = statService; _unitOfWork = unitOfWork; _userManager = userManager; _localizationService = localizationService; _licenseService = licenseService; + _directoryService = directoryService; } [HttpGet("user/{userId}/read")] @@ -111,6 +121,34 @@ public class StatsController : BaseApiController return Ok(await _statService.GetFileBreakdown()); } + /// + /// Generates a csv of all file paths for a given extension + /// + /// + [Authorize("RequireAdminRole")] + [HttpGet("server/file-extension")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task DownloadFilesByExtension(string fileExtension) + { + if (!Regex.IsMatch(fileExtension, Parser.SupportedExtensions)) + { + return BadRequest("Invalid file format"); + } + var tempFile = Path.Join(_directoryService.TempDirectory, + $"file_breakdown_{fileExtension.Replace(".", string.Empty)}.csv"); + + if (!_directoryService.FileSystem.File.Exists(tempFile)) + { + var results = await _statService.GetFilesByExtension(fileExtension); + await using var writer = new StreamWriter(tempFile); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + await csv.WriteRecordsAsync(results); + } + + return PhysicalFile(tempFile, MimeTypeMap.GetMimeType(Path.GetExtension(tempFile)), + System.Web.HttpUtility.UrlEncode(Path.GetFileName(tempFile)), true); + } + /// /// Returns reading history events for a give or all users, broken up by day, and format diff --git a/API/DTOs/ReadingLists/DeleteReadingListsDto.cs b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs new file mode 100644 index 000000000..8417f8132 --- /dev/null +++ b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.ReadingLists; + +public class DeleteReadingListsDto +{ + [Required] + public IList ReadingListIds { get; set; } +} diff --git a/API/DTOs/ReadingLists/PromoteReadingListsDto.cs b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs new file mode 100644 index 000000000..f64bbb5ca --- /dev/null +++ b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace API.DTOs.ReadingLists; + +public class PromoteReadingListsDto +{ + public IList ReadingListIds { get; init; } + public bool Promoted { get; init; } +} diff --git a/API/DTOs/Stats/FileExtensionExportDto.cs b/API/DTOs/Stats/FileExtensionExportDto.cs new file mode 100644 index 000000000..6ed554d75 --- /dev/null +++ b/API/DTOs/Stats/FileExtensionExportDto.cs @@ -0,0 +1,15 @@ +using CsvHelper.Configuration.Attributes; + +namespace API.DTOs.Stats; + +/// +/// Excel export for File Extension Report +/// +public class FileExtensionExportDto +{ + [Name("Path")] + public string FilePath { get; set; } + + [Name("Extension")] + public string Extension { get; set; } +} diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 63e3e8088..a83aa072b 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -13,7 +13,7 @@ public class UpdateNotificationDto /// Semver of the release version /// 0.4.3 /// - public required string UpdateVersion { get; init; } + public required string UpdateVersion { get; set; } /// /// Release body in HTML /// diff --git a/API/Data/ManualMigrations/MigrateEmailTemplates.cs b/API/Data/ManualMigrations/MigrateEmailTemplates.cs index ca0dc125b..0e406c386 100644 --- a/API/Data/ManualMigrations/MigrateEmailTemplates.cs +++ b/API/Data/ManualMigrations/MigrateEmailTemplates.cs @@ -21,10 +21,11 @@ public static class MigrateEmailTemplates var files = directoryService.GetFiles(directoryService.CustomizedTemplateDirectory); if (files.Any()) { - logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error"); return; } + logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error"); + // Write files to directory await DownloadAndWriteToFile(EmailChange, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailChange.html"), logger); await DownloadAndWriteToFile(EmailConfirm, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailConfirm.html"), logger); @@ -33,8 +34,7 @@ public static class MigrateEmailTemplates await DownloadAndWriteToFile(EmailTest, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailTest.html"), logger); - - logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error"); + logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error"); } private static async Task DownloadAndWriteToFile(string url, string filePath, ILogger logger) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index c04328116..a1d2d754e 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -49,6 +49,7 @@ public interface IReadingListRepository Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task RemoveReadingListsWithoutSeries(); Task GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); + Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items); } public class ReadingListRepository : IReadingListRepository @@ -156,6 +157,15 @@ public class ReadingListRepository : IReadingListRepository .FirstOrDefaultAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized) && x.AppUserId == userId); } + public async Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items) + { + return await _context.ReadingList + .Where(c => ids.Contains(c.Id)) + .Includes(includes) + .AsSplitQuery() + .ToListAsync(); + } + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 26e2208b2..0e1050c49 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -23,6 +23,10 @@ public enum VolumeIncludes Chapters = 2, People = 4, Tags = 8, + /// + /// This will include Chapters by default + /// + Files = 16 } public interface IVolumeRepository @@ -34,7 +38,7 @@ public interface IVolumeRepository Task GetVolumeCoverImageAsync(int volumeId); Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); Task> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters); - Task GetVolumeAsync(int volumeId); + Task GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files); Task GetVolumeDtoAsync(int volumeId, int userId); Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task> GetVolumes(int seriesId); @@ -173,11 +177,10 @@ public class VolumeRepository : IVolumeRepository /// /// /// - public async Task GetVolumeAsync(int volumeId) + public async Task GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files) { return await _context.Volume - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) + .Includes(includes) .AsSplitQuery() .SingleOrDefaultAsync(vol => vol.Id == volumeId); } diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 4890a8b90..be26a1762 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -65,22 +65,28 @@ public static class IncludesExtensions public static IQueryable Includes(this IQueryable queryable, VolumeIncludes includes) { - if (includes.HasFlag(VolumeIncludes.Chapters)) + if (includes.HasFlag(VolumeIncludes.Files)) { - queryable = queryable.Include(vol => vol.Chapters); + queryable = queryable + .Include(vol => vol.Chapters.OrderBy(c => c.SortOrder)) + .ThenInclude(c => c.Files); + } else if (includes.HasFlag(VolumeIncludes.Chapters)) + { + queryable = queryable + .Include(vol => vol.Chapters.OrderBy(c => c.SortOrder)); } if (includes.HasFlag(VolumeIncludes.People)) { queryable = queryable - .Include(vol => vol.Chapters) + .Include(vol => vol.Chapters.OrderBy(c => c.SortOrder)) .ThenInclude(c => c.People); } if (includes.HasFlag(VolumeIncludes.Tags)) { queryable = queryable - .Include(vol => vol.Chapters) + .Include(vol => vol.Chapters.OrderBy(c => c.SortOrder)) .ThenInclude(c => c.Tags); } @@ -104,7 +110,7 @@ public static class IncludesExtensions { query = query .Include(s => s.Volumes) - .ThenInclude(v => v.Chapters); + .ThenInclude(v => v.Chapters.OrderBy(c => c.SortOrder)); } if (includeFlags.HasFlag(SeriesIncludes.Related)) diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 5648e5b90..0358494f6 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -22,6 +22,7 @@ using API.DTOs.Search; using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.DTOs.SideNav; +using API.DTOs.Stats; using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; @@ -117,6 +118,10 @@ public class AutoMapperProfiles : Profile opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName))) + .ForMember(dest => dest.Imprints, + opt => + opt.MapFrom(src => + src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Letterers, opt => opt.MapFrom(src => @@ -329,5 +334,8 @@ public class AutoMapperProfiles : Profile opt.MapFrom(src => ReviewService.GetCharacters(src.Body))); CreateMap(); + + + CreateMap(); } } diff --git a/API/I18N/da.json b/API/I18N/da.json new file mode 100644 index 000000000..5b39ccc07 --- /dev/null +++ b/API/I18N/da.json @@ -0,0 +1,42 @@ +{ + "locked-out": "Du er låst ude, grundet for mange forsøg. Vent venligt 10 minutter.", + "disabled-account": "Din konto er deaktiveret. Kontakt server administratoren.", + "register-user": "Der opstod en fejl under brugerregistreringen", + "validate-email": "Der opstod en fejl under validering af emailen: {0}", + "confirm-email": "Du skal bekræfte din email først", + "confirm-token-gen": "Der opstod en fejl under oprettelsen af en bekræftigelsestoken", + "invalid-password": "Ugyldigt kodeord", + "username-taken": "Brugernavenet er allerede taget", + "generate-token": "Der opstod en fejl under generering af bekræftigelsesemailtoken. Se logbeskederne", + "generic-user-update": "Der opstod en fejl ved opdatering af brugeren", + "user-already-registered": "Brugeren er allerede registreret som {0}", + "manual-setup-fail": "Manuel opsætning kunne ikke færdiggøres. Venligst annuller, og genopret invitationen", + "not-accessible-password": "Din server er ikke tilgængelig. Et link til nulstilling af kodeord kan findes i loggen", + "user-migration-needed": "Denne bruger skal migreres. Få dem til at logge ud og ind igen for at starte migreringsflowet", + "invalid-username": "Ugyldigt brugernavn", + "generic-invite-email": "Der opstod en fejl under et forsøg på at sende invitationmail'en igen", + "check-updates": "Tjek for opdateringer", + "update-yearly-stats": "Opdater årlige statistikker", + "no-user": "Brugeren eksistere ikke", + "user-already-confirmed": "Brugeren er allerede bekræftet", + "admin-already-exists": "Administrator eksistere allerede", + "chapter-doesnt-exist": "Kapitel findes ikke", + "not-accessible": "Din server er ikke ekstern tilgængelig", + "email-sent": "Email afsendt", + "book-num": "Bog {0}", + "issue-num": "Nummer {0}{1}", + "chapter-num": "Kapitel {0}", + "backup": "Backup", + "age-restriction-update": "Der opstod en fejl ved opdateringen af aldersbegrænsningen", + "user-already-invited": "Brugeren er allerede inviteret med denne mail, og har ikke accepteret invitationen endnu.", + "invalid-email-confirmation": "Ugyldig mailbekræftigelse", + "password-updated": "Kodeord opdateret", + "denied": "Ikke tilladt", + "permission-denied": "Du har ikke tilladelse til at udføre denne operation", + "nothing-to-do": "Intet at lave", + "password-required": "Du skal skrive dit kodeord for at ændre din konto, medmindre du er en administrator", + "invalid-payload": "Ugyldig payload", + "invalid-token": "Ugyldig token", + "unable-to-reset-key": "Noget gik galt, det var ikke muligt at nulstille nøglen", + "share-multiple-emails": "Du kan ikke dele emails henover flere kontoer" +} diff --git a/API/I18N/de.json b/API/I18N/de.json index 8f97130f1..ff591354a 100644 --- a/API/I18N/de.json +++ b/API/I18N/de.json @@ -38,7 +38,7 @@ "generic-error": "Es ist ein Fehler ist aufgetreten, bitte versuchen Sie es erneut", "device-doesnt-exist": "Das Gerät existiert nicht", "generic-device-create": "Beim Erstellen des Geräts ist ein Fehler aufgetreten", - "send-to-kavita-email": "Das Senden an Gerät kann nicht ohne konfigurierten E-Mail-Dienst durchgeführt werden.", + "send-to-kavita-email": "Das Senden an das Gerät kann nicht mit dem E-Mail-Dienst von Kavita durchgeführt werden. Bitte konfigurieren Sie Ihren eigenen.", "send-to-device-status": "Übertrage Dateien auf Ihr Gerät", "series-doesnt-exist": "Die Serie existiert nicht", "volume-doesnt-exist": "Der Band existiert nicht", @@ -111,7 +111,7 @@ "user-no-access-library-from-series": "Der Benutzer hat keinen Zugang zu der Bibliothek, der zu dieser Serie gehört", "series-restricted-age-restriction": "Benutzer darf diese Serie aufgrund von Altersbeschränkungen nicht sehen", "book-num": "Buch {0}", - "issue-num": "Fehler {0}{1}", + "issue-num": "Ausgabe {0}{1}", "chapter-num": "Kapitel {0}", "reading-list-position": "Position konnte nicht aktualisiert werden", "libraries-restricted": "Benutzer hat keinen Zugriff auf jegliche Bibliothek", @@ -178,7 +178,7 @@ "unable-to-reset-k+": "Aufgrund eines Fehlers konnte die Kavita+ Lizenz nicht zurückgesetzt werden. Kontaktieren Sie den Kavita+ Support", "email-not-enabled": "Der Mailversand ist auf diesem Server nicht aktiviert. Sie können diese Aktion nicht durchführen.", "invalid-email": "Die für den Benutzer hinterlegte E-Mail ist ungültig. Links finden Sie in den Logs.", - "send-to-unallowed": "Sie können nicht an ein Gerät senden, das nicht Ihnen gehört.", + "send-to-unallowed": "Sie können nicht an ein fremdes Gerät senden.", "send-to-size-limit": "Die Datei(en), die Sie senden möchten, ist/sind zu groß für Ihren E-Mail-Service", "check-updates": "Updates überprüfen", "email-settings-invalid": "E-Mail-Einstellungen fehlen Informationen. Stellen Sie sicher, dass alle E-Mail-Einstellungen gespeichert sind.", diff --git a/API/I18N/et.json b/API/I18N/et.json index d2f9fe42d..b1783e987 100644 --- a/API/I18N/et.json +++ b/API/I18N/et.json @@ -1,6 +1,6 @@ { "confirm-email": "Esmalt pead oma e-posti kinnitama", - "locked-out": "Oled liiga paljude sisselogimiskatsete tõttu süsteemist keelatud. Palun oota 10 minutit.", + "locked-out": "Sinu konto on liiga paljude ebaõnnestunud sisselogimiskatsete tõttu süsteemis piiratud. Palun oota 10 minutit.", "disabled-account": "Su konto on keelatud. Võta ühendust serveri administraatoriga.", "register-user": "Kasutaja registreerimisel läks midagi valesti", "validate-email": "Teie e-posti kinnitamisel ilmnes probleem: {0}", @@ -15,5 +15,147 @@ "generate-token": "Kinnitusmeili koodi loomisel ilmnes probleem. Vaata logisid", "confirm-token-gen": "Kinnituskoodi loomisel ilmnes probleem", "nothing-to-do": "Pole midagi teha", - "age-restriction-update": "Vanusepiirangu värskendamisel ilmnes viga" + "age-restriction-update": "Vanusepiirangu värskendamisel ilmnes viga", + "manual-setup-fail": "Mitteautomaatne seadistus ei suuda lõpetada. Palun katkestage ja looge kutse uuesti", + "user-already-registered": "Kasutaja on juba registreeritud kui {0}", + "user-already-confirmed": "Kasutaja on juba kinnitatud", + "generic-user-update": "Kasutajaandmete uuendamisel tekkis viga", + "user-already-invited": "Selle e-posti aadressiga kasutaja on juba kutsutud ja kutse vajab jaatavalt vastamist.", + "generic-invite-user": "Tekkis probleem kasutaja kutsumisel. Palun loe vealogisid.", + "no-user": "Sellenimelist kasutajat ei ole", + "username-taken": "Kasutajanimi on juba kasutuses", + "send-to-unallowed": "Sa ei saa saata seadmele, mis ei ole sinu", + "generic-send-to": "Tekkis viga faili(de) seadmele saatmisel", + "bookmarks-empty": "Järjehoidjad ei saa olla tühjad", + "no-cover-image": "Pole kaanepilti", + "bookmark-doesnt-exist": "Järjehoidjat ei eksisteeri", + "must-be-defined": "{0} peab olema sätestatud", + "generic-favicon": "Domeeni favicon laadimisel tekkis probleem", + "generic-library": "Tekkis möödapääsmatu probleem. Palun proovi uuesti.", + "series-updated": "Edukalt uuendatud", + "no-library-access": "Kasutaja ei oma juurdepääsu sellele kogule", + "user-doesnt-exist": "Kasutajat ei eksisteeri", + "delete-library-while-scan": "Ei saa kustutada tervikkogu, kui skaneerimine on töös. Oota skaneerimise lõppemist või taaskäivita Kavita ning püüa siis uuesti kustutada", + "valid-number": "Peab olema pädev leheküljenumber", + "generic-reading-list-update": "Lugemisloendi uuendamisel tekkis probleem", + "reading-list-position": "Ei õnnestunud uuendada järge", + "series-restricted": "Kasutajal puudub ligipääs sellele sarjale", + "update-yearly-stats": "Uuenda aastate kaupa statistika", + "remove-from-want-to-read": "Lugemisloendi puhastus", + "process-scrobbling-events": "Töötle scrobble juhtumeid", + "user-no-access-library-from-series": "Kasutaja ei oma juurdepääsu täiskogule, milles see seeria on", + "progress-must-exist": "Kasutajal peab olema järg", + "generic-create-temp-archive": "Tekkis probleem ajutise arhiivi loomisel", + "smart-filter-doesnt-exist": "Nutikas filter ei eksisteeri", + "browse-external-sources": "Lehitse väliseid allikaid", + "recently-updated": "Hiljuti uuendatud", + "browse-recently-updated": "Lehitse hiljuti uuendatuid", + "collections": "Kõik kogumid", + "browse-libraries": "Lehitse täiskogude kaupa", + "password-updated": "Parool uuendatud", + "invalid-username": "Vigane kasutajanimi", + "critical-email-migration": "E-posti migreerimisel tekkis viga. Võta toega ühendust", + "email-not-enabled": "E-post ei ole sellel serveril seadistatud. Seda muudatust ei saa teha.", + "chapter-doesnt-exist": "Peatükki ei ole olemas", + "file-missing": "Faili ei leitud raamatust", + "collection-updated": "Kogu uuendatud edukalt", + "collection-deleted": "Kogu kustutatud", + "generic-error": "Midagi ebaõnnestus, palun proovi uuesti", + "collection-doesnt-exist": "Kogu ei eksisteeri", + "device-doesnt-exist": "Seade ei eksisteeri", + "greater-0": "{0} peab olema suurem, kui 0", + "send-to-kavita-email": "Seadmele saatmine ei saa töötada ilma e-posti seadistamata", + "series-doesnt-exist": "Sari ei eksisteeri", + "file-doesnt-exist": "Faili ei eksisteeri", + "library-name-exists": "Kogu nimi juba eksisteerib, palun vali süsteemisiseselt unikaalne nimi.", + "generic-reading-list-create": "Lugemisloendi loomisel tekkis probleem", + "reading-list-doesnt-exist": "Lugemisloendit ei eksisteeri", + "browse-reading-lists": "Lehitse lugemisloendite kaupa", + "external-sources": "Välised allikad", + "smart-filters": "Nutikad filtrid", + "search": "Otsing", + "query-required": "Päringuparameeter on vaja kaasa anda", + "external-source-doesnt-exist": "Väline allikas ei eksisteeri", + "external-source-required": "APIvõti ja serverinimi on vajalikud", + "external-source-already-exists": "Väline allikas on juba olemas", + "device-duplicate": "Sellenimeline seade juba eksisteerib", + "collection-tag-duplicate": "Sellenimeline kogu juba eksisteerib", + "chapter-num": "Peatükk {0}", + "license-check": "Litsentsikontroll", + "check-updates": "Kontrolli uuendusi", + "process-processed-scrobbling-events": "Töötle juba töödeldud scrobble juhtumid", + "backup": "Varund", + "collection-already-exists": "Kogu juba eksisteerib", + "error-import-stack": "Tekkis probleem MAL kuhja importimisel", + "send-to-device-status": "Edastame failid sinu seadmele", + "volume-doesnt-exist": "Raamat ei eksisteeri", + "invalid-filename": "Vigane failinimi", + "generic-library-update": "Tekkis möödapääsmatu probleem tervikkogu uuendamisel.", + "pdf-doesnt-exist": "PDF ei eksisteeri - samas peaks", + "cache-file-find": "Ei leidnud puhverdatud pilti - taaslae ja proovi uuesti.", + "name-required": "Nimi ei või tühjaks jääda", + "reading-list-permission": "Teil ei ole õigusi sellele lugemisloendile või seda loendit ei eksisteeri", + "reading-list-item-delete": "Ei õnnestunud kustutada element(e|i)", + "reading-list-deleted": "Lugemisloend on kustutatud", + "generic-reading-list-delete": "Lugemisloendi kustutamisel tekkis probleem", + "browse-recently-added": "Lehitse hiljuti lisatuid", + "cleanup": "Puhastus", + "issue-num": "Väljaanne {0}{1}", + "book-num": "Raamat {0}", + "series-restricted-age-restriction": "Kasutajal ei ole vanusepiirangust tulenevalt seeriale juurdepääsu", + "send-to-permission": "Kindle ei toeta mitte-EPUB või mitte-PDF formaati", + "device-not-created": "See seade veel ei eksisteeri. Palun loo seade", + "collection-tag-title-required": "Kogu pealkiri ei saa jääda tühjaks", + "epub-html-missing": "Ei suutnud leida sobivat HTML selle lehekülje jaoks", + "epub-malformed": "Failis on süntaksivead! Ei saa lugeda.", + "theme-doesnt-exist": "Teemafail puudub või on vigane", + "external-source-already-in-use": "Juba eksisteerib voog selle välise allikaga", + "sidenav-stream-doesnt-exist": "SideNav voog ei eksisteeri", + "dashboard-stream-doesnt-exist": "Koondpaneeli voog ei eksisteeri", + "smart-filter-already-in-use": "Juba eksisteerib selle nutika filtriga voog", + "favicon-doesnt-exist": "Faviconi ei eksisteeri", + "search-description": "Otsi sarju, kogusid või lugemisloendeid", + "reading-list-restricted": "Lugemisloend ei eksisteeri või teil puudub juurdepääs", + "browse-smart-filters": "Lehitse nutikate filtritega", + "browse-more-in-genre": "Brausi rohkem {0}", + "more-in-genre": "Rohkem žanris {0}", + "browse-collections": "Lehitse kogude kaupa", + "reading-lists": "Lugemisloendid", + "invalid-email-confirmation": "Vigane e-posti kinnitus", + "generic-user-email-update": "Pole võimalik uuendada kasutaja e-posti aadressi. Kontrolli logisid.", + "not-accessible": "Sinu server ei ole väljast ligipääsetav", + "invalid-email": "E-posti aadress selle kasutaja juures ei vasta RFC-le. Vaata palun logisid.", + "not-accessible-password": "Sinu server ei ole ligipääsetav. Sinu parooli taasseadmise link on logides", + "forgot-password-generic": "E-post saadetakse aadressile, mis on meie andmebaasis", + "generic-password-update": "Uue parooli kinnitamisel esines ootamatu viga", + "email-sent": "E-post saadetud", + "user-migration-needed": "Selle kasutaja andmeid on vaja migreerida. Palu tal välja logida, et saaks migratsiooni töövoo käivitada", + "generic-invite-email": "Esines probleem e-postiga kutse taas-saatmisel", + "admin-already-exists": "Administraator on juba määratud", + "account-email-invalid": "Selle administraatorkonto e-posti aadress ei vasta RFC-le. Test e-posti ei saa saata.", + "email-settings-invalid": "E-posti seaded on puudulikud. Veendu, et e-posti seaded saaksid salvestatud.", + "generic-device-create": "Tekkis viga seadme loomisel", + "generic-device-update": "Tekkis viga seadme uuendamisel", + "generic-device-delete": "Tekkis viga seadme kustutamisel", + "send-to-size-limit": "Fail(id) mida üritad saata on e-posti serveri jaoks liiga suured", + "library-doesnt-exist": "Tervikkogu ei eksisteeri", + "invalid-access": "Vigane juurdepääs", + "no-image-for-page": "Pole sellist pilti leheküljel {0}. Proovi taaslaadimist, et võimaldada taaspuhverdamine.", + "bookmark-save": "Ei õnnestunud salvestada järjehoidjat", + "perform-scan": "Palun viige läbi selle seeria või täiskogu skaneerimine, ning proovige uuesti", + "generic-read-progress": "Tekkis probleem järje salvestamisel", + "generic-clear-bookmarks": "Ei suutnud puhastada järjehoidjaid", + "bookmark-permission": "Teil ei ole õigust järjehoidja seadmiseks/kustutamiseks", + "duplicate-bookmark": "Järjehoidja topeltsissekanne on juba olemas", + "reading-list-updated": "Uuendatud", + "bad-copy-files-for-download": "Ei õnnestu kopeerida faile ajutisse kataloogi arhiivina allalaadimiseks.", + "volume-num": "Köide {0}", + "reading-list-title-required": "Lugemisloendi pealkiri ei saa jääda tühjaks", + "reading-list-name-exists": "Sellenimeline lugemisloend on juba olemas", + "check-scrobbling-tokens": "Kontrolli scrobble turvažetoone", + "report-stats": "Raporteeri statistika", + "invalid-path": "Vigane tee", + "not-authenticated": "Kasutaja on autentimata", + "kavita+-data-refresh": "Kavita+andmete värskendus", + "scan-libraries": "Skaneeri täiskogud" } diff --git a/API/I18N/ko.json b/API/I18N/ko.json index 794379660..a6a4f31a3 100644 --- a/API/I18N/ko.json +++ b/API/I18N/ko.json @@ -193,5 +193,7 @@ "update-yearly-stats": "연간 통계 업데이트", "process-processed-scrobbling-events": "처리된 스크로블링 이벤트 처리", "account-email-invalid": "관리자 계정에 등록된 이메일이 유효한 이메일이 아닙니다. 테스트 이메일을 보낼 수 없습니다.", - "email-settings-invalid": "이메일 설정 누락 된 정보. 모든 이메일 설정을 저장합니다." + "email-settings-invalid": "이메일 설정 누락 된 정보. 모든 이메일 설정을 저장합니다.", + "collection-already-exists": "컬렉션은 이미 존재합니다", + "error-import-stack": "MAL 스택을 가져오는 데 문제가 있었습니다" } diff --git a/API/I18N/vi.json b/API/I18N/vi.json index 6d9c26fe3..3d1756392 100644 --- a/API/I18N/vi.json +++ b/API/I18N/vi.json @@ -42,5 +42,9 @@ "no-user": "Ngươi dùng không tồn tại", "user-already-confirmed": "Người dùng này đã xác minh", "generic-user-update": "Có sự cố đã xảy ra khi cập nhật thông tin người dùng", - "user-already-invited": "Người dùng đã được mời qua email này nhưng chưa chấp nhận lời mời." + "user-already-invited": "Người dùng đã được mời qua email này nhưng chưa chấp nhận lời mời.", + "generate-token": "Đã xảy ra sự cố khi tạo mã xác nhận email. Xem bản ghi", + "locked-out": "Bạn đã bị khóa do quá nhiều lần thử đăng nhập. Vui lòng chờ 10 phút.", + "unable-to-reset-key": "Có sự cố xảy ra, không thể đặt lại khóa", + "share-multiple-emails": "Một Email không thể được dùng chung cho nhiều tải khoản" } diff --git a/API/I18N/zh_Hant.json b/API/I18N/zh_Hant.json index f4a220aae..3a6dc251f 100644 --- a/API/I18N/zh_Hant.json +++ b/API/I18N/zh_Hant.json @@ -17,7 +17,7 @@ "file-doesnt-exist": "檔案不存在", "admin-already-exists": "管理員已存在", "age-restriction-update": "更新年齡限制時發生錯誤", - "send-to-kavita-email": "無法將 Kavita 的電子郵件服務用於傳送到裝置。請設定您自己的電子郵件服務。", + "send-to-kavita-email": "未設置電子郵件時無法使用傳送到裝置功能", "not-accessible": "您的伺服器無法從外部存取", "collections": "所有收藏", "email-sent": "電子郵件已傳送", @@ -161,5 +161,39 @@ "collection-deleted": "收藏已刪除", "permission-denied": "您不被允許進行此操作", "device-doesnt-exist": "裝置不存在", - "generic-series-delete": "刪除系列作品時發生問題" + "generic-series-delete": "刪除系列作品時發生問題", + "recently-updated": "最近更新", + "external-source-already-in-use": "已存在具有此外部來源的串流", + "report-stats": "統計報告", + "backup": "備份", + "more-in-genre": "更多關於類型 {0}", + "account-email-invalid": "管理員帳號檔案中的電子郵件無效。無法發送測試電子郵件。", + "email-not-enabled": "此伺服器未啟用電子郵件功能。您無法執行此操作。", + "error-import-stack": "匯入 MAL 時出現問題", + "send-to-unallowed": "您無法傳送到不是您自己的裝置", + "email-settings-invalid": "電子郵件設定缺少資訊。請確保所有電子郵件設定已保存。", + "collection-already-exists": "組合已存在", + "send-to-size-limit": "您嘗試傳送的文件對於您的電子郵件系統來說過大", + "external-sources": "外部來源", + "dashboard-stream-doesnt-exist": "儀表板串流不存在", + "unable-to-reset-k+": "發生錯誤,無法重置 Kavita+ 授權。請聯繫 Kavita+ 支援", + "check-scrobbling-tokens": "檢查 Scrobbling Tokens", + "cleanup": "清理", + "browse-more-in-genre": "在 {0} 中繼續瀏覽", + "browse-recently-updated": "瀏覽最近更新", + "external-source-required": "需要 API 金鑰和 Host", + "external-source-doesnt-exist": "外部來源不存在", + "check-updates": "檢查更新", + "license-check": "授權檢查", + "process-scrobbling-events": "處理 Scrobbling 事件", + "process-processed-scrobbling-events": "處理已處理的 Scrobbling 事件", + "remove-from-want-to-read": "清理閱讀清單", + "scan-libraries": "掃描資料庫", + "kavita+-data-refresh": "Kavita+ 資料更新", + "update-yearly-stats": "更新年度統計", + "invalid-email": "使用者檔案中的電子郵件無效。請查看日誌以獲得任何連結。", + "browse-external-sources": "瀏覽外部來源", + "sidenav-stream-doesnt-exist": "側邊導覽串流不存在", + "smart-filter-already-in-use": "已存在具有此智慧篩選器的串流", + "external-source-already-exists": "外部來源已存在" } diff --git a/API/Program.cs b/API/Program.cs index 6e4341ee6..59c0a6709 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -97,7 +97,7 @@ public class Program Task.Run(async () => { // Apply all migrations on startup - logger.LogInformation("Running Migrations"); + logger.LogInformation("Running Manual Migrations"); try { @@ -113,7 +113,7 @@ public class Program } await unitOfWork.CommitAsync(); - logger.LogInformation("Running Migrations - complete"); + logger.LogInformation("Running Manual Migrations - complete"); }).GetAwaiter() .GetResult(); } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 815be6c86..33b422d70 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -125,14 +125,90 @@ public class ImageService : IImageService } } + /// + /// Tries to determine if there is a better mode for resizing + /// + /// + /// + /// + /// + public static Enums.Size GetSizeForDimensions(Image image, int targetWidth, int targetHeight) + { + try + { + if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) + { + return Enums.Size.Force; + } + } + catch (Exception) + { + /* Swallow */ + } + + return Enums.Size.Both; + } + + public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight) + { + try + { + if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) + { + return null; + } + } catch (Exception) + { + /* Swallow */ + return null; + } + + return Enums.Interesting.Attention; + } + + public static bool WillScaleWell(Image sourceImage, int targetWidth, int targetHeight, double tolerance = 0.1) + { + // Calculate the aspect ratios + var sourceAspectRatio = (double) sourceImage.Width / sourceImage.Height; + var targetAspectRatio = (double) targetWidth / targetHeight; + + // Compare aspect ratios + if (Math.Abs(sourceAspectRatio - targetAspectRatio) > tolerance) + { + return false; // Aspect ratios differ significantly + } + + // Calculate scaling factors + var widthScaleFactor = (double) targetWidth / sourceImage.Width; + var heightScaleFactor = (double) targetHeight / sourceImage.Height; + + // Check resolution quality (example thresholds) + if (widthScaleFactor > 2.0 || heightScaleFactor > 2.0) + { + return false; // Scaling factor too large + } + + return true; // Image will scale well + } + + private static bool IsLikelyWideImage(int width, int height) + { + var aspectRatio = (double) width / height; + return aspectRatio > 1.25; + } + public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size) { if (string.IsNullOrEmpty(path)) return string.Empty; try { - var dims = size.GetDimensions(); - using var thumbnail = Image.Thumbnail(path, dims.Width, height: dims.Height, size: Enums.Size.Force); + var (width, height) = size.GetDimensions(); + using var sourceImage = Image.NewFromFile(path, false, Enums.Access.SequentialUnbuffered); + + using var thumbnail = Image.Thumbnail(path, width, height: height, + size: GetSizeForDimensions(sourceImage, width, height), + crop: GetCropForDimensions(sourceImage, width, height)); var filename = fileName + encodeFormat.GetExtension(); thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); return filename; @@ -156,22 +232,55 @@ public class ImageService : IImageService /// File name with extension of the file. This will always write to public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { - var dims = size.GetDimensions(); - using var thumbnail = Image.ThumbnailStream(stream, dims.Width, height: dims.Height, size: Enums.Size.Force); + var (targetWidth, targetHeight) = size.GetDimensions(); + if (stream.CanSeek) stream.Position = 0; + using var sourceImage = Image.NewFromStream(stream); + if (stream.CanSeek) stream.Position = 0; + + var scalingSize = GetSizeForDimensions(sourceImage, targetWidth, targetHeight); + var scalingCrop = GetCropForDimensions(sourceImage, targetWidth, targetHeight); + + using var thumbnail = sourceImage.ThumbnailImage(targetWidth, targetHeight, + size: scalingSize, + crop: scalingCrop); + var filename = fileName + encodeFormat.GetExtension(); _directoryService.ExistOrCreate(outputDirectory); + try { _directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); } catch (Exception) {/* Swallow exception */} - thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); - return filename; + + try + { + thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); + + return filename; + } + catch (VipsException) + { + // NetVips Issue: https://github.com/kleisauke/net-vips/issues/234 + // Saving pdf covers from a stream can fail, so revert to old code + + if (stream.CanSeek) stream.Position = 0; + using var thumbnail2 = Image.ThumbnailStream(stream, targetWidth, height: targetHeight, + size: scalingSize, + crop: scalingCrop); + thumbnail2.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); + + return filename; + } } public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { - var dims = size.GetDimensions(); - using var thumbnail = Image.Thumbnail(sourceFile, dims.Width, height: dims.Height, size: Enums.Size.Force); + var (width, height) = size.GetDimensions(); + using var sourceImage = Image.NewFromFile(sourceFile, false, Enums.Access.SequentialUnbuffered); + + using var thumbnail = Image.Thumbnail(sourceFile, width, height: height, + size: GetSizeForDimensions(sourceImage, width, height), + crop: GetCropForDimensions(sourceImage, width, height)); var filename = fileName + encodeFormat.GetExtension(); _directoryService.ExistOrCreate(outputDirectory); try @@ -426,7 +535,7 @@ public class ImageService : IImageService public static void CreateMergedImage(IList coverImages, CoverImageSize size, string dest) { - var dims = size.GetDimensions(); + var (width, height) = size.GetDimensions(); int rows, cols; if (coverImages.Count == 1) @@ -446,7 +555,7 @@ public class ImageService : IImageService } - var image = Image.Black(dims.Width, dims.Height); + var image = Image.Black(width, height); var thumbnailWidth = image.Width / cols; var thumbnailHeight = image.Height / rows; diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 5294ebafb..a6b3cb347 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -501,6 +501,7 @@ public class SeriesService : ISeriesService StorylineChapters = storylineChapters, TotalCount = chapters.Count, UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages), + // TODO: See if we can get the ContinueFrom here }; } diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index cf73e0211..b2c5cbaeb 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -5,10 +5,12 @@ using System.Threading.Tasks; using API.Data; using API.DTOs; using API.DTOs.Statistics; +using API.DTOs.Stats; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using AutoMapper; @@ -35,6 +37,7 @@ public interface IStatisticService Task UpdateServerStatistics(); Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds); Task GetKavitaPlusMetadataBreakdown(); + Task> GetFilesByExtension(string fileExtension); } /// @@ -559,6 +562,16 @@ public class StatisticService : IStatisticService } + public async Task> GetFilesByExtension(string fileExtension) + { + var query = _context.MangaFile + .Where(f => f.Extension == fileExtension) + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(f => f.FilePath); + + return await query.ToListAsync(); + } + public async Task> GetTopUsers(int days) { var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 43b3ff5a7..1c5c803e3 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -4,11 +4,13 @@ using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.Entities.Enums; using API.Helpers.Converters; using API.Services.Plus; using API.Services.Tasks; using API.Services.Tasks.Metadata; +using API.SignalR; using Hangfire; using Microsoft.Extensions.Logging; @@ -22,12 +24,12 @@ public interface ITaskScheduler Task ScheduleKavitaPlusTasks(); void ScanFolder(string folderPath, string originalPath, TimeSpan delay); void ScanFolder(string folderPath); - void ScanLibrary(int libraryId, bool force = false); - void ScanLibraries(bool force = false); + Task ScanLibrary(int libraryId, bool force = false); + Task ScanLibraries(bool force = false); void CleanupChapters(int[] chapterIds); void RefreshMetadata(int libraryId, bool forceUpdate = true); void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); - void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); + Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false); void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false); void CancelStatsTasks(); @@ -59,6 +61,7 @@ public class TaskScheduler : ITaskScheduler private readonly IExternalMetadataService _externalMetadataService; private readonly ISmartCollectionSyncService _smartCollectionSyncService; private readonly IFontService _fontService; + private readonly IEventHub _eventHub; public static BackgroundJobServer Client => new (); public const string ScanQueue = "scan"; @@ -79,7 +82,7 @@ public class TaskScheduler : ITaskScheduler public const string KavitaPlusDataRefreshId = "kavita+-data-refresh"; public const string KavitaPlusStackSyncId = "kavita+-stack-sync"; - private static readonly ImmutableArray ScanTasks = + public static readonly ImmutableArray ScanTasks = ["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"]; private static readonly Random Rnd = new Random(); @@ -95,7 +98,7 @@ public class TaskScheduler : ITaskScheduler ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService, - IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, + IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub, IFontService fontService) { _cacheService = cacheService; @@ -116,6 +119,7 @@ public class TaskScheduler : ITaskScheduler _externalMetadataService = externalMetadataService; _smartCollectionSyncService = smartCollectionSyncService; _fontService = fontService; + _eventHub = eventHub; } public async Task ScheduleTasks() @@ -127,7 +131,7 @@ public class TaskScheduler : ITaskScheduler { var scanLibrarySetting = setting; _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); - RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false), + RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), () => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions); } else @@ -324,18 +328,21 @@ public class TaskScheduler : ITaskScheduler /// Attempts to call ScanLibraries on ScannerService, but if another scan task is in progress, will reschedule the invocation for 3 hours in future. /// /// - public void ScanLibraries(bool force = false) + public async Task ScanLibraries(bool force = false) { if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { _logger.LogInformation("A Scan is already running, rescheduling ScanLibraries in 3 hours"); + // Send InfoEvent to UI as this is invoked my API BackgroundJob.Schedule(() => ScanLibraries(force), TimeSpan.FromHours(3)); + await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed", + $"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}")); return; } BackgroundJob.Enqueue(() => _scannerService.ScanLibraries(force)); } - public void ScanLibrary(int libraryId, bool force = false) + public async Task ScanLibrary(int libraryId, bool force = false) { if (HasScanTaskRunningForLibrary(libraryId)) { @@ -344,15 +351,18 @@ public class TaskScheduler : ITaskScheduler } if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); _logger.LogInformation("A Scan is already running, rescheduling ScanLibrary in 3 hours"); + await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan library task delayed", + $"A scan was ongoing during processing of the {library!.Name} scan task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}")); BackgroundJob.Schedule(() => ScanLibrary(libraryId, force), TimeSpan.FromHours(3)); return; } _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); - BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true)); + var jobId = BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true)); // When we do a scan, force cache to re-unpack in case page numbers change - BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); + BackgroundJob.ContinueJobWith(jobId, () => _cleanupService.CleanupCacheDirectory()); } public void TurnOnScrobbling(int userId = 0) @@ -393,7 +403,7 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate)); } - public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) + public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) { if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, forceUpdate], ScanQueue)) { @@ -403,7 +413,10 @@ public class TaskScheduler : ITaskScheduler if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { // BUG: This can end up triggering a ton of scan series calls (but i haven't seen in practice) + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None); _logger.LogInformation("A Scan is already running, rescheduling ScanSeries in 10 minutes"); + await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan series task delayed: {series!.Name}", + $"A scan was ongoing during processing of the scan series task. Task has been rescheduled for 10 minutes: {DateTime.Now.AddMinutes(10)}")); BackgroundJob.Schedule(() => ScanSeries(libraryId, seriesId, forceUpdate), TimeSpan.FromMinutes(10)); return; } @@ -474,6 +487,7 @@ public class TaskScheduler : ITaskScheduler HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, false], ScanQueue, checkRunningJobs); } + /// /// Checks if this same invocation is already enqueued or scheduled /// @@ -482,6 +496,7 @@ public class TaskScheduler : ITaskScheduler /// object[] of arguments in the order they are passed to enqueued job /// Queue to check against. Defaults to "default" /// Check against running jobs. Defaults to false. + /// Check against arguments. Defaults to true. /// public static bool HasAlreadyEnqueuedTask(string className, string methodName, object[] args, string queue = DefaultQueue, bool checkRunningJobs = false) { diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 60262136a..e429a5aed 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -150,13 +150,28 @@ public class LibraryWatcher : ILibraryWatcher { _logger.LogTrace("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType); if (e.ChangeType != WatcherChangeTypes.Changed) return; - BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)))); + + var isDirectoryChange = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)); + + if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange], + checkRunningJobs: true)) + { + return; + } + + BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange)); } private void OnCreated(object sender, FileSystemEventArgs e) { _logger.LogTrace("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name); - BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name))); + var isDirectoryChange = !_directoryService.FileSystem.File.Exists(e.Name); + if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange], + checkRunningJobs: true)) + { + return; + } + BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange)); } /// @@ -168,6 +183,11 @@ public class LibraryWatcher : ILibraryWatcher var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)); if (!isDirectory) return; _logger.LogTrace("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name); + if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, true], + checkRunningJobs: true)) + { + return; + } BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, true)); } @@ -298,7 +318,7 @@ public class LibraryWatcher : ILibraryWatcher /// This is called via Hangfire to decrement the counter. Must work around a lock /// // ReSharper disable once MemberCanBePrivate.Global - public void UpdateLastBufferOverflow() + public static void UpdateLastBufferOverflow() { lock (Lock) { diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 6f6ba9336..3c347e39d 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -351,14 +351,17 @@ public class ParseScannedFiles { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started)); + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1.A: Process {FolderCount} folders", library.Name, folders.Count()); var processedScannedSeries = new List(); //var processedScannedSeries = new ConcurrentBag(); foreach (var folderPath in folders) { try { + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.B: Scan files in {Folder}", library.Name, folderPath); var scanResults = await ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck); + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.C: Process files in {Folder}", library.Name, folderPath); foreach (var scanResult in scanResults) { await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries); diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 24bb3ef7a..840e7a6d8 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -235,7 +235,7 @@ public static class Parser // [SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar, Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz, // Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.30 Omake new Regex( - @"^(?.+?)(\s*Chapter\s*\d+)?(\s|_|\-\s)+Vol(ume)?\.?(\d+|tbd|\s\d).+?", + @"^(?.+?)(?:\s*|_|\-\s*)+(?:Ch(?:apter|\.|)\s*\d+(?:\.\d+)?(?:\s*|_|\-\s*)+)?Vol(?:ume|\.|)\s*(?:\d+|tbd)(?:\s|_|\-\s*).+", MatchOptions, RegexTimeout), // Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip new Regex( @@ -764,7 +764,10 @@ public static class Parser var group = matches .Select(match => match.Groups["Series"]) .FirstOrDefault(group => group.Success && group != Match.Empty); - if (group != null) return CleanTitle(group.Value); + if (group != null) + { + return CleanTitle(group.Value); + } } return string.Empty; diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index ab8340be0..7156ba4ad 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -76,6 +76,7 @@ public enum ScanCancelReason public class ScannerService : IScannerService { public const string Name = "ScannerService"; + private const int Timeout = 60 * 60 * 60; // 2.5 days private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IMetadataService _metadataService; @@ -156,11 +157,11 @@ public class ScannerService : IScannerService } // TODO: Figure out why we have the library type restriction here - if (series != null && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel)) + if (series != null)// && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel) { if (TaskScheduler.HasScanTaskRunningForSeries(series.Id)) { - _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); + _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); return; } _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder); @@ -168,6 +169,7 @@ public class ScannerService : IScannerService return; } + // This is basically rework of what's already done in Library Watcher but is needed if invoked via API var parentDirectory = _directoryService.GetParentDirectoryName(folder); if (string.IsNullOrEmpty(parentDirectory)) return; @@ -183,7 +185,7 @@ public class ScannerService : IScannerService { if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id)) { - _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); + _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); return; } BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1)); @@ -196,12 +198,21 @@ public class ScannerService : IScannerService /// /// Not Used. Scan series will always force [Queue(TaskScheduler.ScanQueue)] + [DisableConcurrentExecution(Timeout)] + [AutomaticRetry(Attempts = 200, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true) { + if (TaskScheduler.HasAlreadyEnqueuedTask(Name, "ScanSeries", [seriesId, bypassFolderOptimizationChecks], TaskScheduler.ScanQueue)) + { + _logger.LogInformation("[ScannerService] Scan series invoked but a task is already running/enqueued. Dropping request"); + return; + } + var sw = Stopwatch.StartNew(); var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update + var existingChapterIdsToClean = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns); @@ -434,7 +445,7 @@ public class ScannerService : IScannerService // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are if (folders.Any(f => !_directoryService.IsDriveMounted(f))) { - _logger.LogCritical("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); + _logger.LogCritical("[ScannerService] Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", @@ -448,7 +459,7 @@ public class ScannerService : IScannerService if (folders.Any(f => _directoryService.IsDirectoryEmpty(f))) { // That way logging and UI informing is all in one place with full context - _logger.LogError("Some of the root folders for the library are empty. " + + _logger.LogError("[ScannerService] Some of the root folders for the library are empty. " + "Either your mount has been disconnected or you are trying to delete all series in the library. " + "Scan has be aborted. " + "Check that your mount is connected or change the library's root folder and rescan"); @@ -465,17 +476,25 @@ public class ScannerService : IScannerService } [Queue(TaskScheduler.ScanQueue)] - [DisableConcurrentExecution(60 * 60 * 60)] + [DisableConcurrentExecution(Timeout)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanLibraries(bool forceUpdate = false) { - _logger.LogInformation("Starting Scan of All Libraries, Forced: {Forced}", forceUpdate); + _logger.LogInformation("[ScannerService] Starting Scan of All Libraries, Forced: {Forced}", forceUpdate); foreach (var lib in await _unitOfWork.LibraryRepository.GetLibrariesAsync()) { + // BUG: This will trigger the first N libraries to scan over and over if there is always an interruption later in the chain + if (TaskScheduler.HasScanTaskRunningForLibrary(lib.Id)) + { + // We don't need to send SignalR event as this is a background job that user doesn't need insight into + _logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running for {LibraryName}. Rescheduling for 4 hours", lib.Name); + await Task.Delay(TimeSpan.FromHours(4)); + } + await ScanLibrary(lib.Id, forceUpdate, true); } _processSeries.Reset(); - _logger.LogInformation("Scan of All Libraries Finished"); + _logger.LogInformation("[ScannerService] Scan of All Libraries Finished"); } @@ -488,13 +507,14 @@ public class ScannerService : IScannerService /// Defaults to false /// Defaults to true. Is this a standalone invocation or is it in a loop? [Queue(TaskScheduler.ScanQueue)] - [DisableConcurrentExecution(60 * 60 * 60)] + [DisableConcurrentExecution(Timeout)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanLibrary(int libraryId, bool forceUpdate = false, bool isSingleScan = true) { var sw = Stopwatch.StartNew(); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns); + var libraryFolderPaths = library!.Folders.Select(fp => fp.Path).ToList(); if (!await CheckMounts(library.Name, libraryFolderPaths)) return; @@ -506,23 +526,27 @@ public class ScannerService : IScannerService var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths)); if (!shouldUseLibraryScan) { - _logger.LogError("Library {LibraryName} consists of one or more Series folders, using series scan", library.Name); + _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name); } + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1: Scan Files", library.Name); var (scanElapsedTime, processedSeries) = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, forceUpdate); + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 2: Track Found Series", library.Name); var parsedSeries = new Dictionary>(); TrackFoundSeriesAndFiles(parsedSeries, processedSeries); // We need to remove any keys where there is no actual parser info + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 3: Process Parsed Series", library.Name); var totalFiles = await ProcessParsedSeries(forceUpdate, parsedSeries, library, scanElapsedTime); UpdateLastScanned(library); _unitOfWork.LibraryRepository.Update(library); + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 4: Save Library", library.Name); if (await _unitOfWork.CommitAsync()) { if (isSingleScan) @@ -543,6 +567,7 @@ public class ScannerService : IScannerService totalFiles, parsedSeries.Count, sw.ElapsedMilliseconds, library.Name); } + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 5: Remove Deleted Series", library.Name); await RemoveSeriesNotFound(parsedSeries, library); } else diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 200851d10..de4c4e607 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -91,12 +91,39 @@ public class VersionUpdaterService : IVersionUpdaterService // Find the latest dto var latestRelease = updateDtos[0]!; + var updateVersion = new Version(latestRelease.UpdateVersion); var isNightly = BuildInfo.Version > new Version(latestRelease.UpdateVersion); + + // isNightly can be true when we compare something like v0.8.1 vs v0.8.1.0 + if (IsVersionEqualToBuildVersion(updateVersion)) + { + //latestRelease.UpdateVersion = BuildInfo.Version.ToString(); + isNightly = false; + } + + latestRelease.IsOnNightlyInRelease = isNightly; return updateDtos; } + private static bool IsVersionEqualToBuildVersion(Version updateVersion) + { + return updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 && + CompareWithoutRevision(BuildInfo.Version, updateVersion); + } + + private static bool CompareWithoutRevision(Version v1, Version v2) + { + if (v1.Major != v2.Major) + return v1.Major == v2.Major; + if (v1.Minor != v2.Minor) + return v1.Minor == v2.Minor; + if (v1.Build != v2.Build) + return v1.Build == v2.Build; + return true; + } + public async Task GetNumberOfReleasesBehind() { var updates = await GetAllReleases(); @@ -109,6 +136,7 @@ public class VersionUpdaterService : IVersionUpdaterService var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty)); var currentVersion = BuildInfo.Version.ToString(4); + return new UpdateNotificationDto() { CurrentVersion = currentVersion, @@ -118,7 +146,7 @@ public class VersionUpdaterService : IVersionUpdaterService UpdateUrl = update.Html_Url, IsDocker = OsInfo.IsDocker, PublishDate = update.Published_At, - IsReleaseEqual = BuildInfo.Version == updateVersion, + IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion), IsReleaseNewer = BuildInfo.Version < updateVersion, }; } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index ff04e3201..27bca2a80 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -345,6 +345,7 @@ public static class MessageFactory EventType = ProgressEventType.Single, Body = new { + Name = Error, Title = title, SubTitle = subtitle, } @@ -362,6 +363,7 @@ public static class MessageFactory EventType = ProgressEventType.Single, Body = new { + Name = Info, Title = title, SubTitle = subtitle, } diff --git a/API/Startup.cs b/API/Startup.cs index 30c663a4b..7e3857c0b 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -137,14 +137,14 @@ public class Startup { c.SwaggerDoc("v1", new OpenApiInfo { - Version = BuildInfo.Version.ToString(), + Version = "3.1.0", Title = "Kavita", - Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required.", + Description = $"Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v{BuildInfo.Version.ToString()}", License = new OpenApiLicense { Name = "GPL-3.0", Url = new Uri("https://github.com/Kareadita/Kavita/blob/develop/LICENSE") - } + }, }); var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; @@ -176,7 +176,7 @@ public class Startup Url = "{protocol}://{hostpath}", Variables = new Dictionary { - { "protocol", new OpenApiServerVariable { Default = "http", Enum = new List { "http", "https" } } }, + { "protocol", new OpenApiServerVariable { Default = "http", Enum = ["http", "https"]} }, { "hostpath", new OpenApiServerVariable { Default = "localhost:5000" } } } }); @@ -207,7 +207,7 @@ public class Startup .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() .UseInMemoryStorage()); - //.UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted (and locking can cause high utilization) + //.UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted (and locking can cause high utilization) (NOTE: There is code to clear jobs on startup a redditor gave me) // Add the processing server as IHostedService services.AddHangfireServer(options => @@ -427,8 +427,8 @@ public class Startup catch (Exception) { /* Swallow Exception */ + Console.WriteLine($"Kavita - v{BuildInfo.Version}"); } - Console.WriteLine($"Kavita - v{BuildInfo.Version}"); }); logger.LogInformation("Starting with base url as {BaseUrl}", basePath); diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 887a70093..0c6352d06 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -2,7 +2,7 @@ "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, "IpAddresses": "0.0.0.0,::", - "BaseUrl": "/tes/", + "BaseUrl": "/", "Cache": 75, "AllowIFraming": false } \ No newline at end of file diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 94406c6ad..7e72f1a52 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net8.0 kavitareader.com Kavita - 0.8.1.12 + 0.8.2.0 en true @@ -14,10 +14,10 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - \ No newline at end of file + diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index ecd2ee604..8ee9deef5 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -550,7 +550,7 @@ export class ActionFactoryService { } ], }, - // RBS will handle rendering this, so non-admins with download are appicable + // RBS will handle rendering this, so non-admins with download are applicable { action: Action.Download, title: 'download', @@ -583,6 +583,20 @@ export class ActionFactoryService { class: 'danger', children: [], }, + { + action: Action.Promote, + title: 'promote', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + { + action: Action.UnPromote, + title: 'unpromote', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, ]; this.bookmarkActions = [ diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 22b8d862d..938b70315 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -24,6 +24,7 @@ import {UserCollection} from "../_models/collection-tag"; import {CollectionTagService} from "./collection-tag.service"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {FilterService} from "./filter.service"; +import {ReadingListService} from "./reading-list.service"; export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -48,7 +49,8 @@ export class ActionService implements OnDestroy { constructor(private libraryService: LibraryService, private seriesService: SeriesService, private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService, - private readonly collectionTagService: CollectionTagService, private filterService: FilterService) { } + private readonly collectionTagService: CollectionTagService, private filterService: FilterService, + private readonly readingListService: ReadingListService) { } ngOnDestroy() { this.onDestroy.next(); @@ -386,7 +388,7 @@ export class ActionService implements OnDestroy { } /** - * Mark all series as Unread. + * Mark all collections as promoted/unpromoted. * @param collections UserCollection, should have id, pagesRead populated * @param promoted boolean, promoted state * @param callback Optional callback to perform actions after API completes @@ -422,6 +424,43 @@ export class ActionService implements OnDestroy { }); } + /** + * Mark all reading lists as promoted/unpromoted. + * @param readingLists UserCollection, should have id, pagesRead populated + * @param promoted boolean, promoted state + * @param callback Optional callback to perform actions after API completes + */ + promoteMultipleReadingLists(readingLists: Array, promoted: boolean, callback?: BooleanActionCallback) { + this.readingListService.promoteMultipleReadingLists(readingLists.map(v => v.id), promoted).pipe(take(1)).subscribe(() => { + if (promoted) { + this.toastr.success(translate('toasts.reading-list-promoted')); + } else { + this.toastr.success(translate('toasts.reading-list-unpromoted')); + } + + if (callback) { + callback(true); + } + }); + } + + /** + * Deletes multiple collections + * @param readingLists ReadingList, should have id + * @param callback Optional callback to perform actions after API completes + */ + async deleteMultipleReadingLists(readingLists: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return; + + this.readingListService.deleteMultipleReadingLists(readingLists.map(v => v.id)).pipe(take(1)).subscribe(() => { + this.toastr.success(translate('toasts.reading-lists-deleted')); + + if (callback) { + callback(true); + } + }); + } + addMultipleToReadingList(seriesId: number, volumes: Array, chapters?: Array, callback?: BooleanActionCallback) { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 4789fe67d..7afe5fa3c 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -8,7 +8,7 @@ import { PaginatedResult } from '../_models/pagination'; import { ReadingList, ReadingListItem } from '../_models/reading-list'; import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary'; import { TextResonse } from '../_types/text-response'; -import { ActionItem } from './action-factory.service'; +import {Action, ActionItem} from './action-factory.service'; @Injectable({ providedIn: 'root' @@ -87,9 +87,15 @@ export class ReadingListService { return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse); } - actionListFilter(action: ActionItem, readingList: ReadingList, isAdmin: boolean) { - if (readingList?.promoted && !isAdmin) return false; + actionListFilter(action: ActionItem, readingList: ReadingList, canPromote: boolean) { + + const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote; + + if (isPromotionAction) return canPromote; return true; + + // if (readingList?.promoted && !isAdmin) return false; + // return true; } nameExists(name: string) { @@ -107,4 +113,14 @@ export class ReadingListService { getCharacters(readingListId: number) { return this.httpClient.get>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId); } + + promoteMultipleReadingLists(listIds: Array, promoted: boolean) { + return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse); + } + + deleteMultipleReadingLists(listIds: Array) { + return this.httpClient.post(this.baseUrl + 'readinglist/delete-multiple', {readingListIds: listIds}, TextResonse); + } + + } diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 2e2173e6a..f729084c5 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -1,9 +1,9 @@ import { HttpClient } from '@angular/common/http'; -import {inject, Injectable} from '@angular/core'; +import {Inject, inject, Injectable} from '@angular/core'; import { environment } from 'src/environments/environment'; import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; -import { map } from 'rxjs'; +import {asyncScheduler, finalize, map, tap} from 'rxjs'; import { MangaFormatPipe } from '../_pipes/manga-format.pipe'; import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; import { TopUserRead } from '../statistics/_models/top-reads'; @@ -15,6 +15,10 @@ import { MangaFormat } from '../_models/manga-format'; import { TextResonse } from '../_types/text-response'; import {TranslocoService} from "@ngneat/transloco"; import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown"; +import {throttleTime} from "rxjs/operators"; +import {DEBOUNCE_TIME} from "../shared/_services/download.service"; +import {download} from "../shared/_models/download"; +import {Saver, SAVER} from "../_providers/saver.provider"; export enum DayOfWeek { @@ -37,7 +41,7 @@ export class StatisticsService { publicationStatusPipe = new PublicationStatusPipe(this.translocoService); mangaFormatPipe = new MangaFormatPipe(this.translocoService); - constructor(private httpClient: HttpClient) { } + constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { } getUserStatistics(userId: number, libraryIds: Array = []) { // TODO: Convert to httpParams object @@ -109,6 +113,20 @@ export class StatisticsService { return this.httpClient.get(this.baseUrl + 'stats/server/file-breakdown'); } + downloadFileBreakdown(extension: string) { + return this.httpClient.get(this.baseUrl + 'stats/server/file-extension?fileExtension=' + encodeURIComponent(extension), + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, decodeURIComponent(filename)); + }), + // tap((d) => this.updateDownloadState(d, downloadType, subtitle, 0)), + // finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + ); + + } + getReadCountByDay(userId: number = 0, days: number = 0) { return this.httpClient.get>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days); } diff --git a/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts index 20df49758..3f5d880d6 100644 --- a/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts +++ b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts @@ -1,4 +1,4 @@ -import { Directive, EventEmitter, Input, Output } from "@angular/core"; +import {ChangeDetectorRef, Directive, EventEmitter, inject, Input, OnInit, Output} from "@angular/core"; export const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0); export type SortColumn = keyof T | ''; @@ -11,6 +11,7 @@ export interface SortEvent { } @Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector selector: 'th[sortable]', host: { '[class.asc]': 'direction === "asc"', @@ -29,4 +30,4 @@ export class SortableHeader { this.direction = rotate[this.direction]; this.sort.emit({ column: this.sortable, direction: this.direction }); } -} \ No newline at end of file +} diff --git a/UI/Web/src/app/cards/bulk-selection.service.ts b/UI/Web/src/app/cards/bulk-selection.service.ts index e1d625329..0572847ed 100644 --- a/UI/Web/src/app/cards/bulk-selection.service.ts +++ b/UI/Web/src/app/cards/bulk-selection.service.ts @@ -4,7 +4,7 @@ import {ReplaySubject} from 'rxjs'; import {filter} from 'rxjs/operators'; import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service'; -type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection'; +type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection' | 'readingList'; /** * Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops. @@ -159,6 +159,10 @@ export class BulkSelectionService { return this.applyFilterToList(this.actionFactory.getCollectionTagActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]); } + if (Object.keys(this.selectedCards).filter(item => item === 'readingList').length > 0) { + return this.applyFilterToList(this.actionFactory.getReadingListActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]); + } + return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions); } diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index 4143e9c4a..7ec5a843b 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -4,6 +4,7 @@
{{t('item-count', {num: collections.length | number})}}
+ {{libraryName}} -
{{t('common.series-count', {num: pagination.totalItems | number})}}
+ @if (active.fragment === '') { +
{{t('common.series-count', {num: pagination.totalItems | number})}}
+ } + - - - - - + @if (filter) { + + + + + + } + diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index d672e83c7..589eabf4d 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -33,18 +33,18 @@ import {SentenceCasePipe} from '../_pipes/sentence-case.pipe'; import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component'; import {SeriesCardComponent} from '../cards/series-card/series-card.component'; import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component'; -import {DecimalPipe, NgFor, NgIf} from '@angular/common'; +import {DecimalPipe} from '@angular/common'; import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap'; import { SideNavCompanionBarComponent } from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {TranslocoDirective} from "@ngneat/transloco"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; -import {MetadataService} from "../_services/metadata.service"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterField} from "../_models/metadata/v2/filter-field"; import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component"; import {LoadingComponent} from "../shared/loading/loading.component"; +import {debounceTime, ReplaySubject, tap} from "rxjs"; @Component({ selector: 'app-library-detail', @@ -52,14 +52,25 @@ import {LoadingComponent} from "../shared/loading/loading.component"; styleUrls: ['./library-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf - , CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent] + imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, + CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent] }) export class LibraryDetailComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); - private readonly metadataService = inject(MetadataService); private readonly cdRef = inject(ChangeDetectorRef); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly seriesService = inject(SeriesService); + private readonly libraryService = inject(LibraryService); + private readonly titleService = inject(Title); + private readonly actionFactoryService = inject(ActionFactoryService); + private readonly actionService = inject(ActionService); + private readonly hubService = inject(MessageHubService); + private readonly utilityService = inject(UtilityService); + private readonly filterUtilityService = inject(FilterUtilitiesService); + public readonly navService = inject(NavService); + public readonly bulkSelectionService = inject(BulkSelectionService); libraryId!: number; libraryName = ''; @@ -82,6 +93,8 @@ export class LibraryDetailComponent implements OnInit { ]; active = this.tabs[0]; + loadPageSource = new ReplaySubject(1); + loadPage$ = this.loadPageSource.asObservable(); bulkActionCallback = async (action: ActionItem, data: any) => { const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series'); @@ -142,10 +155,8 @@ export class LibraryDetailComponent implements OnInit { } } - constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService, - private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, - private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService, - private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService) { + + constructor() { const routeId = this.route.snapshot.paramMap.get('libraryId'); if (routeId === null) { this.router.navigateByUrl('/home'); @@ -180,6 +191,8 @@ export class LibraryDetailComponent implements OnInit { this.filterSettings.presetsV2 = this.filter; + this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe(); + this.cdRef.markForCheck(); }); } @@ -191,7 +204,7 @@ export class LibraryDetailComponent implements OnInit { const seriesAdded = event.payload as SeriesAddedEvent; if (seriesAdded.libraryId !== this.libraryId) return; if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) { - this.loadPage(); + this.loadPageSource.next(true); return; } this.seriesService.getSeries(seriesAdded.seriesId).subscribe(s => { @@ -211,7 +224,7 @@ export class LibraryDetailComponent implements OnInit { const seriesRemoved = event.payload as SeriesRemovedEvent; if (seriesRemoved.libraryId !== this.libraryId) return; if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) { - this.loadPage(); // TODO: This can be quite expensive when bulk deleting. We can refactor this to an ReplaySubject to debounce + this.loadPageSource.next(true); return; } @@ -286,12 +299,12 @@ export class LibraryDetailComponent implements OnInit { this.filter = data.filterV2; if (data.isFirst) { - this.loadPage(); + this.loadPageSource.next(true); return; } this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => { - this.loadPage(); + this.loadPageSource.next(true); }); } diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html index 1be1d0f7e..1994591f4 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html @@ -1,39 +1,49 @@ -
- Captures Scroll Events: {{!this.isScrolling && this.allImagesLoaded}} - Is Scrolling: {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}} - All Images Loaded: {{this.allImagesLoaded}} - Prefetched {{minPageLoaded}}-{{maxPageLoaded}} - Pages: {{pageNum}} / {{totalPages - 1}} - At Top: {{atTop}} - At Bottom: {{atBottom}} - Total Height: {{getTotalHeight()}} - Total Scroll: {{getTotalScroll()}} - Scroll Top: {{getScrollTop()}} -
- -
diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss index 3f0b243c5..b627c84e2 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.scss @@ -21,8 +21,12 @@ .text { z-index: 101; - + } + + .empty-space { + height: 200px; + } } diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts index 0ae69300a..802d0d3fa 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts @@ -1,4 +1,4 @@ -import { DOCUMENT, NgIf, NgFor, AsyncPipe } from '@angular/common'; +import {DOCUMENT, AsyncPipe, NgStyle} from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, @@ -16,7 +16,7 @@ import { Renderer2, SimpleChanges, ViewChild } from '@angular/core'; -import {BehaviorSubject, filter, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs'; +import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { ScrollService } from 'src/app/_services/scroll.service'; import { ReaderService } from '../../../_services/reader.service'; @@ -25,7 +25,6 @@ import { WebtoonImage } from '../../_models/webtoon-image'; import { ManagaReaderService } from '../../_service/managa-reader.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {TranslocoDirective} from "@ngneat/transloco"; -import {MangaReaderComponent} from "../manga-reader/manga-reader.component"; import {InfiniteScrollModule} from "ngx-infinite-scroll"; import {ReaderSetting} from "../../_models/reader-setting"; import {SafeStylePipe} from "../../../_pipes/safe-style.pipe"; @@ -63,7 +62,7 @@ const enum DEBUG_MODES { styleUrls: ['./infinite-scroller.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, NgFor, AsyncPipe, TranslocoDirective, InfiniteScrollModule, SafeStylePipe] + imports: [AsyncPipe, TranslocoDirective, InfiniteScrollModule, SafeStylePipe, NgStyle] }) export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { @@ -174,6 +173,14 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, */ debugLogFilter: Array = ['[PREFETCH]', '[Intersection]', '[Visibility]', '[Image Load]']; + /** + * Width override for maunal width control + * 2 observables needed to avoid flickering, probably due to data races, when changing the width + * this allows to precicely define execution order + */ + widthOverride$ : Observable = new Observable(); + widthSliderValue$ : Observable = new Observable(); + get minPageLoaded() { return Math.min(...Object.values(this.imagesLoaded)); } @@ -232,6 +239,31 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, takeUntilDestroyed(this.destroyRef) ); + + this.widthSliderValue$ = this.readerSettings$.pipe( + map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'), + takeUntilDestroyed(this.destroyRef) + ); + + this.widthOverride$ = this.widthSliderValue$; + + //perfom jump so the page stays in view + this.widthSliderValue$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum); + if(!this.currentPageElem) + return; + + let images = Array.from(document.querySelectorAll('img[id^="page-"]')) as HTMLImageElement[]; + images.forEach((img) => { + this.renderer.setStyle(img, "width", val); + }); + + this.widthOverride$ = this.widthSliderValue$; + this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top; + this.currentPageElem.scrollIntoView(); + this.cdRef.markForCheck(); + }); + if (this.goToPage) { this.goToPage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(page => { const isSamePage = this.pageNum === page; @@ -381,7 +413,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, this.cdRef.markForCheck(); } - if (totalScroll === totalHeight && !this.atBottom) { + if (totalHeight != 0 && totalScroll >= totalHeight && !this.atBottom) { this.atBottom = true; this.cdRef.markForCheck(); this.setPageNum(this.totalPages); @@ -392,6 +424,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, document.body.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2); this.cdRef.markForCheck(); }); + this.checkIfShouldTriggerContinuousReader() } else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) { // This if statement will fire once we scroll into the spacer at all this.loadNextChapter.emit(); diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html index dabfeb095..21b711822 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html @@ -276,6 +276,18 @@ min="10" max="100" step="1" formControlName="darkness"> +
+ + +
+
diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 6648c6f33..c9d10881a 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -19,6 +19,7 @@ import { BehaviorSubject, debounceTime, distinctUntilChanged, + filter, forkJoin, fromEvent, map, @@ -398,6 +399,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Show and log debug information */ debugMode: boolean = false; + /** + * Width override label for manual width control + */ + widthOverrideLabel$ : Observable = new Observable(); // Renderer interaction readerSettings$!: Observable; @@ -513,6 +518,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { autoCloseMenu: new FormControl(this.autoCloseMenu), pageSplitOption: new FormControl(this.pageSplitOption), fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)), + widthSlider: new FormControl('none'), layoutMode: new FormControl(this.layoutMode), darkness: new FormControl(100), emulateBook: new FormControl(this.user.preferences.emulateBook), @@ -549,8 +555,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { takeUntilDestroyed(this.destroyRef) ).subscribe(() => {}); - - + this.setupWidthOverrideTriggers(); this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { @@ -560,11 +565,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.layoutMode === LayoutMode.Single) { this.generalSettingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption); this.generalSettingsForm.get('pageSplitOption')?.enable(); + this.generalSettingsForm.get('widthSlider')?.enable(); this.generalSettingsForm.get('fittingOption')?.enable(); this.generalSettingsForm.get('emulateBook')?.enable(); } else { this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.NoSplit); this.generalSettingsForm.get('pageSplitOption')?.disable(); + this.generalSettingsForm.get('widthSlider')?.disable(); this.generalSettingsForm.get('fittingOption')?.setValue(this.mangaReaderService.translateScalingOption(ScalingOption.FitToHeight)); this.generalSettingsForm.get('fittingOption')?.disable(); this.generalSettingsForm.get('emulateBook')?.enable(); @@ -692,10 +699,73 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + /** + * Width override is only valid under the following conditions: + * Image Scaling is Width + * Reader Mode is Webtoon + * + * In all other cases, the form will be disabled and set to 0 which indicates default/off state. + */ + setupWidthOverrideTriggers() { + const widthOverrideControl = this.generalSettingsForm.get('widthSlider')!; + + const enableWidthOverride = () => { + widthOverrideControl.enable(); + }; + + const disableWidthOverride = () => { + widthOverrideControl.setValue(0); + widthOverrideControl.disable(); + }; + + const handleControlChanges = () => { + const fitting = this.generalSettingsForm.get('fittingOption')?.value; + const splitting = this.generalSettingsForm.get('pageSplitOption')?.value; + + if ((PageSplitOption.FitSplit == splitting && FITTING_OPTION.WIDTH == fitting) || this.readerMode === ReaderMode.Webtoon) { + enableWidthOverride(); + } else { + disableWidthOverride(); + } + }; + + // Reader mode changes + this.readerModeSubject.asObservable() + .pipe( + filter(v => v === ReaderMode.Webtoon), + tap(enableWidthOverride), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); + + // Page split option changes + this.generalSettingsForm.get('pageSplitOption')?.valueChanges.pipe( + distinctUntilChanged(), + tap(handleControlChanges), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); + + // Fitting option changes + this.generalSettingsForm.get('fittingOption')?.valueChanges.pipe( + tap(handleControlChanges), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); + + // Set the default override to 0 + widthOverrideControl.setValue(0); + + //send the current width override value to the label + this.widthOverrideLabel$ = this.readerSettings$?.pipe( + map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'), + takeUntilDestroyed(this.destroyRef) + ); + } + createReaderSettingsUpdate() { return { pageSplit: parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10), fitting: (this.generalSettingsForm.get('fittingOption')?.value as FITTING_OPTION), + widthSlider: this.generalSettingsForm.get('widthSlider')?.value, layoutMode: this.layoutMode, darkness: parseInt(this.generalSettingsForm.get('darkness')?.value + '', 10) || 100, pagingDirection: this.pagingDirection, diff --git a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html index 8bf38e878..a0ebb4abc 100644 --- a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html +++ b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html @@ -3,6 +3,7 @@ [style.filter]="(darkness$ | async) ?? '' | safeStyle" [style.height]="(imageContainerHeight$ | async) ?? '' | safeStyle"> @if(currentImage) {  = new Observable(); + get ReaderMode() {return ReaderMode;} get LayoutMode() {return LayoutMode;} @@ -67,6 +72,13 @@ export class SingleRendererComponent implements OnInit, ImageRenderer { takeUntilDestroyed(this.destroyRef) ); + //handle manual width + this.widthOverride$ = this.readerSettings$.pipe( + map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'), + takeUntilDestroyed(this.destroyRef) + ); + + this.emulateBookClass$ = this.readerSettings$.pipe( map(data => data.emulateBook), map(enabled => enabled ? 'book-shadow' : ''), diff --git a/UI/Web/src/app/manga-reader/_models/reader-enums.ts b/UI/Web/src/app/manga-reader/_models/reader-enums.ts index f33059a96..b8aec8130 100644 --- a/UI/Web/src/app/manga-reader/_models/reader-enums.ts +++ b/UI/Web/src/app/manga-reader/_models/reader-enums.ts @@ -1,7 +1,7 @@ export enum FITTING_OPTION { HEIGHT = 'full-height', WIDTH = 'full-width', - ORIGINAL = 'original' + ORIGINAL = 'original', } /** @@ -12,9 +12,9 @@ export enum SPLIT_PAGE_PART { LEFT_PART = 'left', RIGHT_PART = 'right' } - + export enum PAGING_DIRECTION { FORWARD = 1, BACKWARDS = -1, } - + diff --git a/UI/Web/src/app/manga-reader/_models/reader-setting.ts b/UI/Web/src/app/manga-reader/_models/reader-setting.ts index 33c3e7ba4..0c2ffddbd 100644 --- a/UI/Web/src/app/manga-reader/_models/reader-setting.ts +++ b/UI/Web/src/app/manga-reader/_models/reader-setting.ts @@ -6,9 +6,10 @@ import { FITTING_OPTION, PAGING_DIRECTION } from "./reader-enums"; export interface ReaderSetting { pageSplit: PageSplitOption; fitting: FITTING_OPTION; + widthSlider: string; layoutMode: LayoutMode; darkness: number; pagingDirection: PAGING_DIRECTION; readerMode: ReaderMode; emulateBook: boolean; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html index 2525e5edd..91a38a4a4 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html @@ -1,139 +1,118 @@ - - - - - - - - + @if (isAdmin$ | async) { + @if (downloadService.activeDownloads$ | async; as activeDownloads) { + @if (errors$ | async; as errors) { + @if (infos$ | async; as infos) { + @if (messageHub.onlineUsers$ | async; as onlineUsers) { + + } + } + } + } -
    - - -
  • - {{t('dismiss-all')}} -
  • -
    -
    - @if (debugMode) { - -
  • -
    Title goes here
    -
    Subtitle goes here
    -
    -
    -
    -
    -
    -
  • -
  • -
    Title goes here
    -
    Subtitle goes here
    -
  • -
  • -
    -
    Scanning Books
    -
    E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub
    -
    -
    {{prettyPrintProgress(0.1)}}%
    -
    -
    -
    -
    - -
    -
  • -
  • -
    -
    There was some library scan error
    -
    Click for more information
    -
    - -
  • -
  • -
    -
    Scan didn't run becasuse nothing to do
    -
    Click for more information
    -
    - -
  • -
  • -
    - - - - 10% downloaded - - - Downloading {{'series' | sentenceCase}} -
    -
    PDFs
    -
  • -
    + @if(errors$ | async; as errors) { + @if(infos$ | async; as infos) { + @if (errors.length > 0 || infos.length > 0) { +
  • + {{t('dismiss-all')}} +
  • + } + } } - - - -
  • -
    {{message.title}}
    - @if (message.subTitle !== '') { -
    {{message.subTitle}}
    - } - @if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) { -
    {{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}
    - } -
    - @if(message.progress === 'indeterminate') { -
    -
    -
    - } -
    -
  • - + @if (progressEvents$ | async; as progressUpdates) { + @for (message of progressUpdates; track message) { + @if (message.progress === 'indeterminate' || message.progress === 'none') {
  • {{message.title}}
    -
    {{message.subTitle}}
    + @if (message.subTitle !== '') { +
    {{message.subTitle}}
    + } + @if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) { +
    + {{t('left-to-process', {leftToProcess: message.body.leftToProcess})}} +
    + } +
    + @if(message.progress === 'indeterminate') { +
    +
    +
    + } +
    +
  • + } @else { +
  • +
    {{message.title}}
    + @if (message.subTitle !== '') { +
    {{message.subTitle}}
    + }
    {{prettyPrintProgress(message.body.progress) + '%'}}
    -
    +
  • -
    -
    -
    + } + } + } - - -
  • - {{t('update-available')}} -
  • -
  • -
    {{singleUpdate.title}}
    -
    {{singleUpdate.subTitle}}
    -
  • -
    -
    + @if (singleUpdates$ | async; as singleUpdates) { + @for(singleUpdate of singleUpdates; track singleUpdate) { + @if (singleUpdate.name === EVENTS.UpdateAvailable) { +
  • + {{t('update-available')}} +
  • + } @else { +
  • +
    {{singleUpdate.title}}
    + @if (singleUpdate.subTitle !== '') { +
    {{singleUpdate.subTitle}}
    + } +
  • + } + } + } - - + @if (downloadService.activeDownloads$ | async; as activeDownloads) { + @for(download of activeDownloads; track download) {
  • {{t('downloading-item', {item: download.entityType | sentenceCase})}}
    -
    {{download.subTitle}}
    + + @if (download.subTitle !== '') { +
    {{download.subTitle}}
    + } +
    {{download.progress}}%
    @@ -141,57 +120,49 @@
  • -
    - @if(activeDownloads.length > 1) { -
  • {{activeDownloads.length}} downloads in Queue
  • } -
    - + @if(activeDownloads.length > 1) { +
  • {{t('download-in-queue', {num: activeDownloads.length})}}
  • + } + } - - + @if (errors$ | async; as errors) { + @for (error of errors; track error) { - - + } + } - - + @if (infos$ | async; as infos) { + @for (info of infos; track info) { - - - - - @if (messageHub.onlineUsers$ | async; as onlineUsers) { - @if (onlineUsers.length > 1) { -
  • -
    {{t('users-online-count', {num: onlineUsers.length})}}
    -
  • - } - - @if (debugMode) { -
  • {{t('active-events-title')}} {{activeEvents}}
  • } } - -
  • {{t('no-data')}}
  • -
    - + @if (downloadService.activeDownloads$ | async; as activeDownloads) { + @if (errors$ | async; as errors) { + @if (infos$ | async; as infos) { + @if (infos.length === 0 && errors.length === 0 && activeDownloads.length === 0 && activeEvents === 0) { +
  • {{t('no-data')}}
  • + } + } + } + }
-
+ } +
diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss index cd8143905..59594c8a3 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss @@ -14,6 +14,26 @@ border-bottom-color: transparent; } +.colored { + color: var(--event-widget-activity-bg-color) !important; +} + +.widget-button--indicator { + position: absolute; + top: 30px; + color: var(--event-widget-activity-bg-color); + + &.error { + color: var(--event-widget-error-bg-color) !important; + } + &.info { + color: var(--event-widget-info-bg-color) !important; + } + &.update { + color: var(--event-widget-update-bg-color) !important; + } +} + ::ng-deep .nav-events { .popover-body { @@ -56,67 +76,56 @@ .btn-icon { - color: white; + color: var(--event-widget-text-color); } -.colored { - background-color: var(--primary-color); - border-radius: 60px; -} -.colored-error { - background-color: var(--error-color) !important; - border-radius: 60px; -} - -.colored-info { - background-color: var(--event-widget-info-bg-color) !important; - border-radius: 60px; -} - -.update-available { +.dark-menu-item { + &.update-available { cursor: pointer; i.fa { - color: var(--primary-color) !important; + color: var(--primary-color) !important; } color: var(--primary-color); -} + } -.error { + &.error { cursor: pointer; position: relative; .h6 { - color: var(--error-color); + color: var(--event-widget-error-bg-color); } i.fa { - color: var(--primary-color) !important; + color: var(--primary-color) !important; } .btn-close { - top: 5px; - right: 10px; - font-size: 11px; - position: absolute; + top: 5px; + right: 10px; + font-size: 11px; + position: absolute; } -} + } -.info { + &.info { cursor: pointer; position: relative; .h6 { - color: var(--event-widget-info-bg-color); + color: var(--event-widget-info-bg-color); } i.fa { - color: var(--primary-color) !important; + color: var(--primary-color) !important; } .btn-close { - top: 10px; - right: 10px; - font-size: 11px; - position: absolute; + top: 10px; + right: 10px; + font-size: 11px; + position: absolute; } + } + } diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts index 7f073519a..1dab6dc3e 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts @@ -25,7 +25,7 @@ import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hu import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe'; import { CircularLoaderComponent } from '../../../shared/circular-loader/circular-loader.component'; -import { NgIf, NgClass, NgStyle, NgFor, AsyncPipe } from '@angular/common'; +import { NgClass, NgStyle, AsyncPipe } from '@angular/common'; import {TranslocoDirective} from "@ngneat/transloco"; @Component({ @@ -34,12 +34,20 @@ import {TranslocoDirective} from "@ngneat/transloco"; styleUrls: ['./events-widget.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, NgClass, NgbPopover, NgStyle, CircularLoaderComponent, NgFor, AsyncPipe, SentenceCasePipe, TranslocoDirective] + imports: [NgClass, NgbPopover, NgStyle, CircularLoaderComponent, AsyncPipe, SentenceCasePipe, TranslocoDirective] }) export class EventsWidgetComponent implements OnInit, OnDestroy { - @Input({required: true}) user!: User; + public readonly downloadService = inject(DownloadService); + public readonly messageHub = inject(MessageHubService); + private readonly modalService = inject(NgbModal); + private readonly accountService = inject(AccountService); + private readonly confirmService = inject(ConfirmService); + private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); + @Input({required: true}) user!: User; + + isAdmin$: Observable = of(false); /** @@ -60,17 +68,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { private updateNotificationModalRef: NgbModalRef | null = null; activeEvents: number = 0; + /** + * Intercepts from Single Updates to show an extra indicator to the user + */ + updateAvailable: boolean = false; debugMode: boolean = false; protected readonly EVENTS = EVENTS; - public readonly downloadService = inject(DownloadService); - - constructor(public messageHub: MessageHubService, private modalService: NgbModal, - private accountService: AccountService, private confirmService: ConfirmService, - private readonly cdRef: ChangeDetectorRef) { - } ngOnDestroy(): void { this.progressEventsSource.complete(); @@ -115,6 +121,9 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { values.push(message); this.singleUpdateSource.next(values); this.activeEvents += 1; + if (event.payload.name === EVENTS.UpdateAvailable) { + this.updateAvailable = true; + } this.cdRef.markForCheck(); break; case 'started': diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html index 282e234b6..3b200f657 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html @@ -57,7 +57,7 @@
- + @if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) { diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html index 4b018865f..22227eaf4 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html @@ -4,8 +4,12 @@ {{t('title')}} -
{{t('item-count', {num: pagination.totalItems | number})}}
+ @if (pagination) { +
{{t('item-count', {num: pagination.totalItems | number})}}
+ } + + + (clicked)="handleClick(item)" + (selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)" + [selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true"> diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts index 4fb54d7f5..8fb7d7410 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, inject, OnInit} from '@angular/core'; import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; @@ -21,6 +21,12 @@ import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {Title} from "@angular/platform-browser"; import {WikiLink} from "../../../_models/wiki"; +import {BulkSelectionService} from "../../../cards/bulk-selection.service"; +import {BulkOperationsComponent} from "../../../cards/bulk-operations/bulk-operations.component"; +import {KEY_CODES} from "../../../shared/_services/utility.service"; +import {UserCollection} from "../../../_models/collection-tag"; +import {User} from "../../../_models/user"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Component({ selector: 'app-reading-lists', @@ -28,30 +34,51 @@ import {WikiLink} from "../../../_models/wiki"; styleUrls: ['./reading-lists.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgIf, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective] + imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgIf, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective, BulkOperationsComponent] }) export class ReadingListsComponent implements OnInit { + public readonly bulkSelectionService = inject(BulkSelectionService); + public readonly actionService = inject(ActionService); + protected readonly WikiLink = WikiLink; lists: ReadingList[] = []; loadingLists = false; pagination!: Pagination; isAdmin: boolean = false; + hasPromote: boolean = false; jumpbarKeys: Array = []; actions: {[key: number]: Array>} = {}; globalActions: Array> = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}]; trackByIdentity = (index: number, item: ReadingList) => `${item.id}_${item.title}`; - translocoService = inject(TranslocoService); + @HostListener('document:keydown.shift', ['$event']) + handleKeypress(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = true; + } + } + + @HostListener('document:keyup.shift', ['$event']) + handleKeyUp(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = false; + } + } + constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, - private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService, + private accountService: AccountService, private toastr: ToastrService, private router: Router, private jumpbarService: JumpbarService, private readonly cdRef: ChangeDetectorRef, private ngbModal: NgbModal, private titleService: Title) { } ngOnInit(): void { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); + this.hasPromote = this.accountService.hasPromoteRole(user); + + this.cdRef.markForCheck(); + this.loadPage(); this.titleService.setTitle('Kavita - ' + translate('side-nav.reading-lists')); } @@ -59,8 +86,10 @@ export class ReadingListsComponent implements OnInit { } getActions(readingList: ReadingList) { + const d = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) + .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); return this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) - .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); + .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); } performAction(action: ActionItem, readingList: ReadingList) { @@ -85,7 +114,7 @@ export class ReadingListsComponent implements OnInit { switch(action.action) { case Action.Delete: this.readingListService.delete(readingList.id).subscribe(() => { - this.toastr.success(this.translocoService.translate('toasts.reading-list-deleted')); + this.toastr.success(translate('toasts.reading-list-deleted')); this.loadPage(); }); break; @@ -126,4 +155,33 @@ export class ReadingListsComponent implements OnInit { handleClick(list: ReadingList) { this.router.navigateByUrl('lists/' + list.id); } + + bulkActionCallback = (action: ActionItem, data: any) => { + const selectedReadingListIndexies = this.bulkSelectionService.getSelectedCardsForSource('readingList'); + const selectedReadingLists = this.lists.filter((col, index: number) => selectedReadingListIndexies.includes(index + '')); + + switch (action.action) { + case Action.Promote: + this.actionService.promoteMultipleReadingLists(selectedReadingLists, true, (success) => { + if (!success) return; + this.bulkSelectionService.deselectAll(); + this.loadPage(); + }); + break; + case Action.UnPromote: + this.actionService.promoteMultipleReadingLists(selectedReadingLists, false, (success) => { + if (!success) return; + this.bulkSelectionService.deselectAll(); + this.loadPage(); + }); + break; + case Action.Delete: + this.actionService.deleteMultipleReadingLists(selectedReadingLists, (successful) => { + if (!successful) return; + this.loadPage(); + this.bulkSelectionService.deselectAll(); + }); + break; + } + } } diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.html b/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.html index 4bcf433fa..c1eb262b6 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.html @@ -1,21 +1,23 @@ -
-
-
{{heading}}
-
-
- - - - +@if (tags && tags.length > 0) { +
+
+
{{heading}}
+
+
+ + + @if(itemTemplate) { + - - - - - + } @else { + + + + } - - + +
-
+} + diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.ts b/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.ts index 2741a4a75..e4d25d177 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/metadata-detail/metadata-detail.component.ts @@ -1,5 +1,5 @@ import {ChangeDetectionStrategy, Component, ContentChild, inject, Input, TemplateRef} from '@angular/core'; -import {CommonModule} from '@angular/common'; +import {CommonModule, NgTemplateOutlet} from '@angular/common'; import {A11yClickDirective} from "../../../shared/a11y-click.directive"; import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component"; import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component"; @@ -11,7 +11,7 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv @Component({ selector: 'app-metadata-detail', standalone: true, - imports: [CommonModule, A11yClickDirective, BadgeExpanderComponent, TagBadgeComponent], + imports: [A11yClickDirective, BadgeExpanderComponent, TagBadgeComponent, NgTemplateOutlet], templateUrl: './metadata-detail.component.html', styleUrls: ['./metadata-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 399c641a5..003d45841 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -1,346 +1,393 @@
- - -

- - {{series.name}} - @if(isLoadingExtra || isLoading) { -
+ @if (series) { + + +

+ + {{series.name}} + @if(isLoadingExtra || isLoading) { +
loading...
- } + }
-

-
- -
{{series.localizedName}}
-
+

+
+ @if (series.localizedName !== series.name) { + +
{{series.localizedName}}
+
+ } + +
+
+

{{t('page-settings-title')}}

+ +
+
+
+
+
+ +
+
+ + - -
-
-

{{t('page-settings-title')}}

- -
-
- -
-
- -
-
- - - - - + + +
-
- + +
-
- + - + + } +
+ @if (series) { +
+
+
+ @if (unreadCount > 0 && unreadCount !== totalCount) { +
+ {{unreadCount}} +
+ } -
-
-
-
- {{unreadCount}} + + @if (series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial) { +
+ +
+
+ {{t('continue-from', {title: ContinuePointTitle})}} +
+ }
- - -
- -
-
- {{t('continue-from', {title: ContinuePointTitle})}} -
-
-
-
-
-
-
- -
- -
-
-
- -
-
- -
-
-
- +
+ @if (isAdmin) { +
+ +
+ } + +
+
+ +
+
+ + @if (isAdmin || hasDownloadingRole) { +
+ @if (download$ | async; as download) { + + } @else { + + } +
+ } +
-
- @if (download$ | async; as download) { - - } @else { - - } -
+ @if (seriesMetadata) { +
+ +
+ } +
- @if (seriesMetadata) { -
- -
- } - +
+ + + + + +
-
- - - - - -
-
+ @if (series) { +
- - -
- -
+
- - - + + } -
  • - {{t('specials-tab')}} - - - -
    - - - -
    -
    - - - - + @if (hasRecommendations) { +
  • + {{t('recommendations-tab')}} + + + @switch (renderMode) { + @case (PageLayoutMode.Cards) { +
    + @for(item of scroll.viewPortItems; let idx = $index; track idx) { + @if (!item.hasOwnProperty('coverUrl')) { + + } @else { + + } + } +
    + } + @case (PageLayoutMode.List) { + @for(item of scroll.viewPortItems; let idx = $index; track idx) { + @if (!item.hasOwnProperty('coverUrl')) { + + + + {{item.name}} + + + + } @else { + + + + {{item.name}} + + + + } + } + } + } +
    - - -
  • + + } -
  • - {{t('related-tab')}} - - -
    - - - -
    -
    -
    -
  • -
  • - {{t('recommendations-tab')}} - - - -
    - - - - - - - - -
    -
    - - - - - - - {{item.name}} - - - - - - - - - {{item.name}} - - - - - - + +
    + } -
    -
    -
  • - -
    - + +
    + + @if (nextExpectedChapter) { + @switch (tabId) { + @case (TabID.Volumes) { + @if (nextExpectedChapter.volumeNumber !== SpecialVolumeNumber && nextExpectedChapter.chapterNumber === LooseLeafOrSpecialNumber) { + + } + } + @case (TabID.Chapters) { + + } + @case (TabID.Storyline) { + + } + } + } + + } - -
    - - - - - - - - - - - - - - - - - + @if (!item.isSpecial) { + + + } - - + @if (item.number !== LooseLeafOrSpecialNumber) { + + + } @@ -354,27 +401,32 @@ - - - - - + @if (!item.isSpecial) { + + + + + + } - - - - - + @if (item.number !== LooseLeafOrSpecialNumber) { + + + + + + } + 0)) return true; - - return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic) - && (this.volumes.length > 0 || this.chapters.length > 0); - } - - get ShowVolumeTab() { - if (this.libraryType === LibraryType.ComicVine) { - if (this.volumes.length > 1) return true; - if (this.specials.length === 0 && this.chapters.length === 0) return true; - return false; - } - return this.volumes.length > 0; - } - - get ShowChaptersTab() { - return this.chapters.length > 0; - } - get UseBookLogic() { return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel; } @@ -380,26 +361,43 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { if (!this.currentlyReadingChapter.isSpecial) { const vol = this.volumes.filter(v => v.id === this.currentlyReadingChapter?.volumeId); + let chapterLocaleKey = 'common.chapter-num-shorthand'; + let volumeLocaleKey = 'common.volume-num-shorthand'; + switch (this.libraryType) { + case LibraryType.ComicVine: + case LibraryType.Comic: + chapterLocaleKey = 'common.issue-num-shorthand'; + break; + case LibraryType.Book: + case LibraryType.Manga: + case LibraryType.LightNovel: + case LibraryType.Images: + chapterLocaleKey = 'common.chapter-num-shorthand'; + break; + } + // This is a lone chapter if (vol.length === 0) { if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) { return this.currentlyReadingChapter.titleName; } - return 'Ch ' + this.currentlyReadingChapter.minNumber; // TODO: Refactor this to use DisplayTitle (or Range) and Localize it + return translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber}); } if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) { - return 'Vol ' + vol[0].minNumber; + return translate(chapterLocaleKey, {num: vol[0].minNumber}); } - return 'Vol ' + vol[0].minNumber + ' Ch ' + this.currentlyReadingChapter.minNumber; + return translate(volumeLocaleKey, {num: vol[0].minNumber}) + + ' ' + translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber}); } return this.currentlyReadingChapter.title; } + constructor(@Inject(DOCUMENT) private document: Document) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + this.accountService.currentUser$.subscribe(user => { if (user) { this.user = user; this.isAdmin = this.accountService.hasAdminRole(user); @@ -415,6 +413,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.scrollService.setScrollContainer(this.scrollingBlock); } + debugLog(message: string) { + console.log(message); + } + ngOnInit(): void { const routeId = this.route.snapshot.paramMap.get('seriesId'); @@ -424,7 +426,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { return; } - // Setup the download in progress + // Set up the download in progress this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => { return this.downloadService.mapToEntityType(events, this.series); })); @@ -652,12 +654,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.titleService.setTitle('Kavita - ' + this.series.name + ' Details'); - this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this)) - .filter(action => action.action !== Action.Edit); - this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this)); this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)); - + this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this)) + .filter(action => action.action !== Action.Edit); this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => { @@ -677,6 +677,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { ...relations.editions.map(item => this.createRelatedSeries(item, RelationKind.Edition)), ...relations.annuals.map(item => this.createRelatedSeries(item, RelationKind.Annual)), ]; + if (this.relations.length > 0) { this.hasRelations = true; this.cdRef.markForCheck(); @@ -690,7 +691,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.router.navigateByUrl('/home'); return of(null); })).subscribe(detail => { - if (detail == null) return; + if (detail == null) { + this.router.navigateByUrl('/home'); + return; + } + this.unreadCount = detail.unreadCount; this.totalCount = detail.totalCount; @@ -700,6 +705,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.chapters = detail.chapters; this.volumes = detail.volumes; this.storyChapters = detail.storylineChapters; + this.storylineItems = []; const v = this.volumes.map(v => { return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem; @@ -710,10 +716,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { }); this.storylineItems.push(...c); - + this.updateWhichTabsToShow(); this.updateSelectedTab(); + + this.isLoading = false; this.cdRef.markForCheck(); + console.log('isLoading is now false') }); }, err => { this.router.navigateByUrl('/home'); @@ -724,6 +733,35 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { return {series, relation} as RelatedSeriesPair; } + shouldShowStorylineTab() { + if (this.libraryType === LibraryType.ComicVine) return false; + // Edge case for bad pdf parse + if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true; + + return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic) + && (this.volumes.length > 0 || this.chapters.length > 0); + } + + shouldShowVolumeTab() { + if (this.libraryType === LibraryType.ComicVine) { + if (this.volumes.length > 1) return true; + if (this.specials.length === 0 && this.chapters.length === 0) return true; + return false; + } + return this.volumes.length > 0; + } + + shouldShowChaptersTab() { + return this.chapters.length > 0; + } + + updateWhichTabsToShow() { + this.showVolumeTab = this.shouldShowVolumeTab(); + this.showStorylineTab = this.shouldShowStorylineTab(); + this.showChapterTab = this.shouldShowChaptersTab(); + this.cdRef.markForCheck(); + } + /** * This will update the selected tab * @@ -771,10 +809,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { loadPlusMetadata(seriesId: number, libraryType: LibraryType) { this.isLoadingExtra = true; this.cdRef.markForCheck(); + this.metadataService.getSeriesMetadataFromPlus(seriesId, libraryType).subscribe(data => { - this.isLoadingExtra = false; - this.cdRef.markForCheck(); - if (data === null) return; + if (data === null) { + this.isLoadingExtra = false; + this.cdRef.markForCheck(); + console.log('isLoadingExtra is false') + return; + } // Reviews this.reviews = [...data.reviews]; @@ -790,7 +832,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.hasRecommendations = this.combinedRecs.length > 0; + this.isLoadingExtra = false; this.cdRef.markForCheck(); + console.log('isLoadingExtra is false') }); } @@ -970,11 +1014,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { downloadSeries() { this.downloadService.download('series', this.series, (d) => { - if (d) { - this.downloadInProgress = true; - } else { - this.downloadInProgress = false; - } + this.downloadInProgress = !!d; this.cdRef.markForCheck(); }); } diff --git a/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.html b/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.html index 4ec35388a..0d0037479 100644 --- a/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-metadata-detail/series-metadata-detail.component.html @@ -123,14 +123,14 @@ - - + + {{item.name}} - - + + {{item.name}} @@ -141,8 +141,8 @@ - - + + {{item.name}} diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 7703d9f0b..352f3c431 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -220,6 +220,7 @@ export class DownloadService { ); } + private getIdKey(entity: Chapter | Volume) { if (this.utilityService.isVolume(entity)) return 'volumeId'; if (this.utilityService.isChapter(entity)) return 'chapterId'; diff --git a/UI/Web/src/app/shared/circular-loader/circular-loader.component.html b/UI/Web/src/app/shared/circular-loader/circular-loader.component.html index b703d6a86..8bc22ebf1 100644 --- a/UI/Web/src/app/shared/circular-loader/circular-loader.component.html +++ b/UI/Web/src/app/shared/circular-loader/circular-loader.component.html @@ -1,27 +1,30 @@ - -
    +@if (currentValue > 0) { + @if (showIcon) { +
    -
    -
    - -
    - +
    + } + +
    + +
    +} diff --git a/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts b/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts index e55b2ad12..f03ae2e04 100644 --- a/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts +++ b/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts @@ -1,14 +1,11 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; -import {CommonModule} from "@angular/common"; +import {CommonModule, NgClass, NgStyle} from "@angular/common"; import {NgCircleProgressModule } from "ng-circle-progress"; @Component({ selector: 'app-circular-loader', standalone: true, - imports: [CommonModule, NgCircleProgressModule], - // providers: [ - // importProvidersFrom(NgCircleProgressModule), - // ], + imports: [NgCircleProgressModule, NgStyle, NgClass], templateUrl: './circular-loader.component.html', styleUrls: ['./circular-loader.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html index 39591cca6..bfbd2c311 100644 --- a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html +++ b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html @@ -5,21 +5,28 @@