diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..5f755be --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,144 @@ +name: Build and Push Docker Image + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to build' + required: true + default: 'main' + type: string + platforms: + description: 'Target platforms (comma-separated)' + required: true + default: 'linux/amd64,linux/arm64' + type: choice + options: + - 'linux/amd64' + - 'linux/arm64' + - 'linux/amd64,linux/arm64' + tag: + description: 'Docker image tag (leave empty for branch name)' + required: false + type: string + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Setup Node.js and pnpm + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Build Frontend + working-directory: KaizokuFrontend + run: | + pnpm install --frozen-lockfile + pnpm run build + + - name: Package Frontend for Backend + run: | + cd KaizokuFrontend/out + zip -r ../../KaizokuBackend/wwwroot.zip . + sha256sum ../../KaizokuBackend/wwwroot.zip | awk '{print $1}' > ../../KaizokuBackend/wwwroot.sha256 + + - name: Build Backend (linux-x64) + if: contains(inputs.platforms, 'linux/amd64') + run: | + dotnet publish KaizokuBackend/KaizokuBackend.csproj \ + -c Release \ + -r linux-x64 \ + --self-contained true \ + -o KaizokuBackend/bin/linux/amd64 + + - name: Build Backend (linux-arm64) + if: contains(inputs.platforms, 'linux/arm64') + run: | + dotnet publish KaizokuBackend/KaizokuBackend.csproj \ + -c Release \ + -r linux-arm64 \ + --self-contained true \ + -o KaizokuBackend/bin/linux/arm64 + + - name: Determine image tag + id: tag + run: | + if [ -n "${{ inputs.tag }}" ]; then + echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT + else + # Sanitize branch name for Docker tag + BRANCH="${{ inputs.branch }}" + TAG=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9._-]/-/g' | tr '[:upper:]' '[:lower:]') + echo "tag=$TAG" >> $GITHUB_OUTPUT + fi + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ steps.tag.outputs.tag }} + type=raw,value=${{ steps.tag.outputs.tag }}-${{ github.sha }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./KaizokuBackend + file: ./KaizokuBackend/Dockerfile + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Summary + run: | + echo "## Docker Image Built Successfully" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Branch:** ${{ inputs.branch }}" >> $GITHUB_STEP_SUMMARY + echo "**Platforms:** ${{ inputs.platforms }}" >> $GITHUB_STEP_SUMMARY + echo "**Image:** \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Pull Command" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 7ecd1ce..774cf32 100644 --- a/.gitignore +++ b/.gitignore @@ -310,3 +310,7 @@ next-env.d.ts KaizokuBackend/runtime/kaizoku.schedule.db-wal /KaizokuBackend/wwwroot.sha256 /KaizokuBackend/wwwroot.zip + +# Unraid templates (keep .example, ignore local customizations) +/unraid/*.xml +!/unraid/*.xml.example diff --git a/Kaizoku.sln b/Kaizoku.sln index 8538540..0174834 100644 --- a/Kaizoku.sln +++ b/Kaizoku.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KaizokuBackend", "KaizokuBa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KaizokuTray", "KaizokuTray\KaizokuTray.csproj", "{8D53A467-2804-46E4-BE2C-4EA1A6369097}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KaizokuBackend.Tests", "KaizokuBackend.Tests\KaizokuBackend.Tests.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +43,18 @@ Global {8D53A467-2804-46E4-BE2C-4EA1A6369097}.Release|x64.Build.0 = Release|Any CPU {8D53A467-2804-46E4-BE2C-4EA1A6369097}.Release|x86.ActiveCfg = Release|Any CPU {8D53A467-2804-46E4-BE2C-4EA1A6369097}.Release|x86.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/KaizokuBackend.Tests/KaizokuBackend.Tests.csproj b/KaizokuBackend.Tests/KaizokuBackend.Tests.csproj new file mode 100644 index 0000000..a6b6b37 --- /dev/null +++ b/KaizokuBackend.Tests/KaizokuBackend.Tests.csproj @@ -0,0 +1,17 @@ + + + net9.0 + enable + enable + false + + + + + + + + + + + diff --git a/KaizokuBackend.Tests/Services/Archives/ArchiveWriterTests.cs b/KaizokuBackend.Tests/Services/Archives/ArchiveWriterTests.cs new file mode 100644 index 0000000..70b0dba --- /dev/null +++ b/KaizokuBackend.Tests/Services/Archives/ArchiveWriterTests.cs @@ -0,0 +1,539 @@ +using KaizokuBackend.Models; +using KaizokuBackend.Services.Archives; +using System.IO.Compression; +using Xunit; + +namespace KaizokuBackend.Tests.Services.Archives; + +/// +/// Tests for archive writer functionality including CBZ, PDF, and factory creation +/// +public class ArchiveWriterTests +{ + #region CbzArchiveWriter Tests + + [Fact] + public async Task CbzArchiveWriter_FileExtension_ReturnsCbz() + { + // Arrange + using var stream = new MemoryStream(); + await using var writer = new CbzArchiveWriter(stream); + + // Act + var extension = writer.FileExtension; + + // Assert + Assert.Equal(".cbz", extension); + } + + [Fact] + public async Task CbzArchiveWriter_CreatesValidZipFile() + { + // Arrange + using var outputStream = new MemoryStream(); + await using (var writer = new CbzArchiveWriter(outputStream)) + { + var imageData = CreateFakeImageData(); + using var imageStream = new MemoryStream(imageData); + + // Act + await writer.WriteEntryAsync("page001.jpg", imageStream); + await writer.FinalizeAsync(); + } + + // Assert - Verify ZIP structure + outputStream.Position = 0; + using var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Read); + Assert.Single(zipArchive.Entries); + Assert.Equal("page001.jpg", zipArchive.Entries[0].FullName); + } + + [Fact] + public async Task CbzArchiveWriter_WriteMultipleEntries_AddsAllToArchive() + { + // Arrange + using var outputStream = new MemoryStream(); + await using (var writer = new CbzArchiveWriter(outputStream)) + { + // Act + for (int i = 1; i <= 5; i++) + { + var imageData = CreateFakeImageData(); + using var imageStream = new MemoryStream(imageData); + await writer.WriteEntryAsync($"page{i:D3}.jpg", imageStream); + } + await writer.FinalizeAsync(); + } + + // Assert + outputStream.Position = 0; + using var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Read); + Assert.Equal(5, zipArchive.Entries.Count); + } + + [Fact] + public async Task CbzArchiveWriter_XmlEntry_UsesDeflateCompression() + { + // Arrange + using var outputStream = new MemoryStream(); + await using (var writer = new CbzArchiveWriter(outputStream)) + { + var xmlData = System.Text.Encoding.UTF8.GetBytes(""); + using var xmlStream = new MemoryStream(xmlData); + + // Act + await writer.WriteEntryAsync("ComicInfo.xml", xmlStream); + await writer.FinalizeAsync(); + } + + // Assert - XML entries should be compressed + outputStream.Position = 0; + using var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Read); + var entry = zipArchive.Entries[0]; + Assert.Equal("ComicInfo.xml", entry.FullName); + // Note: We can't easily test compression type in .NET, but we verified the code path + } + + [Fact] + public async Task CbzArchiveWriter_ImageEntry_UsesNoCompression() + { + // Arrange + using var outputStream = new MemoryStream(); + await using (var writer = new CbzArchiveWriter(outputStream)) + { + var imageData = CreateFakeImageData(); + using var imageStream = new MemoryStream(imageData); + + // Act + await writer.WriteEntryAsync("page001.png", imageStream); + await writer.FinalizeAsync(); + } + + // Assert - Image entries exist and are stored + outputStream.Position = 0; + using var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Read); + Assert.Single(zipArchive.Entries); + } + + [Fact] + public async Task CbzArchiveWriter_NullOutputStream_ThrowsArgumentNullException() + { + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await using var writer = new CbzArchiveWriter(null!); + }); + } + + [Fact] + public async Task CbzArchiveWriter_NullContent_ThrowsArgumentNullException() + { + // Arrange + using var stream = new MemoryStream(); + await using var writer = new CbzArchiveWriter(stream); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await writer.WriteEntryAsync("test.jpg", null!)); + } + + [Fact] + public async Task CbzArchiveWriter_WriteAfterFinalize_ThrowsInvalidOperationException() + { + // Arrange + using var stream = new MemoryStream(); + await using var writer = new CbzArchiveWriter(stream); + await writer.FinalizeAsync(); + + // Act & Assert + var imageData = CreateFakeImageData(); + using var imageStream = new MemoryStream(imageData); + await Assert.ThrowsAsync(async () => + await writer.WriteEntryAsync("test.jpg", imageStream)); + } + + [Fact] + public async Task CbzArchiveWriter_WriteAfterDispose_ThrowsObjectDisposedException() + { + // Arrange + using var stream = new MemoryStream(); + var writer = new CbzArchiveWriter(stream); + await writer.DisposeAsync(); + + // Act & Assert + var imageData = CreateFakeImageData(); + using var imageStream = new MemoryStream(imageData); + await Assert.ThrowsAsync(async () => + await writer.WriteEntryAsync("test.jpg", imageStream)); + } + + [Fact] + public async Task CbzArchiveWriter_FinalizeAfterDispose_ThrowsObjectDisposedException() + { + // Arrange + using var stream = new MemoryStream(); + var writer = new CbzArchiveWriter(stream); + await writer.DisposeAsync(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await writer.FinalizeAsync()); + } + + [Fact] + public async Task CbzArchiveWriter_DoubleDispose_DoesNotThrow() + { + // Arrange + using var stream = new MemoryStream(); + var writer = new CbzArchiveWriter(stream); + + // Act & Assert - Should not throw + await writer.DisposeAsync(); + await writer.DisposeAsync(); + } + + #endregion + + #region PdfArchiveWriter Tests + + [Fact] + public async Task PdfArchiveWriter_FileExtension_ReturnsPdf() + { + // Arrange + using var stream = new MemoryStream(); + await using var writer = new PdfArchiveWriter(stream); + + // Act + var extension = writer.FileExtension; + + // Assert + Assert.Equal(".pdf", extension); + } + + [Fact] + public async Task PdfArchiveWriter_SkipsNonImageEntries() + { + // Arrange + using var outputStream = new MemoryStream(); + await using (var writer = new PdfArchiveWriter(outputStream)) + { + var xmlData = System.Text.Encoding.UTF8.GetBytes(""); + using var xmlStream = new MemoryStream(xmlData); + + // Act + await writer.WriteEntryAsync("ComicInfo.xml", xmlStream); + await writer.FinalizeAsync(); + } + + // Assert - PDF should have been created but empty (no images) + // This will throw because no images were added + await Assert.ThrowsAsync(async () => + { + outputStream.Position = 0; + using var tempStream = new MemoryStream(); + await using var writer = new PdfArchiveWriter(tempStream); + var xmlData = System.Text.Encoding.UTF8.GetBytes(""); + using var xmlStream = new MemoryStream(xmlData); + await writer.WriteEntryAsync("ComicInfo.xml", xmlStream); + await writer.FinalizeAsync(); + }); + } + + [Fact] + public async Task PdfArchiveWriter_RecognizesImageExtensions() + { + // This test verifies that common image extensions are recognized + // We can't easily test PDF creation without real images, so we test the extension logic + + // Arrange + var imageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".tiff" }; + + // Act & Assert + foreach (var ext in imageExtensions) + { + using var stream = new MemoryStream(); + await using var writer = new PdfArchiveWriter(stream); + + // Create a simple PNG image (1x1 pixel, valid PNG header) + var pngData = CreateMinimalPngImage(); + using var imageStream = new MemoryStream(pngData); + + // Should not throw for image extensions + await writer.WriteEntryAsync($"test{ext}", imageStream); + } + } + + [Fact] + public async Task PdfArchiveWriter_NullOutputStream_ThrowsArgumentNullException() + { + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await using var writer = new PdfArchiveWriter(null!); + }); + } + + [Fact] + public async Task PdfArchiveWriter_NullContent_ThrowsArgumentNullException() + { + // Arrange + using var stream = new MemoryStream(); + await using var writer = new PdfArchiveWriter(stream); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await writer.WriteEntryAsync("test.jpg", null!)); + } + + [Fact] + public async Task PdfArchiveWriter_WriteAfterFinalize_ThrowsInvalidOperationException() + { + // Arrange + using var stream = new MemoryStream(); + await using var writer = new PdfArchiveWriter(stream); + + var pngData = CreateMinimalPngImage(); + using var imageStream = new MemoryStream(pngData); + await writer.WriteEntryAsync("test.png", imageStream); + await writer.FinalizeAsync(); + + // Act & Assert + using var newImageStream = new MemoryStream(pngData); + await Assert.ThrowsAsync(async () => + await writer.WriteEntryAsync("test2.png", newImageStream)); + } + + [Fact] + public async Task PdfArchiveWriter_WriteAfterDispose_ThrowsObjectDisposedException() + { + // Arrange + using var stream = new MemoryStream(); + var writer = new PdfArchiveWriter(stream); + await writer.DisposeAsync(); + + // Act & Assert + var pngData = CreateMinimalPngImage(); + using var imageStream = new MemoryStream(pngData); + await Assert.ThrowsAsync(async () => + await writer.WriteEntryAsync("test.png", imageStream)); + } + + [Fact] + public async Task PdfArchiveWriter_FinalizeWithoutImages_ThrowsInvalidOperationException() + { + // Arrange + using var stream = new MemoryStream(); + await using var writer = new PdfArchiveWriter(stream); + + // Act & Assert - Cannot create PDF with no images + await Assert.ThrowsAsync(async () => + await writer.FinalizeAsync()); + } + + [Fact] + public async Task PdfArchiveWriter_DoubleDispose_DoesNotThrow() + { + // Arrange + using var stream = new MemoryStream(); + var writer = new PdfArchiveWriter(stream); + + // Act & Assert - Should not throw + await writer.DisposeAsync(); + await writer.DisposeAsync(); + } + + [Fact] + public async Task PdfArchiveWriter_DoubleFinalizeAsync_DoesNotThrow() + { + // Arrange + using var stream = new MemoryStream(); + await using var writer = new PdfArchiveWriter(stream); + + var pngData = CreateMinimalPngImage(); + using var imageStream = new MemoryStream(pngData); + await writer.WriteEntryAsync("test.png", imageStream); + + // Act - First finalize + await writer.FinalizeAsync(); + + // Act & Assert - Second finalize should not throw + await writer.FinalizeAsync(); + } + + #endregion + + #region ArchiveWriterFactory Tests + + [Fact] + public void ArchiveWriterFactory_CreateCbz_ReturnsCbzWriter() + { + // Arrange + var factory = new ArchiveWriterFactory(); + using var stream = new MemoryStream(); + + // Act + using var writer = factory.Create(ArchiveFormat.Cbz, stream); + + // Assert + Assert.IsType(writer); + Assert.Equal(".cbz", writer.FileExtension); + } + + [Fact] + public void ArchiveWriterFactory_CreatePdf_ReturnsPdfWriter() + { + // Arrange + var factory = new ArchiveWriterFactory(); + using var stream = new MemoryStream(); + + // Act + using var writer = factory.Create(ArchiveFormat.Pdf, stream); + + // Assert + Assert.IsType(writer); + Assert.Equal(".pdf", writer.FileExtension); + } + + [Fact] + public void ArchiveWriterFactory_InvalidFormat_ThrowsArgumentOutOfRangeException() + { + // Arrange + var factory = new ArchiveWriterFactory(); + using var stream = new MemoryStream(); + + // Act & Assert + Assert.Throws(() => + factory.Create((ArchiveFormat)999, stream)); + } + + [Fact] + public void ArchiveWriterFactory_GetExtension_CbzFormat_ReturnsCbz() + { + // Act + var extension = ArchiveWriterFactory.GetExtension(ArchiveFormat.Cbz); + + // Assert + Assert.Equal(".cbz", extension); + } + + [Fact] + public void ArchiveWriterFactory_GetExtension_PdfFormat_ReturnsPdf() + { + // Act + var extension = ArchiveWriterFactory.GetExtension(ArchiveFormat.Pdf); + + // Assert + Assert.Equal(".pdf", extension); + } + + [Fact] + public void ArchiveWriterFactory_GetExtension_InvalidFormat_ReturnsCbz() + { + // Act + var extension = ArchiveWriterFactory.GetExtension((ArchiveFormat)999); + + // Assert - Default fallback + Assert.Equal(".cbz", extension); + } + + #endregion + + #region Integration Tests + + [Fact] + public async Task CbzArchiveWriter_CompleteWorkflow_CreatesValidArchive() + { + // Arrange + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = new CbzArchiveWriter(outputStream)) + { + // Add multiple pages + for (int i = 1; i <= 3; i++) + { + var imageData = CreateFakeImageData(); + using var imageStream = new MemoryStream(imageData); + await writer.WriteEntryAsync($"page{i:D3}.jpg", imageStream); + } + + // Add metadata + var xmlData = System.Text.Encoding.UTF8.GetBytes("Test"); + using var xmlStream = new MemoryStream(xmlData); + await writer.WriteEntryAsync("ComicInfo.xml", xmlStream); + + await writer.FinalizeAsync(); + } + + // Assert + outputStream.Position = 0; + using var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Read); + Assert.Equal(4, zipArchive.Entries.Count); + + // Verify entries + Assert.Contains(zipArchive.Entries, e => e.FullName == "page001.jpg"); + Assert.Contains(zipArchive.Entries, e => e.FullName == "page002.jpg"); + Assert.Contains(zipArchive.Entries, e => e.FullName == "page003.jpg"); + Assert.Contains(zipArchive.Entries, e => e.FullName == "ComicInfo.xml"); + } + + [Fact] + public async Task ArchiveWriterFactory_CreateAndUse_WorksCorrectly() + { + // Arrange + var factory = new ArchiveWriterFactory(); + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = factory.Create(ArchiveFormat.Cbz, outputStream)) + { + var imageData = CreateFakeImageData(); + using var imageStream = new MemoryStream(imageData); + await writer.WriteEntryAsync("test.jpg", imageStream); + await writer.FinalizeAsync(); + } + + // Assert + outputStream.Position = 0; + using var zipArchive = new ZipArchive(outputStream, ZipArchiveMode.Read); + Assert.Single(zipArchive.Entries); + } + + #endregion + + #region Helper Methods + + /// + /// Creates fake image data (not a real image, just test data) + /// + private static byte[] CreateFakeImageData() + { + var random = new Random(); + var data = new byte[1024]; + random.NextBytes(data); + return data; + } + + /// + /// Creates a minimal valid PNG image (1x1 pixel, black) + /// This is a valid PNG file that SkiaSharp can decode + /// + private static byte[] CreateMinimalPngImage() + { + // Minimal 1x1 black PNG (67 bytes) + return new byte[] + { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions + 0x08, 0x00, 0x00, 0x00, 0x00, 0x3A, 0x7E, 0x9B, // bit depth 8, grayscale + 0x55, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, // IDAT chunk + 0x54, 0x08, 0x1D, 0x01, 0x00, 0x00, 0xFF, 0xFF, // compressed data + 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, 0x27, + 0xDE, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, // IEND chunk + 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 + }; + } + + #endregion +} diff --git a/KaizokuBackend.Tests/Services/Background/JobQueueConcurrencyTests.cs b/KaizokuBackend.Tests/Services/Background/JobQueueConcurrencyTests.cs new file mode 100644 index 0000000..2fc2ce9 --- /dev/null +++ b/KaizokuBackend.Tests/Services/Background/JobQueueConcurrencyTests.cs @@ -0,0 +1,347 @@ +using System.Collections.Concurrent; +using Xunit; + +namespace KaizokuBackend.Tests.Services.Background; + +/// +/// Tests for verifying thread safety of the job queue slot allocation mechanism. +/// These tests validate the concurrency fixes in JobQueueHostedService, specifically: +/// - MaxThreads limit enforcement under concurrent load +/// - Thread-safe add/remove operations on running jobs dictionary +/// - Proper slot allocation with lock protection +/// +public class JobQueueConcurrencyTests +{ + /// + /// Simulates the slot allocation pattern used in JobQueueHostedService. + /// This tests the concurrent dictionary + lock pattern that prevents over-allocation. + /// + private class SlotAllocator + { + private readonly ConcurrentDictionary _runningJobs = new(); + private readonly object _slotLock = new(); + private readonly int _maxSlots; + + public SlotAllocator(int maxSlots) + { + _maxSlots = maxSlots; + } + + public int RunningCount => _runningJobs.Count; + + /// + /// Attempts to allocate a slot for the given job ID. + /// Uses the same pattern as JobQueueHostedService.ProcessQueueAsync. + /// + public bool TryAllocateSlot(string jobId) + { + lock (_slotLock) + { + if (_runningJobs.Count >= _maxSlots) + return false; + + return _runningJobs.TryAdd(jobId, 0); + } + } + + /// + /// Releases a slot for the given job ID. + /// + public bool ReleaseSlot(string jobId) + { + return _runningJobs.TryRemove(jobId, out _); + } + } + + [Fact] + public void TryAllocateSlot_SingleThread_RespectsMaxSlots() + { + // Arrange + const int maxSlots = 5; + var allocator = new SlotAllocator(maxSlots); + + // Act - allocate up to max + for (int i = 0; i < maxSlots; i++) + { + var result = allocator.TryAllocateSlot($"job-{i}"); + Assert.True(result, $"Should allocate slot {i}"); + } + + // Try to allocate one more + var overflowResult = allocator.TryAllocateSlot("job-overflow"); + + // Assert + Assert.False(overflowResult, "Should not allocate beyond max slots"); + Assert.Equal(maxSlots, allocator.RunningCount); + } + + [Fact] + public async Task TryAllocateSlot_ConcurrentAccess_NeverExceedsMaxSlots() + { + // This tests the race condition fix: without proper locking, + // concurrent allocations could exceed the max slot limit. + + // Arrange + const int maxSlots = 10; + const int concurrentAttempts = 100; + var allocator = new SlotAllocator(maxSlots); + var barrier = new Barrier(concurrentAttempts); + var successCount = 0; + var maxObservedCount = 0; + var countLock = new object(); + + // Act - try to allocate from many threads simultaneously + var tasks = Enumerable.Range(0, concurrentAttempts).Select(i => Task.Run(() => + { + barrier.SignalAndWait(); // Sync all threads to start together + + if (allocator.TryAllocateSlot($"job-{i}")) + { + var currentCount = allocator.RunningCount; + lock (countLock) + { + successCount++; + if (currentCount > maxObservedCount) + maxObservedCount = currentCount; + } + } + })); + + await Task.WhenAll(tasks); + + // Assert + Assert.Equal(maxSlots, successCount); + Assert.True(maxObservedCount <= maxSlots, + $"Running count ({maxObservedCount}) should never exceed max slots ({maxSlots})"); + } + + [Fact] + public async Task AllocateAndRelease_ConcurrentOperations_MaintainsConsistency() + { + // Tests that concurrent add/remove operations maintain consistency + + // Arrange + const int maxSlots = 20; + const int iterations = 1000; + var allocator = new SlotAllocator(maxSlots); + var exceptions = new ConcurrentBag(); + + // Act - perform many allocate/release cycles concurrently + var tasks = Enumerable.Range(0, iterations).Select(i => Task.Run(async () => + { + try + { + var jobId = $"job-{i}"; + + if (allocator.TryAllocateSlot(jobId)) + { + // Simulate some work + await Task.Delay(Random.Shared.Next(1, 5)); + + // Release the slot + var released = allocator.ReleaseSlot(jobId); + Assert.True(released, $"Should release allocated slot for {jobId}"); + } + } + catch (Exception ex) + { + exceptions.Add(ex); + } + })); + + await Task.WhenAll(tasks); + + // Assert + Assert.Empty(exceptions); + // After all operations, running count should be 0 + Assert.Equal(0, allocator.RunningCount); + } + + [Fact] + public async Task TryAllocateSlot_DuplicateJobId_RejectsSecondAttempt() + { + // Tests that the same job ID cannot be added twice + + // Arrange + var allocator = new SlotAllocator(10); + const string jobId = "duplicate-job"; + + // Act + var firstAttempt = allocator.TryAllocateSlot(jobId); + var secondAttempt = allocator.TryAllocateSlot(jobId); + + // Assert + Assert.True(firstAttempt, "First attempt should succeed"); + Assert.False(secondAttempt, "Second attempt with same ID should fail"); + Assert.Equal(1, allocator.RunningCount); + } + + [Fact] + public async Task ConcurrentAllocateRelease_UnderHighContention_NoExceptions() + { + // Stress test for the slot allocation mechanism + + // Arrange + const int maxSlots = 5; + const int threads = 50; + const int operationsPerThread = 100; + var allocator = new SlotAllocator(maxSlots); + var exceptions = new ConcurrentBag(); + var barrier = new Barrier(threads); + + // Act + var tasks = Enumerable.Range(0, threads).Select(threadId => Task.Run(async () => + { + barrier.SignalAndWait(); + + for (int i = 0; i < operationsPerThread; i++) + { + try + { + var jobId = $"thread-{threadId}-job-{i}"; + + if (allocator.TryAllocateSlot(jobId)) + { + // Verify we don't exceed max + var count = allocator.RunningCount; + if (count > maxSlots) + { + throw new InvalidOperationException( + $"Running count {count} exceeds max {maxSlots}"); + } + + await Task.Yield(); // Allow other threads to interleave + allocator.ReleaseSlot(jobId); + } + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + })); + + await Task.WhenAll(tasks); + + // Assert + Assert.Empty(exceptions); + } + + /// + /// Tests the pattern of checking count outside lock (optimization) + /// followed by atomic allocation inside lock. + /// + [Fact] + public async Task OptimisticCheckWithPessimisticAllocation_WorksCorrectly() + { + // This mirrors the actual pattern in JobQueueHostedService: + // 1. Quick check outside lock (optimization) + // 2. Re-check and allocate inside lock (correctness) + + // Arrange + const int maxSlots = 3; + var runningJobs = new ConcurrentDictionary(); + var slotLock = new object(); + var allocations = new ConcurrentBag(); + var barrier = new Barrier(20); + + async Task TryAllocateWithOptimisticCheck(string jobId) + { + // Optimistic check (no lock) + if (runningJobs.Count >= maxSlots) + return false; + + // Pessimistic allocation (with lock) + lock (slotLock) + { + if (runningJobs.Count >= maxSlots) + return false; + + return runningJobs.TryAdd(jobId, 0); + } + } + + // Act + var tasks = Enumerable.Range(0, 20).Select(i => Task.Run(async () => + { + barrier.SignalAndWait(); + + if (await TryAllocateWithOptimisticCheck($"job-{i}")) + { + allocations.Add($"job-{i}"); + } + })); + + await Task.WhenAll(tasks); + + // Assert + Assert.Equal(maxSlots, allocations.Count); + Assert.Equal(maxSlots, runningJobs.Count); + } + + /// + /// Tests that releasing a non-existent job ID is handled gracefully. + /// + [Fact] + public void ReleaseSlot_NonExistentJob_ReturnsFalse() + { + // Arrange + var allocator = new SlotAllocator(10); + + // Act + var result = allocator.ReleaseSlot("non-existent-job"); + + // Assert + Assert.False(result); + Assert.Equal(0, allocator.RunningCount); + } + + /// + /// Tests slot reuse after release. + /// + [Fact] + public void SlotReuse_AfterRelease_WorksCorrectly() + { + // Arrange + const int maxSlots = 2; + var allocator = new SlotAllocator(maxSlots); + + // Act - fill slots + Assert.True(allocator.TryAllocateSlot("job-1")); + Assert.True(allocator.TryAllocateSlot("job-2")); + Assert.False(allocator.TryAllocateSlot("job-3")); // Should fail + + // Release one + Assert.True(allocator.ReleaseSlot("job-1")); + + // Now should be able to allocate + Assert.True(allocator.TryAllocateSlot("job-3")); + Assert.Equal(maxSlots, allocator.RunningCount); + } + + /// + /// Tests multiple queues scenario (each queue has its own dictionary). + /// + [Fact] + public async Task MultipleQueues_IndependentSlotTracking() + { + // Arrange + var queue1 = new SlotAllocator(maxSlots: 2); + var queue2 = new SlotAllocator(maxSlots: 3); + + // Act + queue1.TryAllocateSlot("q1-job-1"); + queue1.TryAllocateSlot("q1-job-2"); + queue2.TryAllocateSlot("q2-job-1"); + queue2.TryAllocateSlot("q2-job-2"); + queue2.TryAllocateSlot("q2-job-3"); + + // Assert + Assert.Equal(2, queue1.RunningCount); + Assert.Equal(3, queue2.RunningCount); + + // Each queue should be at capacity + Assert.False(queue1.TryAllocateSlot("q1-overflow")); + Assert.False(queue2.TryAllocateSlot("q2-overflow")); + } +} diff --git a/KaizokuBackend.Tests/Services/Naming/TemplateParserTests.cs b/KaizokuBackend.Tests/Services/Naming/TemplateParserTests.cs new file mode 100644 index 0000000..e5f3e30 --- /dev/null +++ b/KaizokuBackend.Tests/Services/Naming/TemplateParserTests.cs @@ -0,0 +1,727 @@ +using KaizokuBackend.Models; +using KaizokuBackend.Services.Naming; +using Xunit; + +namespace KaizokuBackend.Tests.Services.Naming; + +/// +/// Tests for TemplateParser functionality including template parsing, validation, and preview generation +/// +public class TemplateParserTests +{ + private readonly ITemplateParser _parser; + private readonly Settings _settings; + + public TemplateParserTests() + { + _parser = new TemplateParser(); + _settings = new Settings + { + CategorizedFolders = true + }; + } + + private static TemplateVariables CreateSampleVariables() => new( + Series: "One Piece", + Chapter: 1089m, + Volume: 105, + Provider: "MangaDex", + Scanlator: "TCBScans", + Language: "en", + Title: "The Beginning", + UploadDate: new DateTime(2024, 6, 15), + Type: "Manga", + MaxChapter: 1200m + ); + + #region ParseFileName Tests + + [Fact] + public void ParseFileName_BasicTemplate_ReturnsCorrectResult() + { + // Arrange + var template = "{Series} - {Chapter}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("One Piece - 1089", result); + } + + [Fact] + public void ParseFileName_WithPaddingFormat_AppliesPaddingCorrectly() + { + // Arrange + var template = "{Series} - Chapter {Chapter:000}"; + var vars = CreateSampleVariables() with { Chapter = 5m }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("One Piece - Chapter 005", result); + } + + [Fact] + public void ParseFileName_WithFourDigitPadding_AppliesPaddingCorrectly() + { + // Arrange + var template = "{Chapter:0000}"; + var vars = CreateSampleVariables() with { Chapter = 42m }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("0042", result); + } + + [Fact] + public void ParseFileName_AutoPadsBasedOnMaxChapter_WorksCorrectly() + { + // Arrange + var template = "{Series} {Chapter}"; + var vars = CreateSampleVariables() with { Chapter = 5m, MaxChapter = 9999m }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert - Should pad to 4 digits based on MaxChapter 9999 + Assert.Equal("One Piece 0005", result); + } + + [Fact] + public void ParseFileName_DecimalChapter_FormatsCorrectly() + { + // Arrange + var template = "{Series} {Chapter}"; + var vars = CreateSampleVariables() with { Chapter = 123.5m }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("One Piece 0123.5", result); + } + + [Fact] + public void ParseFileName_SanitizesInvalidCharacters_ReplacesCorrectly() + { + // Arrange + var template = "{Series} {Chapter}"; + var vars = CreateSampleVariables() with { Series = "Test:Series/With*Invalid?Chars" }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert - Invalid filename characters should be removed/replaced + Assert.DoesNotContain(":", result); + Assert.DoesNotContain("/", result); + Assert.DoesNotContain("*", result); + Assert.DoesNotContain("?", result); + } + + [Fact] + public void ParseFileName_RemovesParentheses_FromSeriesName() + { + // Arrange + var template = "{Series}"; + var vars = CreateSampleVariables() with { Series = "Series (Name)" }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert - Parentheses should be removed by SanitizeForTemplate + Assert.Equal("Series Name", result); + } + + [Fact] + public void ParseFileName_WithAllVariables_FormatsCorrectly() + { + // Arrange + var template = "[{Provider}][{Language}] {Series} - Ch.{Chapter} Vol.{Volume:00} {Title} {Year}-{Month}-{Day}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Contains("MangaDex", result); + Assert.Contains("en", result); + Assert.Contains("One Piece", result); + Assert.Contains("1089", result); + Assert.Contains("105", result); + Assert.Contains("2024", result); + Assert.Contains("06", result); + Assert.Contains("15", result); + } + + [Fact] + public void ParseFileName_WithProvider_FormatsWithScanlator() + { + // Arrange + var template = "[{Provider}]"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert - Should include scanlator if different from provider + Assert.Contains("MangaDex", result); + Assert.Contains("TCBScans", result); + } + + [Fact] + public void ParseFileName_WithScanlatorVariable_ReturnsScanlator() + { + // Arrange + var template = "{Scanlator}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("TCBScans", result); + } + + [Fact] + public void ParseFileName_WithNullScanlator_ReturnsEmptyString() + { + // Arrange + var template = "{Series} [{Scanlator}]"; + var vars = CreateSampleVariables() with { Scanlator = null }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("One Piece []", result); + } + + [Fact] + public void ParseFileName_WithTitle_FormatsWithBrackets() + { + // Arrange + var template = "{Series} {Title}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert - Title should be wrapped in parentheses + Assert.Contains("(The Beginning)", result); + } + + [Fact] + public void ParseFileName_WithChapterTitle_SkipsTitle() + { + // Arrange + var template = "{Series} {Title}"; + var vars = CreateSampleVariables() with { Title = "Chapter 5" }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert - Should skip title if it contains "chapter" + Assert.Equal("One Piece", result.Trim()); + } + + [Fact] + public void ParseFileName_WithNullVolume_ReturnsEmptyForVolume() + { + // Arrange + var template = "{Series} Vol.{Volume}"; + var vars = CreateSampleVariables() with { Volume = null }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("One Piece Vol.", result); + } + + [Fact] + public void ParseFileName_WithNullChapter_ReturnsEmptyForChapter() + { + // Arrange + var template = "{Series} {Chapter}"; + var vars = CreateSampleVariables() with { Chapter = null }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("One Piece", result.Trim()); + } + + [Fact] + public void ParseFileName_TrimsMultipleSpaces_ToSingleSpace() + { + // Arrange + var template = "{Series} {Chapter}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert - Multiple spaces should be replaced with single space + Assert.DoesNotContain(" ", result); + } + + [Fact] + public void ParseFileName_VolumeWithDefaultPadding_PadsToTwoDigits() + { + // Arrange + var template = "Vol.{Volume}"; + var vars = CreateSampleVariables() with { Volume = 5 }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("Vol.05", result); + } + + [Fact] + public void ParseFileName_VolumeWithCustomPadding_AppliesPadding() + { + // Arrange + var template = "Vol.{Volume:000}"; + var vars = CreateSampleVariables() with { Volume = 5 }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("Vol.005", result); + } + + #endregion + + #region ParseFolderPath Tests + + [Fact] + public void ParseFolderPath_SimpleTemplate_ReturnsCorrectPath() + { + // Arrange + var template = "{Type}/{Series}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFolderPath(template, vars, _settings); + + // Assert + Assert.Equal(Path.Combine("Manga", "One Piece"), result); + } + + [Fact] + public void ParseFolderPath_WithMultipleSegments_BuildsCorrectPath() + { + // Arrange + var template = "{Type}/{Provider}/{Series}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFolderPath(template, vars, _settings); + + // Assert + var segments = result.Split(Path.DirectorySeparatorChar); + Assert.Equal(3, segments.Length); + Assert.Contains("Manga", segments); + Assert.Contains("MangaDex", segments); + Assert.Contains("One Piece", segments); + } + + [Fact] + public void ParseFolderPath_SanitizesEachSegment_Correctly() + { + // Arrange + var template = "{Series}"; + var vars = CreateSampleVariables() with { Series = "Test:Series/Invalid" }; + + // Act + var result = _parser.ParseFolderPath(template, vars, _settings); + + // Assert - Should remove invalid path characters + Assert.DoesNotContain(":", result); + Assert.DoesNotContain("/", result); + } + + [Fact] + public void ParseFolderPath_WithBackslashes_NormalizesToSystemSeparator() + { + // Arrange + var template = "{Type}\\{Series}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFolderPath(template, vars, _settings); + + // Assert + var segments = result.Split(Path.DirectorySeparatorChar); + Assert.Equal(2, segments.Length); + } + + [Fact] + public void ParseFolderPath_WithYear_FormatsCorrectly() + { + // Arrange + var template = "{Year}/{Series}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFolderPath(template, vars, _settings); + + // Assert + Assert.Contains("2024", result); + } + + [Fact] + public void ParseFolderPath_RemovesEmptySegments_Correctly() + { + // Arrange + var template = "{Series}///{Type}"; + var vars = CreateSampleVariables(); + + // Act + var result = _parser.ParseFolderPath(template, vars, _settings); + + // Assert - Empty segments should be removed + var segments = result.Split(Path.DirectorySeparatorChar); + Assert.All(segments, s => Assert.NotEmpty(s)); + } + + #endregion + + #region ValidateTemplate Tests + + [Fact] + public void ValidateTemplate_WithUnknownVariable_ReturnsError() + { + // Arrange + var template = "{Series} {InvalidVar}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FileName); + + // Assert + Assert.False(result.IsValid); + Assert.NotEmpty(result.Errors); + Assert.Contains(result.Errors, e => e.Contains("InvalidVar")); + } + + [Fact] + public void ValidateTemplate_WithValidVariables_ReturnsValid() + { + // Arrange + var template = "{Series} - {Chapter}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FileName); + + // Assert + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + [Fact] + public void ValidateTemplate_MissingSeries_ReturnsWarning() + { + // Arrange + var template = "{Chapter}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FileName); + + // Assert + Assert.True(result.IsValid); // Still valid, just warned + Assert.NotEmpty(result.Warnings); + Assert.Contains(result.Warnings, w => w.Contains("Series")); + } + + [Fact] + public void ValidateTemplate_MissingChapter_ReturnsWarning() + { + // Arrange + var template = "{Series}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FileName); + + // Assert + Assert.True(result.IsValid); + Assert.NotEmpty(result.Warnings); + Assert.Contains(result.Warnings, w => w.Contains("Chapter")); + } + + [Fact] + public void ValidateTemplate_FolderPathMissingSeries_ReturnsWarning() + { + // Arrange + var template = "{Type}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FolderPath); + + // Assert + Assert.True(result.IsValid); + Assert.NotEmpty(result.Warnings); + Assert.Contains(result.Warnings, w => w.Contains("Series")); + } + + [Fact] + public void ValidateTemplate_EmptyTemplate_ReturnsError() + { + // Arrange + var template = ""; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FileName); + + // Assert + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("empty")); + } + + [Fact] + public void ValidateTemplate_WhitespaceTemplate_ReturnsError() + { + // Arrange + var template = " "; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FileName); + + // Assert + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("empty")); + } + + [Fact] + public void ValidateTemplate_FileNameWithChapterVariable_NotAllowedInFolderPath() + { + // Arrange + var template = "{Series}/{Chapter}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FolderPath); + + // Assert + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Chapter")); + } + + [Fact] + public void ValidateTemplate_TracksUsedVariables_Correctly() + { + // Arrange + var template = "{Series} {Chapter} {Volume}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FileName); + + // Assert + Assert.Equal(3, result.UsedVariables.Count); + Assert.Contains("Series", result.UsedVariables, StringComparer.OrdinalIgnoreCase); + Assert.Contains("Chapter", result.UsedVariables, StringComparer.OrdinalIgnoreCase); + Assert.Contains("Volume", result.UsedVariables, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateTemplate_WithFormatSpecifier_ValidatesVariableName() + { + // Arrange + var template = "{Chapter:000}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FileName); + + // Assert + Assert.True(result.IsValid); + Assert.Contains("Chapter", result.UsedVariables, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateTemplate_FolderPathWithValidVariables_Succeeds() + { + // Arrange + var template = "{Type}/{Series}/{Language}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FolderPath); + + // Assert + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + #endregion + + #region GetPreview Tests + + [Fact] + public void GetPreview_FileNameTemplate_ReturnsSampleOutput() + { + // Arrange + var template = "{Series} - {Chapter}"; + + // Act + var result = _parser.GetPreview(template, TemplateType.FileName); + + // Assert + Assert.NotEmpty(result); + Assert.Contains("One Piece", result); + } + + [Fact] + public void GetPreview_FolderPathTemplate_ReturnsSampleOutput() + { + // Arrange + var template = "{Type}/{Series}"; + + // Act + var result = _parser.GetPreview(template, TemplateType.FolderPath); + + // Assert + Assert.NotEmpty(result); + Assert.Contains("Manga", result); + Assert.Contains("One Piece", result); + } + + [Fact] + public void GetPreview_ComplexTemplate_GeneratesPreview() + { + // Arrange + var template = "[{Provider}] {Series} - Ch.{Chapter:000} {Title}"; + + // Act + var result = _parser.GetPreview(template, TemplateType.FileName); + + // Assert + Assert.NotEmpty(result); + Assert.Contains("MangaDex", result); + Assert.Contains("One Piece", result); + } + + [Fact] + public void GetPreview_WithDecimalChapter_ShowsDecimalInPreview() + { + // Arrange - The sample data uses 1089.5 + var template = "{Chapter}"; + + // Act + var result = _parser.GetPreview(template, TemplateType.FileName); + + // Assert - Should show decimal chapter from sample data + Assert.Contains(".", result); + } + + #endregion + + #region Edge Cases + + [Fact] + public void ParseFileName_WithNullUploadDate_HandlesGracefully() + { + // Arrange + var template = "{Series} {Year}-{Month}-{Day}"; + var vars = CreateSampleVariables() with { UploadDate = null }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("One Piece", result.Trim()); // Date parts should be empty + } + + [Fact] + public void ParseFileName_WithNullType_UsesDefault() + { + // Arrange + var template = "{Type}"; + var vars = CreateSampleVariables() with { Type = null }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("Manga", result); // Should default to "Manga" + } + + [Fact] + public void ParseFileName_LanguageVariable_ConvertedToLowercase() + { + // Arrange + var template = "{Language}"; + var vars = CreateSampleVariables() with { Language = "EN" }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Equal("en", result); + } + + [Fact] + public void ParseFileName_TitleWithParentheses_ReplacedWithBrackets() + { + // Arrange + var template = "{Title}"; + var vars = CreateSampleVariables() with { Title = "Title (with parens)" }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Contains("[with parens]", result); + Assert.DoesNotContain("(with parens)", result); + } + + [Fact] + public void ParseFileName_ProviderWithHyphen_ReplacedWithUnderscore() + { + // Arrange + var template = "{Provider}"; + var vars = CreateSampleVariables() with { Provider = "Manga-Site", Scanlator = null }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Contains("Manga_Site", result); + } + + [Fact] + public void ParseFileName_ProviderWithBrackets_ReplacedWithParens() + { + // Arrange + var template = "{Provider}"; + var vars = CreateSampleVariables() with { Provider = "[Provider]", Scanlator = null }; + + // Act + var result = _parser.ParseFileName(template, vars, _settings); + + // Assert + Assert.Contains("(Provider)", result); + Assert.DoesNotContain("[Provider]", result); + } + + [Fact] + public void ValidateTemplate_CaseInsensitive_RecognizesVariables() + { + // Arrange + var template = "{SERIES} {chapter}"; + + // Act + var result = _parser.ValidateTemplate(template, TemplateType.FileName); + + // Assert + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + #endregion +} diff --git a/KaizokuBackend.Tests/Utils/KeyedAsyncLockTests.cs b/KaizokuBackend.Tests/Utils/KeyedAsyncLockTests.cs new file mode 100644 index 0000000..ebba277 --- /dev/null +++ b/KaizokuBackend.Tests/Utils/KeyedAsyncLockTests.cs @@ -0,0 +1,309 @@ +using KaizokuBackend.Utils; +using Xunit; + +namespace KaizokuBackend.Tests.Utils; + +/// +/// Tests for KeyedAsyncLock focusing on the reference counting mechanism +/// and concurrent access scenarios that were previously problematic. +/// +public class KeyedAsyncLockTests +{ + [Fact] + public async Task LockAsync_SingleKey_AcquiresAndReleasesLock() + { + // Arrange + var keyedLock = new KeyedAsyncLock(); + const string key = "test-key"; + + // Act + using (var lockHandle = await keyedLock.LockAsync(key)) + { + // Assert - lock was acquired successfully + Assert.NotNull(lockHandle); + } + // Lock released after using block + } + + [Fact] + public async Task LockAsync_DifferentKeys_AllowsConcurrentAccess() + { + // Arrange + var keyedLock = new KeyedAsyncLock(); + var key1Acquired = new TaskCompletionSource(); + var key2Acquired = new TaskCompletionSource(); + var bothAcquired = new TaskCompletionSource(); + + // Act - acquire locks on different keys concurrently + var task1 = Task.Run(async () => + { + using var lockHandle = await keyedLock.LockAsync("key1"); + key1Acquired.SetResult(true); + await bothAcquired.Task; // Hold lock until both are acquired + }); + + var task2 = Task.Run(async () => + { + using var lockHandle = await keyedLock.LockAsync("key2"); + key2Acquired.SetResult(true); + await bothAcquired.Task; // Hold lock until both are acquired + }); + + // Wait for both locks to be acquired (with timeout) + var key1Task = key1Acquired.Task; + var key2Task = key2Acquired.Task; + + var completedInTime = await Task.WhenAll( + Task.WhenAny(key1Task, Task.Delay(1000)), + Task.WhenAny(key2Task, Task.Delay(1000)) + ); + + // Assert - both locks were acquired concurrently + Assert.True(key1Task.IsCompleted && key1Task.Result, "Key1 lock should be acquired"); + Assert.True(key2Task.IsCompleted && key2Task.Result, "Key2 lock should be acquired"); + + // Cleanup + bothAcquired.SetResult(true); + await Task.WhenAll(task1, task2); + } + + [Fact] + public async Task LockAsync_SameKey_SerializesAccess() + { + // Arrange + var keyedLock = new KeyedAsyncLock(); + const string key = "shared-key"; + var firstLockAcquired = new TaskCompletionSource(); + var secondLockAttempted = new TaskCompletionSource(); + var secondLockAcquired = new TaskCompletionSource(); + var releaseFirstLock = new TaskCompletionSource(); + + // Act + var task1 = Task.Run(async () => + { + using var lockHandle = await keyedLock.LockAsync(key); + firstLockAcquired.SetResult(true); + await releaseFirstLock.Task; // Hold lock until signaled + }); + + // Wait for first lock to be acquired + await firstLockAcquired.Task; + + var task2 = Task.Run(async () => + { + secondLockAttempted.SetResult(true); + using var lockHandle = await keyedLock.LockAsync(key); + secondLockAcquired.SetResult(true); + }); + + // Wait for second lock attempt to start + await secondLockAttempted.Task; + await Task.Delay(50); // Give time for the second task to block + + // Assert - second lock should NOT be acquired yet + Assert.False(secondLockAcquired.Task.IsCompleted, + "Second lock should be blocked while first lock is held"); + + // Release first lock + releaseFirstLock.SetResult(true); + + // Wait for second lock (with timeout) + var completedTask = await Task.WhenAny(secondLockAcquired.Task, Task.Delay(1000)); + + // Assert - second lock should now be acquired + Assert.True(secondLockAcquired.Task.IsCompleted && secondLockAcquired.Task.Result, + "Second lock should be acquired after first is released"); + + await Task.WhenAll(task1, task2); + } + + [Fact] + public async Task LockAsync_ParallelOnSameKey_DoesNotDisposeWhileInUse() + { + // This test verifies the fix for the race condition where a semaphore + // could be disposed while another thread was still using it. + + // Arrange + var keyedLock = new KeyedAsyncLock(); + const string key = "race-condition-key"; + const int parallelCount = 50; + var exceptions = new List(); + var lockObject = new object(); + + // Act - hammer the same key from multiple threads + var tasks = Enumerable.Range(0, parallelCount).Select(async i => + { + try + { + // Introduce some jitter to increase chance of race conditions + if (i % 2 == 0) await Task.Delay(1); + + using var lockHandle = await keyedLock.LockAsync(key); + + // Simulate some work while holding the lock + await Task.Delay(5); + } + catch (Exception ex) + { + lock (lockObject) + { + exceptions.Add(ex); + } + } + }); + + await Task.WhenAll(tasks); + + // Assert - no ObjectDisposedException should have been thrown + Assert.Empty(exceptions); + } + + [Fact] + public async Task LockAsync_RapidAcquireRelease_SemaphoreCleanedUpCorrectly() + { + // This test verifies that semaphores are properly cleaned up + // after all locks are released. + + // Arrange + var keyedLock = new KeyedAsyncLock(); + const string key = "cleanup-key"; + const int iterations = 100; + + // Act - rapidly acquire and release the same key + for (int i = 0; i < iterations; i++) + { + using var lockHandle = await keyedLock.LockAsync(key); + // Minimal work + } + + // Assert - should complete without errors + // The internal dictionary should have cleaned up the semaphore + // (we can't directly verify this without reflection, but no exceptions = success) + + // Final lock should work + using var finalLock = await keyedLock.LockAsync(key); + Assert.NotNull(finalLock); + } + + [Fact] + public async Task LockAsync_ConcurrentAcquireRelease_MaintainsCorrectRefCount() + { + // This test simulates the exact race condition that was fixed: + // - Thread A acquires lock + // - Thread B tries to acquire same key, blocks + // - Thread A releases lock + // - Without proper ref counting, the semaphore could be disposed + // before Thread B finishes acquiring it + + // Arrange + var keyedLock = new KeyedAsyncLock(); + const string key = "refcount-key"; + const int parallelWaiters = 20; + var barrier = new Barrier(parallelWaiters + 1); + var completionCount = 0; + + // Act + var tasks = new List(); + + // Start many tasks that will all try to acquire the same key + for (int i = 0; i < parallelWaiters; i++) + { + tasks.Add(Task.Run(async () => + { + barrier.SignalAndWait(); // Sync all threads to start together + + using var lockHandle = await keyedLock.LockAsync(key); + Interlocked.Increment(ref completionCount); + await Task.Delay(1); // Small delay while holding lock + })); + } + + // Signal all threads to start + barrier.SignalAndWait(); + + // Wait for all to complete + await Task.WhenAll(tasks); + + // Assert - all tasks should have completed successfully + Assert.Equal(parallelWaiters, completionCount); + } + + [Fact] + public async Task LockAsync_WithCancellation_ReleasesRefCorrectly() + { + // Arrange + var keyedLock = new KeyedAsyncLock(); + const string key = "cancellation-key"; + var firstLockAcquired = new TaskCompletionSource(); + var cts = new CancellationTokenSource(); + + // Act + var task1 = Task.Run(async () => + { + using var lockHandle = await keyedLock.LockAsync(key); + firstLockAcquired.SetResult(true); + await Task.Delay(5000); // Hold lock for a while + }); + + await firstLockAcquired.Task; + + // Try to acquire with a token that will be cancelled + var task2 = keyedLock.LockAsync(key, cts.Token); + + await Task.Delay(50); // Let task2 start waiting + cts.Cancel(); + + // Assert - should throw OperationCanceledException + await Assert.ThrowsAsync(async () => await task2); + + // Verify the lock still works after cancellation + task1.Wait(1000); // Force task1 to complete via timeout mechanism + } + + [Fact] + public async Task LockAsync_MultipleKeysParallel_NoDeadlock() + { + // Arrange + var keyedLock = new KeyedAsyncLock(); + var keys = Enumerable.Range(0, 10).Select(i => $"key-{i}").ToArray(); + const int operationsPerKey = 20; + var random = new Random(42); // Deterministic seed + + // Act - perform many operations on multiple keys concurrently + var tasks = keys.SelectMany(key => + Enumerable.Range(0, operationsPerKey).Select(async _ => + { + await Task.Delay(random.Next(5)); // Random delay + using var lockHandle = await keyedLock.LockAsync(key); + await Task.Delay(random.Next(3)); // Random work + })); + + var allTasks = Task.WhenAll(tasks); + var completedInTime = await Task.WhenAny(allTasks, Task.Delay(10000)); + + // Assert - should complete without deadlock + Assert.Equal(allTasks, completedInTime); + } + + [Fact] + public async Task LockAsync_DisposeTwice_IsIdempotent() + { + // Arrange + var keyedLock = new KeyedAsyncLock(); + const string key = "dispose-twice-key"; + + // Act + var lockHandle = await keyedLock.LockAsync(key); + lockHandle.Dispose(); + + // Dispose again - should not throw + var exception = Record.Exception(() => lockHandle.Dispose()); + + // Assert + Assert.Null(exception); + + // Lock should still work for new acquisitions + using var newLock = await keyedLock.LockAsync(key); + Assert.NotNull(newLock); + } +} diff --git a/KaizokuBackend/Controllers/DownloadsController.cs b/KaizokuBackend/Controllers/DownloadsController.cs index 60d39bb..f9a996a 100644 --- a/KaizokuBackend/Controllers/DownloadsController.cs +++ b/KaizokuBackend/Controllers/DownloadsController.cs @@ -1,6 +1,7 @@ using KaizokuBackend.Models; using KaizokuBackend.Models.Database; using KaizokuBackend.Services.Downloads; +using KaizokuBackend.Services.Jobs; using Microsoft.AspNetCore.Mvc; namespace KaizokuBackend.Controllers @@ -12,12 +13,14 @@ public class DownloadsController : ControllerBase { private readonly DownloadQueryService _downloadQuery; private readonly DownloadCommandService _downloadCommand; + private readonly JobManagementService _jobManagement; private readonly ILogger _logger; - public DownloadsController(ILogger logger, DownloadQueryService downloadQuery, DownloadCommandService downloadCommand) + public DownloadsController(ILogger logger, DownloadQueryService downloadQuery, DownloadCommandService downloadCommand, JobManagementService jobManagement) { _downloadQuery = downloadQuery; _downloadCommand = downloadCommand; + _jobManagement = jobManagement; _logger = logger; } @@ -86,5 +89,48 @@ public async Task ManageErrorDownloadAsync([FromQuery]Guid id, [Fr return StatusCode(500, new { error = "An error occurred while managing the download." }); } } + + /// + /// Clears all queued downloads from the queue + /// + [HttpDelete("clear")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> ClearAllDownloadsAsync(CancellationToken token = default) + { + try + { + int count = await _jobManagement.ClearAllDownloadsAsync(token).ConfigureAwait(false); + return Ok(new { cleared = count, message = $"Cleared {count} downloads from queue" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error clearing downloads: {Message}", ex.Message); + return StatusCode(500, new { error = "An error occurred while clearing downloads" }); + } + } + + /// + /// Removes a scheduled download from the queue + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task RemoveScheduledDownloadAsync(Guid id, CancellationToken token = default) + { + try + { + bool removed = await _downloadCommand.RemoveScheduledDownloadAsync(id, token).ConfigureAwait(false); + if (!removed) + return NotFound(new { error = "Download not found or not in waiting status" }); + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing download {Id}: {Message}", id, ex.Message); + return StatusCode(500, new { error = "An error occurred while removing the download" }); + } + } } } diff --git a/KaizokuBackend/Controllers/ProviderController.cs b/KaizokuBackend/Controllers/ProviderController.cs index d9a5ff4..40a8cd5 100644 --- a/KaizokuBackend/Controllers/ProviderController.cs +++ b/KaizokuBackend/Controllers/ProviderController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using KaizokuBackend.Models; using KaizokuBackend.Services.Providers; +using KaizokuBackend.Utils; namespace KaizokuBackend.Controllers { @@ -68,6 +69,10 @@ public async Task>> GetProvidersAsync(Cance [ProducesResponseType(typeof(object), 500)] public async Task InstallProvider([FromRoute] string pkgName, CancellationToken token = default) { + if (!PathValidationHelper.IsValidPackageName(pkgName)) + { + return BadRequest(new { error = "Invalid package name" }); + } try { var success = await _installationService.InstallProviderAsync(pkgName, token).ConfigureAwait(false); @@ -101,6 +106,10 @@ public async Task InstallProvider([FromRoute] string pkgName, Can [ProducesResponseType(typeof(object), 500)] public async Task> GetPreferencesAsync([FromRoute] string pkgName, CancellationToken token = default) { + if (!PathValidationHelper.IsValidPackageName(pkgName)) + { + return BadRequest(new { error = "Invalid package name" }); + } try { var prefs = await _preferencesService.GetProviderPreferencesAsync(pkgName, token).ConfigureAwait(false); @@ -157,6 +166,10 @@ public async Task SetPreferencesAsync([FromBody] ProviderPreferen [ProducesResponseType(typeof(object), 500)] public async Task UninstallProviderAsync([FromRoute] string pkgName, CancellationToken token = default) { + if (!PathValidationHelper.IsValidPackageName(pkgName)) + { + return BadRequest(new { error = "Invalid package name" }); + } try { var success = await _installationService.UninstallProviderAsync(pkgName, token).ConfigureAwait(false); @@ -183,9 +196,14 @@ public async Task UninstallProviderAsync([FromRoute] string pkgNa /// If an error occurs while retrieving the icon [HttpGet("icon/{apkName}")] [ProducesResponseType(typeof(FileResult), 200)] + [ProducesResponseType(typeof(object), 400)] [ProducesResponseType(typeof(object), 500)] public async Task GetExtensionIcon([FromRoute] string apkName, CancellationToken token = default) { + if (!PathValidationHelper.IsValidPackageName(apkName)) + { + return BadRequest(new { error = "Invalid package name" }); + } try { return await _resourceService.GetProviderIconAsync(apkName, token).ConfigureAwait(false); @@ -216,6 +234,13 @@ public async Task> InstallProviderFromFileAsync([FromForm] return BadRequest(new { error = "No file uploaded" }); } + // Validate filename to prevent path traversal + var fileName = Path.GetFileName(file.FileName); + if (string.IsNullOrWhiteSpace(fileName) || !PathValidationHelper.IsValidPackageName(fileName)) + { + return BadRequest(new { error = "Invalid filename" }); + } + try { using var ms = new MemoryStream(); diff --git a/KaizokuBackend/Controllers/SeriesController.cs b/KaizokuBackend/Controllers/SeriesController.cs index 18a9d2c..c41cb90 100644 --- a/KaizokuBackend/Controllers/SeriesController.cs +++ b/KaizokuBackend/Controllers/SeriesController.cs @@ -3,6 +3,7 @@ using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Providers; using KaizokuBackend.Services.Series; +using KaizokuBackend.Utils; using Microsoft.AspNetCore.Mvc; namespace KaizokuBackend.Controllers @@ -158,9 +159,14 @@ public async Task>> GetLatestAsync([FromQuer /// The icon as an image result. [HttpGet("source/icon/{apk}")] [ProducesResponseType(typeof(FileResult), 200)] + [ProducesResponseType(400)] [ProducesResponseType(500)] public async Task GetSourceIconAsync([FromRoute] string apk, CancellationToken token = default) { + if (!PathValidationHelper.IsValidPackageName(apk)) + { + return BadRequest(new { error = "Invalid package name" }); + } try { return await _providerQueryService.GetExtensionIconAsync(apk, token).ConfigureAwait(false); @@ -184,9 +190,15 @@ public async Task GetSourceIconAsync([FromRoute] string apk, Canc /// The thumbnail as an image result. [HttpGet("thumb/{id}")] [ProducesResponseType(typeof(FileResult), 200)] + [ProducesResponseType(400)] [ProducesResponseType(500)] public async Task GetSeriesThumbAsync([FromRoute] string id, CancellationToken token = default) { + // Thumbnail IDs are in format "{suwayomiId}!{timestamp}" or "unknown", not GUIDs + if (string.IsNullOrEmpty(id)) + { + return BadRequest(new { error = "Invalid thumbnail id" }); + } try { return await _queryService.GetSeriesThumbnailAsync(id, token).ConfigureAwait(false); diff --git a/KaizokuBackend/Controllers/SettingsController.cs b/KaizokuBackend/Controllers/SettingsController.cs index ed5c53e..a2b71fc 100644 --- a/KaizokuBackend/Controllers/SettingsController.cs +++ b/KaizokuBackend/Controllers/SettingsController.cs @@ -1,7 +1,7 @@ - using Microsoft.AspNetCore.Mvc; using KaizokuBackend.Models; using System.ComponentModel.DataAnnotations; +using KaizokuBackend.Services.Naming; using KaizokuBackend.Services.Settings; namespace KaizokuBackend.Controllers @@ -15,11 +15,13 @@ namespace KaizokuBackend.Controllers public class SettingsController : ControllerBase { private readonly SettingsService _settingsService; + private readonly ITemplateParser _templateParser; private readonly ILogger _logger; - public SettingsController(SettingsService settingsService, ILogger logger) + public SettingsController(SettingsService settingsService, ITemplateParser templateParser, ILogger logger) { _settingsService = settingsService; + _templateParser = templateParser; _logger = logger; } @@ -102,5 +104,66 @@ public async Task UpdateAsync([FromBody][Required] Settings setti return StatusCode(StatusCodes.Status500InternalServerError, new { error = "An error occurred while updating settings" }); } } + + /// + /// Validates a template string. + /// + /// The template string to validate. + /// The template type (0 = FileName, 1 = FolderPath). + /// Validation result with errors and warnings. + /// Returns the validation result + [HttpGet("validate-template")] + [ProducesResponseType(typeof(TemplateValidationResult), StatusCodes.Status200OK)] + public ActionResult ValidateTemplate( + [FromQuery][Required] string template, + [FromQuery] int type = 0) + { + var templateType = type == 1 ? TemplateType.FolderPath : TemplateType.FileName; + var result = _templateParser.ValidateTemplate(template, templateType); + return Ok(result); + } + + /// + /// Gets a preview of the template with sample data. + /// + /// The template string to preview. + /// The template type (0 = FileName, 1 = FolderPath). + /// Preview string showing how the template would render. + /// Returns the preview string + [HttpGet("preview-template")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + public ActionResult PreviewTemplate( + [FromQuery][Required] string template, + [FromQuery] int type = 0) + { + var templateType = type == 1 ? TemplateType.FolderPath : TemplateType.FileName; + var preview = _templateParser.GetPreview(template, templateType); + var validation = _templateParser.ValidateTemplate(template, templateType); + return Ok(new { preview, validation }); + } + + /// + /// Renames all existing downloaded files to match the current naming scheme. + /// + /// Cancellation token. + /// Status of the rename operation. + /// Rename operation started + /// If an error occurs during rename + [HttpPost("rename-files")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public async Task RenameFilesAsync(CancellationToken token = default) + { + try + { + var result = await _settingsService.RenameFilesToCurrentSchemeAsync(token).ConfigureAwait(false); + return Ok(new { message = "Rename operation started", totalFiles = result }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting file rename operation"); + return StatusCode(StatusCodes.Status500InternalServerError, new { error = "An error occurred while starting the rename operation" }); + } + } } } \ No newline at end of file diff --git a/KaizokuBackend/Controllers/SetupWizardController.cs b/KaizokuBackend/Controllers/SetupWizardController.cs index 9655d8c..e83b562 100644 --- a/KaizokuBackend/Controllers/SetupWizardController.cs +++ b/KaizokuBackend/Controllers/SetupWizardController.cs @@ -6,6 +6,7 @@ using KaizokuBackend.Services.Import; using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Settings; +using KaizokuBackend.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.IO; @@ -111,6 +112,10 @@ public async Task> AugmentAsync([FromQuery] string path { try { + if (!PathValidationHelper.IsValidPath(path)) + { + return BadRequest(new { error = "Invalid path" }); + } if (linkedSeries == null || linkedSeries.Count == 0) { return BadRequest(new { error = "No series provided to augment" }); @@ -225,6 +230,37 @@ public async Task> GetImportsTotalsAsync(Cancellation } } + /// + /// Get the current status of wizard-related jobs + /// + /// Cancellation token. + /// Status of each wizard job type + [HttpGet("job-status")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + public async Task GetWizardJobStatusAsync(CancellationToken token = default) + { + var wizardJobTypes = new[] { JobType.ScanLocalFiles, JobType.InstallAdditionalExtensions, JobType.SearchProviders, JobType.ImportSeries }; + var latestJobs = await _db.Queues + .Where(j => wizardJobTypes.Contains(j.JobType)) + .GroupBy(j => j.JobType) + .Select(g => g.OrderByDescending(j => j.EnqueuedDate).First()) + .ToListAsync(token).ConfigureAwait(false); + + var statusMap = wizardJobTypes.ToDictionary( + jt => jt switch + { + JobType.ScanLocalFiles => "scanLocalFiles", + JobType.InstallAdditionalExtensions => "installAdditionalExtensions", + JobType.SearchProviders => "searchProviders", + JobType.ImportSeries => "importSeries", + _ => jt.ToString() + }, + jt => (int?)latestJobs.FirstOrDefault(j => j.JobType == jt)?.Status + ); + + return Ok(statusMap); + } + /// /// Import series from the provided list /// diff --git a/KaizokuBackend/Dockerfile b/KaizokuBackend/Dockerfile index c386503..c2215c4 100644 --- a/KaizokuBackend/Dockerfile +++ b/KaizokuBackend/Dockerfile @@ -24,6 +24,7 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* COPY ./scripts/kcef_download.sh /root/kcef_download.sh +RUN chmod +x /root/kcef_download.sh # install CEF dependencies RUN if ([ "$TARGETPLATFORM" = "linux/amd64" ] || [ "$TARGETPLATFORM" = "linux/arm64" ]); then \ @@ -52,6 +53,7 @@ ENV HOME=/config/Suwayomi WORKDIR /app COPY ./bin/$TARGETPLATFORM/ . COPY ./scripts/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh EXPOSE 9833 EXPOSE 4567 ENTRYPOINT ["tini", "--"] diff --git a/KaizokuBackend/Extensions/FileSystemExtensions.cs b/KaizokuBackend/Extensions/FileSystemExtensions.cs index 8083426..a048b9b 100644 --- a/KaizokuBackend/Extensions/FileSystemExtensions.cs +++ b/KaizokuBackend/Extensions/FileSystemExtensions.cs @@ -126,11 +126,24 @@ public static string BuildStoragePath(this string title, string? type, Settings /// Rewritten path public static string RewriteToKaizokuPath(this SuwayomiSeries series) { - if (series.ThumbnailUrl == null) + // Handle null or empty thumbnail URL + if (string.IsNullOrEmpty(series.ThumbnailUrl)) return "serie/thumb/unknown"; - string thumb = series.ThumbnailUrl[(series.ThumbnailUrl.IndexOf("/manga/", StringComparison.InvariantCulture) + 6)..]; - thumb = thumb[..thumb.LastIndexOf('/')]; + // Find the /manga/ marker in the URL + int mangaIndex = series.ThumbnailUrl.IndexOf("/manga/", StringComparison.InvariantCulture); + if (mangaIndex < 0) + return "serie/thumb/unknown"; + + // Extract the path after /manga/ (keeping the leading slash) + string thumb = series.ThumbnailUrl[(mangaIndex + 6)..]; + + // Find the last slash to remove the filename portion + int lastSlash = thumb.LastIndexOf('/'); + if (lastSlash < 0) + return "serie/thumb/unknown"; + + thumb = thumb[..lastSlash]; return $"serie/thumb{thumb}!{series.ThumbnailUrlLastFetched}"; } diff --git a/KaizokuBackend/Extensions/ModelExtensions.cs b/KaizokuBackend/Extensions/ModelExtensions.cs index f4c0651..c40c9b3 100644 --- a/KaizokuBackend/Extensions/ModelExtensions.cs +++ b/KaizokuBackend/Extensions/ModelExtensions.cs @@ -44,7 +44,7 @@ public static LatestSeriesInfo ToSeriesInfo(this LatestSerie serie, ContextProvi Provider = serie.Provider, Status = serie.Status, Title = serie.Title, - ThumbnailUrl = $"{provider.BaseUrl}{serie.ThumbnailUrl}", + ThumbnailUrl = provider.BaseUrl + (string.IsNullOrEmpty(serie.ThumbnailUrl) ? "serie/thumb/unknown" : serie.ThumbnailUrl), Language = serie.Language, ChapterCount = serie.ChapterCount, FetchDate = serie.FetchDate, @@ -171,7 +171,7 @@ public static SeriesExtendedInfo ToSeriesExtendedInfo(this Series s, ContextProv Id = s.Id, Title = s.Title, Description = s.Description, - ThumbnailUrl = cp.BaseUrl + s.ThumbnailUrl, + ThumbnailUrl = cp.BaseUrl + (string.IsNullOrEmpty(s.ThumbnailUrl) ? "serie/thumb/unknown" : s.ThumbnailUrl), Artist = s.Artist, PausedDownloads = s.PauseDownloads, Author = s.Author, diff --git a/KaizokuBackend/Extensions/StringExtensions.cs b/KaizokuBackend/Extensions/StringExtensions.cs index 040be68..7e40f82 100644 --- a/KaizokuBackend/Extensions/StringExtensions.cs +++ b/KaizokuBackend/Extensions/StringExtensions.cs @@ -166,7 +166,7 @@ public static string ToPascalCase(this string input) /// Second title /// Similarity threshold (0.0 to 1.0) /// True if titles are similar, false otherwise - public static bool AreStringSimilar(this string title1, string title2, double threshold = 0.1) + public static bool AreStringSimilar(this string title1, string title2, double threshold = 0.3) { if (string.IsNullOrWhiteSpace(title1) || string.IsNullOrWhiteSpace(title2)) { diff --git a/KaizokuBackend/Models/ArchiveFormat.cs b/KaizokuBackend/Models/ArchiveFormat.cs new file mode 100644 index 0000000..b18c41f --- /dev/null +++ b/KaizokuBackend/Models/ArchiveFormat.cs @@ -0,0 +1,17 @@ +namespace KaizokuBackend.Models; + +/// +/// Supported output archive formats for chapter downloads +/// +public enum ArchiveFormat +{ + /// + /// Comic Book Zip format (default) + /// + Cbz = 0, + + /// + /// Portable Document Format + /// + Pdf = 1 +} diff --git a/KaizokuBackend/Models/EditableSettings.cs b/KaizokuBackend/Models/EditableSettings.cs index d3d770b..7289195 100644 --- a/KaizokuBackend/Models/EditableSettings.cs +++ b/KaizokuBackend/Models/EditableSettings.cs @@ -49,16 +49,16 @@ public class EditableSettings [JsonPropertyName("numberOfSimultaneousDownloadsPerProvider")] public int NumberOfSimultaneousDownloadsPerProvider { get; set; } = 3; - [JsonPropertyName("nsfwVisibility")] - [JsonConverter(typeof(JsonStringEnumConverter))] - public NsfwVisibility NsfwVisibility { get; set; } = NsfwVisibility.HideByDefault; + [JsonPropertyName("fileNameTemplate")] + public string FileNameTemplate { get; set; } = "[{Provider}][{Language}] {Series} {Chapter}"; -} + [JsonPropertyName("folderTemplate")] + public string FolderTemplate { get; set; } = "{Type}/{Series}"; + + [JsonPropertyName("outputFormat")] + public int OutputFormat { get; set; } = 0; + + [JsonPropertyName("includeChapterTitle")] + public bool IncludeChapterTitle { get; set; } = true; -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum NsfwVisibility -{ - AlwaysHide = 0, - HideByDefault = 1, - Show = 2, } \ No newline at end of file diff --git a/KaizokuBackend/Services/Archives/ArchiveWriterFactory.cs b/KaizokuBackend/Services/Archives/ArchiveWriterFactory.cs new file mode 100644 index 0000000..81cf946 --- /dev/null +++ b/KaizokuBackend/Services/Archives/ArchiveWriterFactory.cs @@ -0,0 +1,40 @@ +using KaizokuBackend.Models; + +namespace KaizokuBackend.Services.Archives; + +/// +/// Factory for creating archive writers based on format +/// +public class ArchiveWriterFactory +{ + /// + /// Creates an archive writer for the specified format + /// + /// Archive format to create + /// Output stream to write to + /// Archive writer instance + public IArchiveWriter Create(ArchiveFormat format, Stream outputStream) + { + return format switch + { + ArchiveFormat.Cbz => new CbzArchiveWriter(outputStream), + ArchiveFormat.Pdf => new PdfArchiveWriter(outputStream), + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported archive format") + }; + } + + /// + /// Gets the file extension for the specified format + /// + /// Archive format + /// File extension including the dot + public static string GetExtension(ArchiveFormat format) + { + return format switch + { + ArchiveFormat.Cbz => ".cbz", + ArchiveFormat.Pdf => ".pdf", + _ => ".cbz" + }; + } +} diff --git a/KaizokuBackend/Services/Archives/CbzArchiveWriter.cs b/KaizokuBackend/Services/Archives/CbzArchiveWriter.cs new file mode 100644 index 0000000..0cf67b3 --- /dev/null +++ b/KaizokuBackend/Services/Archives/CbzArchiveWriter.cs @@ -0,0 +1,72 @@ +using SharpCompress.Common; +using SharpCompress.Writers; +using SharpCompress.Writers.Zip; + +namespace KaizokuBackend.Services.Archives; + +/// +/// Archive writer for CBZ (Comic Book Zip) format +/// +public class CbzArchiveWriter : IArchiveWriter +{ + private readonly Stream _outputStream; + private readonly IWriter _zipWriter; + private bool _finalized; + private bool _disposed; + + public CbzArchiveWriter(Stream outputStream) + { + _outputStream = outputStream ?? throw new ArgumentNullException(nameof(outputStream)); + _zipWriter = WriterFactory.Open(outputStream, ArchiveType.Zip, CompressionType.None); + } + + /// + public string FileExtension => ".cbz"; + + /// + public Task WriteEntryAsync(string entryName, Stream content, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (_disposed) throw new ObjectDisposedException(nameof(CbzArchiveWriter)); + if (_finalized) throw new InvalidOperationException("Archive has been finalized"); + if (content == null) throw new ArgumentNullException(nameof(content)); + + // Determine compression based on entry type + var compressionType = entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) + ? CompressionType.Deflate + : CompressionType.None; + + if (_zipWriter is ZipWriter zipWriter) + { + zipWriter.Write(entryName, content, new ZipWriterEntryOptions + { + CompressionType = compressionType, + ModificationDateTime = DateTime.Now + }); + } + else + { + _zipWriter.Write(entryName, content); + } + + return Task.CompletedTask; + } + + /// + public Task FinalizeAsync(CancellationToken token = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(CbzArchiveWriter)); + _finalized = true; + return Task.CompletedTask; + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + _zipWriter.Dispose(); + await _outputStream.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/KaizokuBackend/Services/Archives/IArchiveWriter.cs b/KaizokuBackend/Services/Archives/IArchiveWriter.cs new file mode 100644 index 0000000..3b05677 --- /dev/null +++ b/KaizokuBackend/Services/Archives/IArchiveWriter.cs @@ -0,0 +1,26 @@ +namespace KaizokuBackend.Services.Archives; + +/// +/// Interface for writing archive files (CBZ, PDF, etc.) +/// +public interface IArchiveWriter : IAsyncDisposable +{ + /// + /// Writes an entry to the archive + /// + /// Name of the entry in the archive + /// Content stream to write + /// Cancellation token + Task WriteEntryAsync(string entryName, Stream content, CancellationToken token = default); + + /// + /// Finalizes the archive (must be called before disposing) + /// + /// Cancellation token + Task FinalizeAsync(CancellationToken token = default); + + /// + /// File extension for this archive type (including the dot) + /// + string FileExtension { get; } +} diff --git a/KaizokuBackend/Services/Archives/PdfArchiveWriter.cs b/KaizokuBackend/Services/Archives/PdfArchiveWriter.cs new file mode 100644 index 0000000..0953b5c --- /dev/null +++ b/KaizokuBackend/Services/Archives/PdfArchiveWriter.cs @@ -0,0 +1,104 @@ +using SkiaSharp; + +namespace KaizokuBackend.Services.Archives; + +/// +/// Archive writer for PDF format using SkiaSharp +/// +public class PdfArchiveWriter : IArchiveWriter +{ + private readonly Stream _outputStream; + private readonly List<(string Name, byte[] Data)> _images = new(); + private bool _finalized; + private bool _disposed; + + // Known image extensions + private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".tiff", ".jxl", ".jp2", ".heic", ".heif" + }; + + public PdfArchiveWriter(Stream outputStream) + { + _outputStream = outputStream ?? throw new ArgumentNullException(nameof(outputStream)); + } + + /// + public string FileExtension => ".pdf"; + + /// + public async Task WriteEntryAsync(string entryName, Stream content, CancellationToken token = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(PdfArchiveWriter)); + if (_finalized) throw new InvalidOperationException("Archive has been finalized"); + if (content == null) throw new ArgumentNullException(nameof(content)); + + // Skip non-image entries (like ComicInfo.xml) + string extension = Path.GetExtension(entryName); + if (!ImageExtensions.Contains(extension)) + { + return; + } + + // Read image data into memory + using var ms = new MemoryStream(); + await content.CopyToAsync(ms, token).ConfigureAwait(false); + _images.Add((entryName, ms.ToArray())); + } + + /// + public Task FinalizeAsync(CancellationToken token = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(PdfArchiveWriter)); + if (_finalized) return Task.CompletedTask; + _finalized = true; + + // Sort images by name to maintain page order + var sortedImages = _images.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToList(); + + if (sortedImages.Count == 0) + { + throw new InvalidOperationException("Cannot create PDF with no images"); + } + + // Create PDF document + using var document = SKDocument.CreatePdf(_outputStream); + + foreach (var (name, data) in sortedImages) + { + token.ThrowIfCancellationRequested(); + + using var imageData = SKData.CreateCopy(data); + using var bitmap = SKBitmap.Decode(imageData); + + if (bitmap == null) + { + // Skip images that can't be decoded + continue; + } + + // Create a page with the image's dimensions + var pageSize = new SKSize(bitmap.Width, bitmap.Height); + using var canvas = document.BeginPage(pageSize.Width, pageSize.Height); + + // Draw the image + canvas.DrawBitmap(bitmap, 0, 0); + + document.EndPage(); + } + + document.Close(); + + return Task.CompletedTask; + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + _images.Clear(); + await _outputStream.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/KaizokuBackend/Services/Background/JobQueueHostedService.cs b/KaizokuBackend/Services/Background/JobQueueHostedService.cs index 810d7a6..628c0cb 100644 --- a/KaizokuBackend/Services/Background/JobQueueHostedService.cs +++ b/KaizokuBackend/Services/Background/JobQueueHostedService.cs @@ -17,7 +17,8 @@ public class JobQueueHostedService : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly JobsSettings _settings; - private readonly ConcurrentDictionary> _runningJobs = new(); + private readonly ConcurrentDictionary> _runningJobs = new(); + private readonly object _slotLock = new object(); public JobQueueHostedService(IServiceScopeFactory scopeFactory, ILogger logger, JobsSettings settings) @@ -29,7 +30,7 @@ public JobQueueHostedService(IServiceScopeFactory scopeFactory, ILogger(); + _runningJobs[queue.Name] = new ConcurrentDictionary(); } } @@ -74,12 +75,13 @@ private async Task ProcessJobQueuesAsync(CancellationToken stoppingToken) } } - private async Task ProcessQueueAsync(JobManagementService jobManagement, QueueSettings queueSettings, + private async Task ProcessQueueAsync(JobManagementService jobManagement, QueueSettings queueSettings, CancellationToken stoppingToken) { var queueName = queueSettings.Name; - var runningJobsInQueue = _runningJobs.GetValueOrDefault(queueName, new HashSet()); - + var runningJobsInQueue = _runningJobs.GetOrAdd(queueName, _ => new ConcurrentDictionary()); + + // Check available slots outside lock first (quick exit optimization) if (runningJobsInQueue.Count >= queueSettings.MaxThreads) return; @@ -88,7 +90,7 @@ private async Task ProcessQueueAsync(JobManagementService jobManagement, QueueSe return; // Get jobs ready for execution - var jobsToProcess = await GetJobsToProcessAsync(jobManagement, queueName, queueSettings, + var jobsToProcess = await GetJobsToProcessAsync(jobManagement, queueName, queueSettings, availableSlots, stoppingToken).ConfigureAwait(false); foreach (var job in jobsToProcess) @@ -96,16 +98,24 @@ private async Task ProcessQueueAsync(JobManagementService jobManagement, QueueSe if (stoppingToken.IsCancellationRequested) break; - runningJobsInQueue.Add(job.Id.ToString()); - + // Atomic slot allocation: check and reserve under lock + lock (_slotLock) + { + if (runningJobsInQueue.Count >= queueSettings.MaxThreads) + break; + + if (!runningJobsInQueue.TryAdd(job.Id.ToString(), 0)) + continue; // Job already running, skip + } + // Update job status to running job.Status = QueueStatus.Running; job.StartedDate = DateTime.UtcNow; - + // Save changes through the service await UpdateJobStatusAsync(job, stoppingToken).ConfigureAwait(false); jobManagement.DetachJob(job); - + // Start job execution in background _ = ExecuteJobAsync(job, queueName, queueSettings, stoppingToken); } @@ -181,7 +191,7 @@ private async Task ExecuteJobAsync(Enqueue job, JobQueues queueName, QueueSettin // Remove job from running list if (_runningJobs.TryGetValue(queueName, out var runningJobs)) { - runningJobs.Remove(jobId); + runningJobs.TryRemove(jobId, out _); } } } diff --git a/KaizokuBackend/Services/Background/JobScheduledHostedService.cs b/KaizokuBackend/Services/Background/JobScheduledHostedService.cs index eff6f16..2925a13 100644 --- a/KaizokuBackend/Services/Background/JobScheduledHostedService.cs +++ b/KaizokuBackend/Services/Background/JobScheduledHostedService.cs @@ -85,9 +85,39 @@ await jobManagementService.EnqueueJobAsIsAsync( // Update job for next execution job.PreviousExecution = job.NextExecution; - while(job.NextExecution < DateTime.UtcNow) - job.NextExecution = job.NextExecution.Add(job.TimeBetweenJobs); - + + // Guard against infinite loop if TimeBetweenJobs is zero or negative + if (job.TimeBetweenJobs <= TimeSpan.Zero) + { + _logger.LogWarning("Job {Key} has invalid TimeBetweenJobs ({TimeBetweenJobs}), skipping schedule update", + job.Key, job.TimeBetweenJobs); + continue; + } + + var iterationCount = 0; + const int maxIterations = 1000; + var initialNextExecution = job.NextExecution; + + while (job.NextExecution < DateTime.UtcNow) + { + job.NextExecution = job.NextExecution.Add(job.TimeBetweenJobs); + iterationCount++; + + if (iterationCount >= maxIterations) + { + _logger.LogWarning("Job {Key} exceeded {MaxIterations} catch-up iterations, setting next execution to now", + job.Key, maxIterations); + job.NextExecution = DateTime.UtcNow.Add(job.TimeBetweenJobs); + break; + } + } + + if (iterationCount > 1) + { + _logger.LogInformation("Job {Key} caught up from {InitialTime} to {NewTime} ({Iterations} intervals behind)", + job.Key, initialNextExecution, job.NextExecution, iterationCount); + } + await dbContext.SaveChangesAsync(stoppingToken).ConfigureAwait(false); _logger.LogInformation("Next Queued Execution of job {Key} will be {NextExecution}", diff --git a/KaizokuBackend/Services/Downloads/DownloadCommandService.cs b/KaizokuBackend/Services/Downloads/DownloadCommandService.cs index 213d17d..f403b35 100644 --- a/KaizokuBackend/Services/Downloads/DownloadCommandService.cs +++ b/KaizokuBackend/Services/Downloads/DownloadCommandService.cs @@ -5,6 +5,8 @@ using KaizokuBackend.Services.Jobs.Models; using KaizokuBackend.Services.Jobs.Report; using KaizokuBackend.Services.Helpers; +using KaizokuBackend.Services.Archives; +using KaizokuBackend.Services.Naming; using KaizokuBackend.Utils; using Microsoft.EntityFrameworkCore; using SharpCompress.Common; @@ -27,9 +29,12 @@ public class DownloadCommandService private readonly SettingsService _settings; private readonly JobManagementService _jobManagementService; private readonly JobHubReportService _reportingService; + private readonly ITemplateParser _templateParser; + private readonly ArchiveWriterFactory _archiveWriterFactory; private readonly string _tempFolder; private readonly ILogger _logger; - private static readonly KeyedAsyncLock _lock = new KeyedAsyncLock(); + private static readonly KeyedAsyncLock _seriesLock = new KeyedAsyncLock(); + private static readonly SemaphoreSlim _directoryLock = new SemaphoreSlim(1, 1); public DownloadCommandService( SuwayomiClient suwayomi, @@ -37,6 +42,8 @@ public DownloadCommandService( SettingsService settings, JobManagementService jobManagementService, JobHubReportService reportingService, + ITemplateParser templateParser, + ArchiveWriterFactory archiveWriterFactory, IConfiguration config, ILogger logger) { @@ -45,6 +52,8 @@ public DownloadCommandService( _settings = settings; _jobManagementService = jobManagementService; _reportingService = reportingService; + _templateParser = templateParser; + _archiveWriterFactory = archiveWriterFactory; _logger = logger; _tempFolder = Path.Combine(config["runtimeDirectory"] ?? "", "Downloads"); } @@ -113,29 +122,55 @@ public async Task DownloadChapterAsync(ChapterDownload ch, JobInfo jo if (p != null) maxChap = p.Chapters.Max(c => c.Number); - string zipFile = ArchiveHelperService.MakeFileNameSafe(ch.ProviderName, ch.Scanlator, ch.SeriesTitle, ch.Language, ch.Chapter.ChapterNumber, rchap, maxChap) + ".cbz"; + // Determine output format from settings + ArchiveFormat outputFormat = (ArchiveFormat)appSettings.OutputFormat; + string fileExtension = ArchiveWriterFactory.GetExtension(outputFormat); + + // Build template variables for file naming + TemplateVariables templateVars = new TemplateVariables( + Series: ch.SeriesTitle, + Chapter: ch.Chapter.ChapterNumber, + Volume: null, // Volume info not available in ChapterDownload + Provider: ch.ProviderName, + Scanlator: ch.Scanlator, + Language: ch.Language, + Title: appSettings.IncludeChapterTitle ? rchap : null, + UploadDate: ch.ComicUploadDateUTC, + Type: ch.Type, + MaxChapter: maxChap + ); + + // Generate file name from template + string baseFileName = _templateParser.ParseFileName(appSettings.FileNameTemplate, templateVars, appSettings); + string archiveFile = baseFileName + fileExtension; + string message = $"Downloading ({providerName}) {ch.Title} {chapterName}..."; reporter.Report(ProgressStatus.Started, 0, message, dci); float step = 100 / (float)(ch.PageCount); float acum = 0; int page = 0; - string tempZipPath = Path.Combine(_tempFolder, zipFile); + string tempArchivePath = Path.Combine(_tempFolder, archiveFile); bool breaked = false; try { - lock (_lock) + await _directoryLock.WaitAsync(token).ConfigureAwait(false); + try { if (!Directory.Exists(_tempFolder)) Directory.CreateDirectory(_tempFolder); } + finally + { + _directoryLock.Release(); + } - if (File.Exists(tempZipPath)) - File.Delete(tempZipPath); + if (File.Exists(tempArchivePath)) + File.Delete(tempArchivePath); - using (var zipStream = File.OpenWrite(tempZipPath)) - using (var zipWriter = WriterFactory.Open(zipStream, ArchiveType.Zip, CompressionType.None)) + await using (var archiveStream = File.OpenWrite(tempArchivePath)) + await using (var archiveWriter = _archiveWriterFactory.Create(outputFormat, archiveStream)) { while (true) { @@ -168,9 +203,10 @@ public async Task DownloadChapterAsync(ChapterDownload ch, JobInfo jo break; } - string fileName = ArchiveHelperService.MakeFileNameSafe(ch.ProviderName, ch.Scanlator, ch.SeriesTitle, ch.Language, - ch.Chapter.ChapterNumber, ch.ChapterName, maxChap, page + 1, ch.PageCount) + ext; - zipWriter.Write(fileName, ms); + // Generate page file name with padding + int pageLength = ch.PageCount.ToString().Length; + string pageFileName = $"{(page + 1).ToString().PadLeft(pageLength, '0')}{ext}"; + await archiveWriter.WriteEntryAsync(pageFileName, ms, token).ConfigureAwait(false); page++; acum += step; message = $"Downloading ({providerName}) {ch.Title} {chapterName} {page}"; @@ -197,10 +233,12 @@ public async Task DownloadChapterAsync(ChapterDownload ch, JobInfo jo if (!breaked) { + // Write ComicInfo.xml (will be skipped for PDF format) using (Stream comicInfo = ArchiveHelperService.CreateComicInfo(ch, page).ToStream()) { - ((ZipWriter)zipWriter).Write("ComicInfo.xml", comicInfo, new ZipWriterEntryOptions { CompressionType = CompressionType.Deflate, ModificationDateTime = DateTime.Now }); + await archiveWriter.WriteEntryAsync("ComicInfo.xml", comicInfo, token).ConfigureAwait(false); } + await archiveWriter.FinalizeAsync(token).ConfigureAwait(false); } } @@ -208,11 +246,11 @@ public async Task DownloadChapterAsync(ChapterDownload ch, JobInfo jo { try { - File.Delete(tempZipPath); + File.Delete(tempArchivePath); } catch (Exception e) { - _logger.LogError(e, "Failed to delete temporary zip file {TempZipPath}", tempZipPath); + _logger.LogError(e, "Failed to delete temporary archive file {TempArchivePath}", tempArchivePath); } reporter.Report(ProgressStatus.Failed, (int)acum, message, dci); return await RescheduleDownloadAsync(ch, token).ConfigureAwait(false); @@ -222,19 +260,19 @@ public async Task DownloadChapterAsync(ChapterDownload ch, JobInfo jo if (!Directory.Exists(dirPath)) Directory.CreateDirectory(dirPath); - string finalPath = Path.Combine(dirPath, zipFile); + string finalPath = Path.Combine(dirPath, archiveFile); try { - await Task.Run(() => File.Move(tempZipPath, finalPath, true), token).ConfigureAwait(false); + await Task.Run(() => File.Move(tempArchivePath, finalPath, true), token).ConfigureAwait(false); } catch (Exception e) { - _logger.LogError(e, "Failed to move downloaded file from {TempZipPath} to {FinalPath}", tempZipPath, finalPath); + _logger.LogError(e, "Failed to move downloaded file from {TempArchivePath} to {FinalPath}", tempArchivePath, finalPath); reporter.Report(ProgressStatus.Failed, (int)acum, message, dci); return await RescheduleDownloadAsync(ch, token).ConfigureAwait(false); } - using (var n = await _lock.LockAsync(ch.SeriesId.ToString(), token).ConfigureAwait(false)) + using (var n = await _seriesLock.LockAsync(ch.SeriesId.ToString(), token).ConfigureAwait(false)) { SeriesProvider? provider = await _db.SeriesProviders.FirstOrDefaultAsync(a => a.Id == ch.SeriesProviderId, token).ConfigureAwait(false); if (provider == null) @@ -258,7 +296,7 @@ public async Task DownloadChapterAsync(ChapterDownload ch, JobInfo jo cha.Number = ch.Chapter.ChapterNumber; cha.DownloadDate = DateTime.UtcNow; cha.ProviderUploadDate = ch.ComicUploadDateUTC; - cha.Filename = zipFile; + cha.Filename = archiveFile; cha.ShouldDownload = false; provider.ContinueAfterChapter = provider.Chapters.MaxNull(c => c.Number); provider.ChapterCount = provider.Chapters.Count; @@ -304,11 +342,11 @@ public async Task DownloadChapterAsync(ChapterDownload ch, JobInfo jo } catch (Exception e) { - if (File.Exists(tempZipPath)) + if (File.Exists(tempArchivePath)) { try { - File.Delete(tempZipPath); + File.Delete(tempArchivePath); } catch { @@ -320,6 +358,27 @@ public async Task DownloadChapterAsync(ChapterDownload ch, JobInfo jo } } + /// + /// Removes a scheduled (waiting) download from the queue + /// + /// Download ID + /// Cancellation token + /// True if the download was removed, false if not found or not in waiting status + public async Task RemoveScheduledDownloadAsync(Guid id, CancellationToken token = default) + { + Enqueue? download = await _db.Queues + .Where(a => a.Id == id && a.JobType == JobType.Download && a.Status == QueueStatus.Waiting) + .FirstOrDefaultAsync(token).ConfigureAwait(false); + + if (download == null) + return false; + + _db.Queues.Remove(download); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + _logger.LogInformation("Removed scheduled download {Id} from queue", id); + return true; + } + /// /// Manages error downloads by retrying or deleting them /// diff --git a/KaizokuBackend/Services/Helpers/ArchiveHelperService.cs b/KaizokuBackend/Services/Helpers/ArchiveHelperService.cs index f538c01..9d2c481 100644 --- a/KaizokuBackend/Services/Helpers/ArchiveHelperService.cs +++ b/KaizokuBackend/Services/Helpers/ArchiveHelperService.cs @@ -82,39 +82,11 @@ public async Task UpdateTitleAndAddComicInfoAsync(Models.Database.Series series, string archivePath = Path.Combine(settings.StorageFolder, series.StoragePath, chap.Filename); if (!File.Exists(archivePath)) continue; - // Now check if the filename should be changed - string prefix = $"[{sp.Provider}][{sp.Language}]"; - string safeName = MakeFileNameSafe(sp.Provider, sp.Scanlator, sp.Title, sp.Language, chap.Number, chap.Name, sp.ChapterCount); - string extension = Path.GetExtension(chap.Filename) ?? ".cbz"; - string newFileName = safeName + extension; - if (!chap.Filename.StartsWith(prefix)) - { - //Not ours or renamed by us - continue; - } - if (!onlyDownloadByKaizoku) - extension = ".cbz"; // Force .cbz, we don't write other formats. - if (!string.Equals(newFileName, chap.Filename, StringComparison.OrdinalIgnoreCase)) - { - string basePath = Path.Combine(settings.StorageFolder, series.StoragePath); - string oldFullPath = Path.Combine(basePath, chap.Filename); - string newFullPath = Path.Combine(basePath, newFileName); - try - { - //SharpCompress do not care if we call a rar a zip, so no issues to rename it first. - File.Move(oldFullPath, newFullPath); - chap.Filename = newFileName; - _db.Touch(sp, a => a.Chapters); - await _db.SaveChangesAsync(token).ConfigureAwait(false); - _logger.LogInformation("Renamed archive from {oldFullPath} to {newFullPath} and updated chapters filename.", oldFullPath, newFullPath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to rename archive from {oldFullPath} to {newFullPath}", oldFullPath, newFullPath); - } - archivePath = newFullPath; - } + // Note: Automatic file renaming has been removed to avoid conflicts with the template-based + // naming system. Use Settings > "Rename Files" to manually rename files to match the current template. + + string prefix = $"[{sp.Provider}][{sp.Language}]"; if (onlyDownloadByKaizoku) { diff --git a/KaizokuBackend/Services/Import/ImportCommandService.cs b/KaizokuBackend/Services/Import/ImportCommandService.cs index 5631875..eb484c6 100644 --- a/KaizokuBackend/Services/Import/ImportCommandService.cs +++ b/KaizokuBackend/Services/Import/ImportCommandService.cs @@ -21,6 +21,7 @@ namespace KaizokuBackend.Services.Import; public class ImportCommandService { + private static readonly SemaphoreSlim _importLock = new SemaphoreSlim(1, 1); private readonly ILogger _logger; private readonly AppDbContext _db; private readonly SuwayomiClient _sc; @@ -74,110 +75,130 @@ public ImportCommandService( public async Task ScanAsync(string directoryPath, JobInfo jobInfo, CancellationToken token = default) { ProgressReporter progress = _reportingService.CreateReporter(jobInfo); - if ((await _jobManagementService.IsJobTypeRunningAsync(JobType.SearchProviders, token).ConfigureAwait(false)) + if ((await _jobManagementService.IsJobTypeRunningAsync(JobType.SearchProviders, token).ConfigureAwait(false)) || (await _jobManagementService.IsJobTypeRunningAsync(JobType.InstallAdditionalExtensions, token).ConfigureAwait(false))) { + _logger.LogWarning("Scan skipped: SearchProviders or InstallAdditionalExtensions job is currently running."); + progress.Report(ProgressStatus.Completed, 100, "Scan skipped: another import job is currently running."); + return JobResult.Success; + } + + await _importLock.WaitAsync(token).ConfigureAwait(false); + try + { + List allseries = await _db.Series.Include(a => a.Sources).ToListAsync(token).ConfigureAwait(false); + List exts = await _sc.GetExtensionsAsync(token).ConfigureAwait(false); + if (!Directory.Exists(directoryPath)) + { + _logger.LogError("Directory not found: {directoryPath}", directoryPath); + return JobResult.Failed; + } + progress.Report(ProgressStatus.Started, 0, "Scanning Directories..."); + var seriesDict = new List(); + await _scanner.RecurseDirectoryAsync(allseries, exts, seriesDict, directoryPath, directoryPath, progress, token).ConfigureAwait(false); + HashSet folders = seriesDict.Select(a => a.Path).ToHashSet(); + await SaveImportsAsync(folders, seriesDict, token).ConfigureAwait(false); progress.Report(ProgressStatus.Completed, 100, "Scanning completed successfully."); return JobResult.Success; - } - - List allseries = await _db.Series.Include(a => a.Sources).ToListAsync(token).ConfigureAwait(false); - List exts = await _sc.GetExtensionsAsync(token).ConfigureAwait(false); - if (!Directory.Exists(directoryPath)) + } + finally { - _logger.LogError("Directory not found: {directoryPath}", directoryPath); - return JobResult.Failed; + _importLock.Release(); } - progress.Report(ProgressStatus.Started, 0, "Scanning Directories..."); - var seriesDict = new List(); - await _scanner.RecurseDirectoryAsync(allseries, exts, seriesDict, directoryPath, directoryPath, progress, token).ConfigureAwait(false); - HashSet folders = seriesDict.Select(a => a.Path).ToHashSet(); - await SaveImportsAsync(folders, seriesDict, token).ConfigureAwait(false); - progress.Report(ProgressStatus.Completed, 100, "Scanning completed successfully."); - return JobResult.Success; } private async Task SaveImportsAsync(HashSet existingFolders, List newSeries, CancellationToken token = default) { - var imports = await _db.Imports.ToListAsync(token).ConfigureAwait(false); - foreach (KaizokuBackend.Models.Database.Import a in imports) - { - if (!existingFolders.Contains(a.Path, StringComparer.InvariantCultureIgnoreCase) && a.Status != ImportStatus.DoNotChange) - { - _db.Imports.Remove(a); - } - } - Dictionary paths = await _db.GetPathsAsync(token).ConfigureAwait(false); - foreach (KaizokuInfo k in newSeries) + using var transaction = await _db.Database.BeginTransactionAsync(token).ConfigureAwait(false); + try { - KaizokuBackend.Models.Database.Series? s = null; - if (!string.IsNullOrEmpty(k.Path) && paths.TryGetValue(k.Path, out Guid id)) + var imports = await _db.Imports.ToListAsync(token).ConfigureAwait(false); + foreach (KaizokuBackend.Models.Database.Import a in imports) { - s = await _db.Series.Include(a => a.Sources) - .Where(a => a.Id == id) - .FirstOrDefaultAsync(token).ConfigureAwait(false); + if (!existingFolders.Contains(a.Path, StringComparer.InvariantCultureIgnoreCase) && a.Status != ImportStatus.DoNotChange) + { + _db.Imports.Remove(a); + } } - bool update = false; - bool exists = false; - if (s != null) + Dictionary paths = await _db.GetPathsAsync(token).ConfigureAwait(false); + foreach (KaizokuInfo k in newSeries) { - exists = true; - Dictionary chapters = s.Sources.SelectMany(a => a.Chapters, (p, c) => new { Provider = p, Chapter = c }).Where(a=>!string.IsNullOrEmpty(a.Chapter.Filename)).ToDictionary(x => x.Chapter, x => x.Provider); - Dictionary archives = k.Providers.SelectMany(a => a.Archives, (p, c) => new { Provider = p, Chapter = c }).Where(a => !string.IsNullOrEmpty(a.Chapter.ArchiveName)).ToDictionary(a => a.Chapter, a => a.Provider); - foreach (ArchiveInfo archive in archives.Keys) + KaizokuBackend.Models.Database.Series? s = null; + if (!string.IsNullOrEmpty(k.Path) && paths.TryGetValue(k.Path, out Guid id)) { - Chapter? c = chapters.Keys.FirstOrDefault(a => a.Filename!.Equals(archive.ArchiveName!, StringComparison.InvariantCultureIgnoreCase)); - if (c != null) + s = await _db.Series.Include(a => a.Sources) + .Where(a => a.Id == id) + .FirstOrDefaultAsync(token).ConfigureAwait(false); + } + bool update = false; + bool exists = false; + if (s != null) + { + exists = true; + Dictionary chapters = s.Sources.SelectMany(a => a.Chapters, (p, c) => new { Provider = p, Chapter = c }).Where(a => !string.IsNullOrEmpty(a.Chapter.Filename)).ToDictionary(x => x.Chapter, x => x.Provider); + Dictionary archives = k.Providers.SelectMany(a => a.Archives, (p, c) => new { Provider = p, Chapter = c }).Where(a => !string.IsNullOrEmpty(a.Chapter.ArchiveName)).ToDictionary(a => a.Chapter, a => a.Provider); + foreach (ArchiveInfo archive in archives.Keys) { - chapters.Remove(c); + Chapter? c = chapters.Keys.FirstOrDefault(a => a.Filename!.Equals(archive.ArchiveName!, StringComparison.InvariantCultureIgnoreCase)); + if (c != null) + { + chapters.Remove(c); + } + else + { + update = true; + } } - else + if (chapters.Count > 0) { - update = true; + foreach (Chapter c in chapters.Keys) + { + _logger.LogWarning("Removing chapter '{Filename}' from provider '{Provider}' for series '{Title}' — file no longer found on disk.", + c.Filename, chapters[c].Provider, s.Title); + chapters[c].Chapters.Remove(c); + _db.Touch(chapters[c], c => c.Chapters); + } } } - if (chapters.Count > 0) + KaizokuBackend.Models.Database.Import? import = imports.FirstOrDefault(a => a.Path.Equals(k.Path, StringComparison.InvariantCultureIgnoreCase)); + if (import != null) { - foreach (Chapter c in chapters.Keys) + bool change = false; + if ((k.ArchiveCompare & ArchiveCompare.Equal) != ArchiveCompare.Equal) + (change, import.Info) = import.Info.Merge(k); + _db.Touch(import, a => a.Info); + if (update) + import.Status = ImportStatus.Import; + else if (!exists && import.Action != Action.Skip) + import.Status = ImportStatus.Import; + else if (import.Action == Action.Skip) { - chapters[c].Chapters.Remove(c); - _db.Touch(chapters[c],c=>c.Chapters); + import.Status = ImportStatus.Skip; } - await _db.SaveChangesAsync(token).ConfigureAwait(false); - } - } - KaizokuBackend.Models.Database.Import? import = imports.FirstOrDefault(a => a.Path.Equals(k.Path, StringComparison.InvariantCultureIgnoreCase)); - if (import != null) - { - bool change = false; - if ((k.ArchiveCompare & ArchiveCompare.Equal) != ArchiveCompare.Equal) - (change, import.Info) = import.Info.Merge(k); - _db.Touch(import, a => a.Info); - if (update) - import.Status = ImportStatus.Import; - else if (!exists && import.Action!=Action.Skip) - import.Status = ImportStatus.Import; - else if (import.Action == Action.Skip) - { - import.Status = ImportStatus.Skip; + else + import.Status = ImportStatus.DoNotChange; } else - import.Status = ImportStatus.DoNotChange; - } - else - { - KaizokuBackend.Models.Database.Import imp = new KaizokuBackend.Models.Database.Import { - Title = k.Title, - Path = k.Path, - Status = ImportStatus.Import, - Action = Action.Add, - Info = k - }; - _db.Imports.Add(imp); + KaizokuBackend.Models.Database.Import imp = new KaizokuBackend.Models.Database.Import + { + Title = k.Title, + Path = k.Path, + Status = ImportStatus.Import, + Action = Action.Add, + Info = k + }; + _db.Imports.Add(imp); + } } + await _db.SaveChangesAsync(token).ConfigureAwait(false); + await transaction.CommitAsync(token).ConfigureAwait(false); + } + catch + { + await transaction.RollbackAsync(token).ConfigureAwait(false); + throw; } - await _db.SaveChangesAsync(token).ConfigureAwait(false); } public async Task AddExtensionsAsync(JobInfo jobInfo, int startPercentage, int maxPercentage, CancellationToken token = default) @@ -187,7 +208,8 @@ public async Task AddExtensionsAsync(JobInfo jobInfo, int startPercen ProgressReporter progress = _reportingService.CreateReporter(jobInfo); if ((await _jobManagementService.IsJobTypeRunningAsync(JobType.SearchProviders, token).ConfigureAwait(false))) { - progress.Report(ProgressStatus.Completed, maxPercentage, "Extensions installed successfully."); + _logger.LogWarning("Extension installation skipped: SearchProviders job is currently running."); + progress.Report(ProgressStatus.Completed, maxPercentage, "Extension installation skipped: search job is running."); return JobResult.Success; } progress.Report(ProgressStatus.InProgress, startPercentage, null); @@ -237,6 +259,7 @@ public async Task SearchSeriesAsync(JobInfo jobInfo, CancellationToke { ProgressReporter progress = _reportingService.CreateReporter(jobInfo); progress.Report(ProgressStatus.Started, 0, "Starting series search..."); + await _importLock.WaitAsync(token).ConfigureAwait(false); try { List imports = await _db.Imports @@ -487,7 +510,7 @@ await _seriesProvider.RescheduleIfNeededAsync(s.Sources, false, s.PauseDownloads { foreach (string title in titles) { - if (l.Title.AreStringSimilar(title,0.1)) + if (l.Title.AreStringSimilar(title)) { linked.Add(l); break; @@ -530,6 +553,9 @@ await _seriesProvider.RescheduleIfNeededAsync(s.Sources, false, s.PauseDownloads catch (Exception ex) { _logger.LogError(ex, "Error searching for series: {Title}", import.Info.Title); + import.Status = ImportStatus.Skip; + import.Action = Action.Skip; + await _db.SaveChangesAsync(token).ConfigureAwait(false); } } progress.Report(ProgressStatus.Completed, 100, $"Search completed for {imports.Count} series"); @@ -541,20 +567,24 @@ await _seriesProvider.RescheduleIfNeededAsync(s.Sources, false, s.PauseDownloads progress.Report(ProgressStatus.Failed, 100, $"Series search failed: {ex.Message}"); return JobResult.Failed; } + finally + { + _importLock.Release(); + } } public async Task ImportSeriesAsync(JobInfo jobInfo, bool disableJob, CancellationToken token = default) { ProgressReporter progress = _reportingService.CreateReporter(jobInfo); progress.Report(ProgressStatus.Started, 0, "Starting series import..."); - List imports = await _db.Imports - .Where(a => a.Status != ImportStatus.DoNotChange) - .AsNoTracking() - .ToListAsync(token).ConfigureAwait(false); - float step = 100 / (float)imports.Count; - float acum = 0F; + await _importLock.WaitAsync(token).ConfigureAwait(false); try { + List imports = await _db.Imports + .Where(a => a.Status != ImportStatus.DoNotChange) + .ToListAsync(token).ConfigureAwait(false); + float step = 100 / (float)imports.Count; + float acum = 0F; foreach (KaizokuBackend.Models.Database.Import import in imports) { if (import.Series != null && import.Series.Count > 0 && import.Action == Action.Add) @@ -569,7 +599,7 @@ public async Task ImportSeriesAsync(JobInfo jobInfo, bool disableJob, augmented.Action = import.Action; augmented.Status = import.Status; Guid seriesid = await _seriesCommand.AddSeriesAsync(augmented, token).ConfigureAwait(false); - KaizokuBackend.Models.Database.Series? serie = await _db.Series.Include(a => a.Sources).Where(a => a.Id == seriesid).AsNoTracking().FirstOrDefaultAsync(token).ConfigureAwait(false); + KaizokuBackend.Models.Database.Series? serie = await _db.Series.Include(a => a.Sources).Where(a => a.Id == seriesid).FirstOrDefaultAsync(token).ConfigureAwait(false); if (serie != null) { KaizokuBackend.Models.Settings settings2 = await _settings.GetSettingsAsync(token).ConfigureAwait(false); @@ -593,5 +623,9 @@ public async Task ImportSeriesAsync(JobInfo jobInfo, bool disableJob, progress.Report(ProgressStatus.Failed, 100, "Error importing series"); return JobResult.Failed; } + finally + { + _importLock.Release(); + } } } diff --git a/KaizokuBackend/Services/Import/ImportExtensions.cs b/KaizokuBackend/Services/Import/ImportExtensions.cs index 051bf59..3ebbc78 100644 --- a/KaizokuBackend/Services/Import/ImportExtensions.cs +++ b/KaizokuBackend/Services/Import/ImportExtensions.cs @@ -11,7 +11,7 @@ namespace KaizokuBackend.Services.Import; public static class ImportExtensions { - public static List FindAndLinkSimilarSeries(this List series, ContextProvider cp, double threshold = 0.1) + public static List FindAndLinkSimilarSeries(this List series, ContextProvider cp, double threshold = 0.3) { // ...moved logic from ModelExtensions... if (series == null || series.Count == 0) @@ -70,7 +70,7 @@ public static List FindAndLinkSimilarSeries(this List linkedSeries, double threshold = 0.1) + public static void MergeSimilarSeries(this List linkedSeries, double threshold = 0.3) { if (linkedSeries.Count <= 1) { @@ -172,10 +172,13 @@ public static void FillMissingChapterNumbers(this IEnumerable cha int prevIdx = ordered[prev].Index; int nextIdx = ordered[next].Index; int gap = nextIdx - prevIdx; - decimal step = (nextNum - prevNum) / gap; - for (int j = prev + 1; j < next; j++) + if (gap > 0) { - ordered[j].ChapterNumber = prevNum + step * (ordered[j].Index - prevIdx); + decimal step = (nextNum - prevNum) / gap; + for (int j = prev + 1; j < next; j++) + { + ordered[j].ChapterNumber = prevNum + step * (ordered[j].Index - prevIdx); + } } i = next; } diff --git a/KaizokuBackend/Services/Jobs/JobExecutionService.cs b/KaizokuBackend/Services/Jobs/JobExecutionService.cs index 952e93a..1f261a8 100644 --- a/KaizokuBackend/Services/Jobs/JobExecutionService.cs +++ b/KaizokuBackend/Services/Jobs/JobExecutionService.cs @@ -11,15 +11,16 @@ public class JobExecutionService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; - private readonly List _commandTypes; + + private static readonly Lazy> _commandTypeMap = new(() => + Assembly.GetExecutingAssembly().GetTypes() + .Where(type => typeof(ICommand).IsAssignableFrom(type) && type.IsClass && !type.IsAbstract) + .ToDictionary(type => type.Name, type => type)); public JobExecutionService(IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; - _commandTypes = Assembly.GetExecutingAssembly().GetTypes() - .Where(type => typeof(ICommand).IsAssignableFrom(type) && type.IsClass && !type.IsAbstract) - .ToList(); } public async Task ExecuteJobAsync(JobInfo jobInfo, CancellationToken token = default) @@ -50,9 +51,15 @@ public async Task ExecuteJobAsync(JobInfo jobInfo, CancellationToken private ICommand? GetCommandInstance(IServiceProvider serviceProvider, JobType jobType) { - Type? commandType = _commandTypes.FirstOrDefault(t => t.Name == jobType.ToString()); - if (commandType == null) + string commandName = jobType.ToString(); + if (!_commandTypeMap.Value.TryGetValue(commandName, out Type? commandType)) + { + _logger.LogError( + "Command type '{CommandName}' not found. Available commands: {AvailableCommands}", + commandName, + string.Join(", ", _commandTypeMap.Value.Keys)); return null; + } return ActivatorUtilities.CreateInstance(serviceProvider, commandType) as ICommand; } diff --git a/KaizokuBackend/Services/Jobs/JobManagementService.cs b/KaizokuBackend/Services/Jobs/JobManagementService.cs index ce91f49..ac50c7c 100644 --- a/KaizokuBackend/Services/Jobs/JobManagementService.cs +++ b/KaizokuBackend/Services/Jobs/JobManagementService.cs @@ -371,6 +371,24 @@ public async Task DeleteQueuedJobsAsync(IEnumerable jobIds, CancellationTo } } + /// + /// Clears all queued and waiting download jobs from the queue + /// + public async Task ClearAllDownloadsAsync(CancellationToken token = default) + { + using (await _lock.LockAsync(token)) + { + var downloadJobs = _db.Queues.Where(j => + j.JobType == JobType.Download && + j.Status == QueueStatus.Waiting); + int count = await downloadJobs.CountAsync(token).ConfigureAwait(false); + _db.Queues.RemoveRange(downloadJobs); + await _db.SaveChangesAsync(token).ConfigureAwait(false); + _logger.LogInformation("Cleared {Count} download jobs from queue", count); + return count; + } + } + #endregion #region System Operations diff --git a/KaizokuBackend/Services/Naming/ITemplateParser.cs b/KaizokuBackend/Services/Naming/ITemplateParser.cs new file mode 100644 index 0000000..d6f0d1e --- /dev/null +++ b/KaizokuBackend/Services/Naming/ITemplateParser.cs @@ -0,0 +1,30 @@ +using KaizokuBackend.Models; +using SettingsModel = KaizokuBackend.Models.Settings; + +namespace KaizokuBackend.Services.Naming; + +/// +/// Interface for template parsing and validation +/// +public interface ITemplateParser +{ + /// + /// Parses a file name template with the given variables + /// + string ParseFileName(string template, TemplateVariables vars, SettingsModel settings); + + /// + /// Parses a folder path template with the given variables + /// + string ParseFolderPath(string template, TemplateVariables vars, SettingsModel settings); + + /// + /// Validates a template string + /// + TemplateValidationResult ValidateTemplate(string template, TemplateType type); + + /// + /// Gets a preview of the template with sample data + /// + string GetPreview(string template, TemplateType type); +} diff --git a/KaizokuBackend/Services/Naming/TemplateParser.cs b/KaizokuBackend/Services/Naming/TemplateParser.cs new file mode 100644 index 0000000..96c045c --- /dev/null +++ b/KaizokuBackend/Services/Naming/TemplateParser.cs @@ -0,0 +1,240 @@ +using System.Text.RegularExpressions; +using KaizokuBackend.Extensions; +using KaizokuBackend.Models; +using SettingsModel = KaizokuBackend.Models.Settings; + +namespace KaizokuBackend.Services.Naming; + +/// +/// Parses and validates file/folder naming templates +/// +public class TemplateParser : ITemplateParser +{ + // Regex to match {Variable} or {Variable:format} + private static readonly Regex VariablePattern = new(@"\{(\w+)(?::([^}]+))?\}", RegexOptions.Compiled); + + // Variables allowed in file name templates + private static readonly HashSet FileNameVariables = new(StringComparer.OrdinalIgnoreCase) + { + "Series", "Chapter", "Volume", "Provider", "Scanlator", + "Language", "Title", "Year", "Month", "Day" + }; + + // Variables allowed in folder path templates + private static readonly HashSet FolderPathVariables = new(StringComparer.OrdinalIgnoreCase) + { + "Series", "Type", "Provider", "Language", "Year" + }; + + // Sample data for previews + private static readonly TemplateVariables SampleVariables = new( + Series: "One Piece", + Chapter: 1089.5m, + Volume: 105, + Provider: "MangaDex", + Scanlator: "TCBScans", + Language: "en", + Title: "The Beginning", + UploadDate: new DateTime(2024, 6, 15), + Type: "Manga", + MaxChapter: 1200m + ); + + /// + public string ParseFileName(string template, TemplateVariables vars, SettingsModel settings) + { + string result = ExpandTemplate(template, vars, settings, isFileName: true); + result = result.ReplaceInvalidFilenameAndPathCharacters(); + result = Regex.Replace(result, @"\s+", " ").Trim(); + return result; + } + + /// + public string ParseFolderPath(string template, TemplateVariables vars, SettingsModel settings) + { + string result = ExpandTemplate(template, vars, settings, isFileName: false); + // Process each path segment separately + var segments = result.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); + var sanitizedSegments = segments.Select(s => s.ReplaceInvalidFilenameAndPathCharacters().Trim()); + return Path.Combine(sanitizedSegments.ToArray()); + } + + /// + public TemplateValidationResult ValidateTemplate(string template, TemplateType type) + { + var errors = new List(); + var warnings = new List(); + var usedVariables = new List(); + var allowedVariables = type == TemplateType.FileName ? FileNameVariables : FolderPathVariables; + + if (string.IsNullOrWhiteSpace(template)) + { + errors.Add("Template cannot be empty"); + return new TemplateValidationResult(false, errors, warnings, usedVariables); + } + + var matches = VariablePattern.Matches(template); + foreach (Match match in matches) + { + string varName = match.Groups[1].Value; + usedVariables.Add(varName); + + if (!allowedVariables.Contains(varName)) + { + errors.Add($"Unknown variable: {{{varName}}}. Allowed: {string.Join(", ", allowedVariables.Select(v => $"{{{v}}}"))}"); + } + } + + // Check for recommended variables + if (type == TemplateType.FileName) + { + if (!usedVariables.Contains("Series", StringComparer.OrdinalIgnoreCase)) + { + warnings.Add("Template is missing {Series} - filenames may be ambiguous"); + } + if (!usedVariables.Contains("Chapter", StringComparer.OrdinalIgnoreCase)) + { + warnings.Add("Template is missing {Chapter} - filenames may collide"); + } + } + + if (type == TemplateType.FolderPath) + { + if (!usedVariables.Contains("Series", StringComparer.OrdinalIgnoreCase)) + { + warnings.Add("Template is missing {Series} - folder structure may be unclear"); + } + } + + return new TemplateValidationResult(errors.Count == 0, errors, warnings, usedVariables); + } + + /// + public string GetPreview(string template, TemplateType type) + { + var sampleSettings = new SettingsModel + { + CategorizedFolders = true + }; + + return type == TemplateType.FileName + ? ParseFileName(template, SampleVariables, sampleSettings) + : ParseFolderPath(template, SampleVariables, sampleSettings); + } + + private string ExpandTemplate(string template, TemplateVariables vars, SettingsModel settings, bool isFileName) + { + return VariablePattern.Replace(template, match => + { + string varName = match.Groups[1].Value; + string? format = match.Groups[2].Success ? match.Groups[2].Value : null; + + return GetVariableValue(varName, format, vars, settings, isFileName); + }); + } + + private string GetVariableValue(string varName, string? format, TemplateVariables vars, SettingsModel settings, bool isFileName) + { + return varName.ToLowerInvariant() switch + { + "series" => SanitizeForTemplate(vars.Series), + "chapter" => FormatChapter(vars.Chapter, format, vars.MaxChapter, settings), + "volume" => FormatVolume(vars.Volume, format, settings), + "provider" => FormatProvider(vars.Provider, vars.Scanlator), + "scanlator" => SanitizeForTemplate(vars.Scanlator ?? ""), + "language" => vars.Language.ToLowerInvariant(), + "title" => FormatTitle(vars.Title), + "year" => vars.UploadDate?.Year.ToString() ?? "", + "month" => vars.UploadDate?.Month.ToString("D2") ?? "", + "day" => vars.UploadDate?.Day.ToString("D2") ?? "", + "type" => SanitizeForTemplate(vars.Type ?? "Manga"), + _ => $"{{{varName}}}" // Keep unknown variables as-is + }; + } + + private static string SanitizeForTemplate(string value) + { + // Remove characters that cause issues in file/folder names + return value.Replace("(", "").Replace(")", "").Trim(); + } + + private static string FormatProvider(string provider, string? scanlator) + { + string result = provider.Replace("-", "_"); + if (!string.IsNullOrEmpty(scanlator) && provider != scanlator) + { + result += "-" + scanlator; + } + return result.Replace("[", "(").Replace("]", ")"); + } + + private static string FormatChapter(decimal? chapter, string? format, decimal? maxChapter, SettingsModel settings) + { + if (!chapter.HasValue) + return ""; + + // Determine padding length from format string (e.g., "000" = 3 digits) + // If no format specified, no padding is applied + int paddingLength = 0; + if (!string.IsNullOrEmpty(format) && format.All(c => c == '0')) + { + paddingLength = format.Length; + } + + // Format chapter with proper decimal handling + if (chapter.Value % 1 != 0) + { + // Decimal chapter (e.g., 5.5) - pad integer part only + int intPart = (int)chapter.Value; + decimal decPart = chapter.Value - intPart; + string intStr = paddingLength > 0 ? intPart.ToString().PadLeft(paddingLength, '0') : intPart.ToString(); + return intStr + decPart.ToString(System.Globalization.CultureInfo.InvariantCulture).Substring(1); + } + else + { + // Whole number chapter + string intStr = ((int)chapter.Value).ToString(); + return paddingLength > 0 ? intStr.PadLeft(paddingLength, '0') : intStr; + } + } + + private static string FormatVolume(int? volume, string? format, SettingsModel settings) + { + if (!volume.HasValue) + return ""; + + string volumeStr = volume.Value.ToString(); + + // Determine padding from format string (e.g., "00" = 2 digits) + // If no format specified, no padding is applied + int paddingLength = 0; + if (!string.IsNullOrEmpty(format) && format.All(c => c == '0')) + { + paddingLength = format.Length; + } + + return paddingLength > 0 ? volumeStr.PadLeft(paddingLength, '0') : volumeStr; + } + + private static string FormatTitle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) + return ""; + + string trimmed = title.Trim(); + + // Skip if title is just chapter info + string lower = trimmed.ToLowerInvariant(); + if (lower.Contains("ch.") || lower.Contains("chapter") || lower.Contains("chap")) + return ""; + + return "(" + trimmed.Replace('(', '[').Replace(')', ']') + ")"; + } + + private static string FormatDecimal(decimal value) + { + return value % 1 == 0 + ? ((int)value).ToString() + : value.ToString(System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/KaizokuBackend/Services/Naming/TemplateValidationResult.cs b/KaizokuBackend/Services/Naming/TemplateValidationResult.cs new file mode 100644 index 0000000..e4e7242 --- /dev/null +++ b/KaizokuBackend/Services/Naming/TemplateValidationResult.cs @@ -0,0 +1,20 @@ +namespace KaizokuBackend.Services.Naming; + +/// +/// Result of template validation +/// +public record TemplateValidationResult( + bool IsValid, + List Errors, + List Warnings, + List UsedVariables +); + +/// +/// Type of template being validated +/// +public enum TemplateType +{ + FileName, + FolderPath +} diff --git a/KaizokuBackend/Services/Naming/TemplateVariables.cs b/KaizokuBackend/Services/Naming/TemplateVariables.cs new file mode 100644 index 0000000..b87152a --- /dev/null +++ b/KaizokuBackend/Services/Naming/TemplateVariables.cs @@ -0,0 +1,17 @@ +namespace KaizokuBackend.Services.Naming; + +/// +/// Variables available for template expansion in file and folder naming +/// +public record TemplateVariables( + string Series, + decimal? Chapter, + int? Volume, + string Provider, + string? Scanlator, + string Language, + string? Title, // Chapter title + DateTime? UploadDate, + string? Type, // Manga, Manhwa, etc. + decimal? MaxChapter +); diff --git a/KaizokuBackend/Services/Search/SearchQueryService.cs b/KaizokuBackend/Services/Search/SearchQueryService.cs index 064579b..a7ad6f8 100644 --- a/KaizokuBackend/Services/Search/SearchQueryService.cs +++ b/KaizokuBackend/Services/Search/SearchQueryService.cs @@ -74,7 +74,7 @@ public async Task> GetAvailableSearchSourcesAsync(Cancellatio /// Cancellation token /// List of linked series matching the search criteria public async Task> SearchSeriesAsync(string keyword, List languages, - List? searchSources = null, double threshold = 0.1f, CancellationToken token = default) + List? searchSources = null, double threshold = 0.3, CancellationToken token = default) { if (string.IsNullOrWhiteSpace(keyword) || languages == null || languages.Count == 0) { @@ -95,7 +95,7 @@ public async Task> SearchSeriesAsync(string keyword, List> SearchSeriesAsync(List<(string, SuwayomiSource, ProviderStorage)> sources, KaizokuBackend.Models.Settings? appSettings, double threshold = 0.1f, CancellationToken token = default) + public async Task> SearchSeriesAsync(List<(string, SuwayomiSource, ProviderStorage)> sources, KaizokuBackend.Models.Settings? appSettings, double threshold = 0.3, CancellationToken token = default) { var results = new ConcurrentBag<(string, SuwayomiSource Source, ProviderStorage Storag, SuwayomiSeriesResult Result)>(); var maxConcurrency = Math.Min(appSettings?.NumberOfSimultaneousSearches ?? 10, sources.Count); @@ -184,7 +184,7 @@ await Parallel.ForEachAsync( /// Cancellation token /// List of linked series matching the search criteria public async Task> SearchSeriesAsync(string keyword, Dictionary sources, - KaizokuBackend.Models.Settings? appSettings, double threshold = 0.1f, CancellationToken token = default) + KaizokuBackend.Models.Settings? appSettings, double threshold = 0.3, CancellationToken token = default) { try { diff --git a/KaizokuBackend/Services/Series/SeriesExtensions.cs b/KaizokuBackend/Services/Series/SeriesExtensions.cs index 9054f23..cc75f90 100644 --- a/KaizokuBackend/Services/Series/SeriesExtensions.cs +++ b/KaizokuBackend/Services/Series/SeriesExtensions.cs @@ -374,7 +374,7 @@ public static SeriesInfo ToSeriesInfo(this Models.Database.Series series, Contex Id = series.Id, Title = series.Title, Description = series.Description, - ThumbnailUrl = cp.BaseUrl + series.ThumbnailUrl, + ThumbnailUrl = cp.BaseUrl + (string.IsNullOrEmpty(series.ThumbnailUrl) ? "serie/thumb/unknown" : series.ThumbnailUrl), Artist = series.Artist, Author = series.Author, Genre = series.Genre?.ToDistinctPascalCase() ?? new List(), diff --git a/KaizokuBackend/Services/Series/SeriesProviderService.cs b/KaizokuBackend/Services/Series/SeriesProviderService.cs index 2827d00..7296596 100644 --- a/KaizokuBackend/Services/Series/SeriesProviderService.cs +++ b/KaizokuBackend/Services/Series/SeriesProviderService.cs @@ -5,6 +5,7 @@ using KaizokuBackend.Models.Database; using KaizokuBackend.Services.Helpers; using KaizokuBackend.Services.Jobs; +using KaizokuBackend.Services.Naming; using KaizokuBackend.Services.Settings; using Microsoft.EntityFrameworkCore; @@ -19,15 +20,17 @@ public class SeriesProviderService private readonly SettingsService _settings; private readonly JobBusinessService _jobBusinessService; private readonly JobManagementService _jobManagementService; + private readonly ITemplateParser _templateParser; private readonly ILogger _logger; public SeriesProviderService(AppDbContext db, SettingsService settings, JobBusinessService jobBusinessService, - JobManagementService jobManagementService, ILogger logger) + JobManagementService jobManagementService, ITemplateParser templateParser, ILogger logger) { _db = db; _settings = settings; _jobBusinessService = jobBusinessService; _jobManagementService = jobManagementService; + _templateParser = templateParser; _logger = logger; } @@ -104,8 +107,22 @@ public async Task SetMatchAsync(ProviderMatch pm, CancellationToken token if (ch != null && dst != null) { decimal? maxChap = mi.Chapters.Max(c => c.Number); - string filename = ArchiveHelperService.MakeFileNameSafe(mi.Provider, mi.Scanlator, mi.Title, - mi.Language, dst.Number, dst.Name, maxChap); + + // Use template parser for consistent filename generation + var vars = new TemplateVariables( + Series: mi.Title, + Chapter: dst.Number, + Volume: null, + Provider: mi.Provider, + Scanlator: mi.Scanlator, + Language: mi.Language, + Title: settings.IncludeChapterTitle ? dst.Name : null, + UploadDate: dst.ProviderUploadDate, + Type: series.Type, + MaxChapter: maxChap + ); + string filename = _templateParser.ParseFileName(settings.FileNameTemplate, vars, settings); + string? extension = Path.GetExtension(ch.Filename); string newFilename = filename + extension; string originalPath = Path.Combine(settings.StorageFolder, series.StoragePath, ch.Filename ?? ""); diff --git a/KaizokuBackend/Services/ServiceExtensions.cs b/KaizokuBackend/Services/ServiceExtensions.cs index 2c48f27..64a1ecd 100644 --- a/KaizokuBackend/Services/ServiceExtensions.cs +++ b/KaizokuBackend/Services/ServiceExtensions.cs @@ -1,9 +1,11 @@ -using KaizokuBackend.Services.Daily; +using KaizokuBackend.Services.Archives; +using KaizokuBackend.Services.Daily; using KaizokuBackend.Services.Downloads; using KaizokuBackend.Services.Helpers; using KaizokuBackend.Services.Import; using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Jobs.Settings; +using KaizokuBackend.Services.Naming; using KaizokuBackend.Services.Providers; using KaizokuBackend.Services.Search; using KaizokuBackend.Services.Series; @@ -92,7 +94,16 @@ public static IServiceCollection AddDownloadServices(this IServiceCollection ser // Download CQRS Services services.TryAddScoped(); services.TryAddScoped(); - + + return services; + } + + public static IServiceCollection AddNamingServices(this IServiceCollection services) + { + // Template parsing and file naming + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; } } diff --git a/KaizokuBackend/Services/Settings/SettingsService.cs b/KaizokuBackend/Services/Settings/SettingsService.cs index 640b6c6..b997b16 100644 --- a/KaizokuBackend/Services/Settings/SettingsService.cs +++ b/KaizokuBackend/Services/Settings/SettingsService.cs @@ -5,9 +5,9 @@ using KaizokuBackend.Services.Jobs; using KaizokuBackend.Services.Jobs.Models; using KaizokuBackend.Services.Jobs.Settings; +using KaizokuBackend.Services.Naming; using KaizokuBackend.Services.Providers; using Microsoft.EntityFrameworkCore; -using System.ComponentModel; using System.Reflection; namespace KaizokuBackend.Services.Settings @@ -18,16 +18,19 @@ public class SettingsService private readonly AppDbContext _db; private readonly SuwayomiClient _client; private readonly IServiceScopeFactory _prov; + private readonly ITemplateParser _templateParser; + private readonly ILogger _logger; private static Models.Settings? _settings; - public SettingsService(IConfiguration config, IServiceScopeFactory prov, SuwayomiClient client, AppDbContext db) + public SettingsService(IConfiguration config, IServiceScopeFactory prov, SuwayomiClient client, AppDbContext db, ITemplateParser templateParser, ILogger logger) { _config = config; _client = client; _db = db; _prov = prov; - + _templateParser = templateParser; + _logger = logger; } @@ -78,10 +81,6 @@ private static List Serialize(EditableSettings editableSettings) case "datetime": setting.Value = ((DateTime)(p.GetValue(editableSettings) ?? new DateTime(0,1,1,4,0,0))).ToString("o"); // ISO 8601 format break; - default: - if (p.PropertyType.IsEnum) - setting.Value = p.GetValue(editableSettings)?.ToString() ?? string.Empty; - break; } serializedSettings.Add(setting); } @@ -140,10 +139,6 @@ private static (bool, EditableSettings) Deserialize(List settings, Edit case "datetime": p.SetValue(newEditableSettings, DateTime.TryParse(setting.Value, out DateTime dateTimeValue) ? dateTimeValue : DateTime.MinValue); break; - default: - if (p.PropertyType.IsEnum) - p.SetValue(newEditableSettings, Enum.TryParse(p.PropertyType, setting.Value, out var enumValue) ? enumValue : p.GetValue(defaultValues)); - break; } } return (needSave, newEditableSettings); @@ -275,7 +270,10 @@ public async Task SaveSettingsAsync(Models.Settings settings, bool force, Cancel FlareSolverrAsResponseFallback = settings.FlareSolverrAsResponseFallback, IsWizardSetupComplete = settings.IsWizardSetupComplete, WizardSetupStepCompleted = settings.WizardSetupStepCompleted, - NsfwVisibility = settings.NsfwVisibility + FileNameTemplate = settings.FileNameTemplate, + FolderTemplate = settings.FolderTemplate, + OutputFormat = settings.OutputFormat, + IncludeChapterTitle = settings.IncludeChapterTitle }; await SaveSettingsAsync(editableSettings, force, token).ConfigureAwait(false); @@ -304,11 +302,146 @@ public Models.Settings GetFromEditableSettings(EditableSettings ed) FlareSolverrAsResponseFallback = ed.FlareSolverrAsResponseFallback, IsWizardSetupComplete = ed.IsWizardSetupComplete, WizardSetupStepCompleted = ed.WizardSetupStepCompleted, - NsfwVisibility = ed.NsfwVisibility + FileNameTemplate = ed.FileNameTemplate, + FolderTemplate = ed.FolderTemplate, + OutputFormat = ed.OutputFormat, + IncludeChapterTitle = ed.IncludeChapterTitle }; set.StorageFolder = _config["StorageFolder"] ?? string.Empty; return set; } + + /// + /// Renames all existing downloaded files to match the current naming scheme. + /// + public async Task RenameFilesToCurrentSchemeAsync(CancellationToken token = default) + { + var settings = await GetSettingsAsync(token).ConfigureAwait(false); + var storageFolder = settings.StorageFolder; + + if (string.IsNullOrEmpty(storageFolder) || !Directory.Exists(storageFolder)) + { + _logger.LogWarning("Storage folder not found or not configured: {StorageFolder}", storageFolder); + return 0; + } + + // Get all series with their providers (need tracking to update filenames) + // Note: Chapters are automatically loaded as they are stored as JSON in SeriesProvider + var allSeries = await _db.Series + .Include(s => s.Sources) + .ToListAsync(token).ConfigureAwait(false); + + int renamedCount = 0; + int errorCount = 0; + + foreach (var series in allSeries) + { + if (series.Sources == null) continue; + + // Get max chapter for this series (for potential auto-padding) + decimal? maxChapter = series.Sources + .SelectMany(sp => sp.Chapters) + .Where(c => c.Number.HasValue) + .Max(c => c.Number); + + foreach (var source in series.Sources) + { + if (source.Chapters == null) continue; + + foreach (var chapter in source.Chapters) + { + if (string.IsNullOrEmpty(chapter.Filename)) continue; + + // Build current file path + var currentPath = Path.Combine(storageFolder, series.StoragePath ?? "", chapter.Filename); + + if (!File.Exists(currentPath)) + { + continue; + } + + try + { + // Create template variables from chapter data + var vars = new TemplateVariables( + Series: series.Title, + Chapter: chapter.Number, + Volume: null, // Volume info not stored in chapter + Provider: source.Provider, + Scanlator: source.Scanlator, + Language: source.Language, + Title: settings.IncludeChapterTitle ? chapter.Name : null, + UploadDate: chapter.ProviderUploadDate, + Type: series.Type, + MaxChapter: maxChapter + ); + + // Generate new filename using template + var newFileName = _templateParser.ParseFileName(settings.FileNameTemplate, vars, settings); + + // Ensure correct extension based on output format + var currentExt = Path.GetExtension(currentPath); + var expectedExt = settings.OutputFormat == 1 ? ".pdf" : ".cbz"; + + // Keep original extension if it's a valid archive format + if (currentExt.Equals(".cbz", StringComparison.OrdinalIgnoreCase) || + currentExt.Equals(".pdf", StringComparison.OrdinalIgnoreCase) || + currentExt.Equals(".zip", StringComparison.OrdinalIgnoreCase)) + { + // Remove any extension the template might have added and use original + if (newFileName.EndsWith(".cbz", StringComparison.OrdinalIgnoreCase) || + newFileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) + { + newFileName = newFileName[..^4]; + } + newFileName += currentExt; + } + + // Skip if filename is the same + if (chapter.Filename.Equals(newFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var newPath = Path.Combine(storageFolder, series.StoragePath ?? "", newFileName); + + // Skip if target already exists (avoid overwriting) + if (File.Exists(newPath)) + { + _logger.LogWarning("Target file already exists, skipping: {NewPath}", newPath); + continue; + } + + // Store old filename for logging before updating + var oldFileName = chapter.Filename; + + // Rename the file + File.Move(currentPath, newPath); + + // Update database + chapter.Filename = newFileName; + renamedCount++; + + _logger.LogInformation("Renamed: {OldName} -> {NewName}", oldFileName, newFileName); + } + catch (Exception ex) + { + errorCount++; + _logger.LogError(ex, "Error renaming file {FilePath}", currentPath); + } + } + } + } + + // Save all database changes + if (renamedCount > 0) + { + await _db.SaveChangesAsync(token).ConfigureAwait(false); + } + + _logger.LogInformation("Rename operation complete: {RenamedCount} files renamed, {ErrorCount} errors", renamedCount, errorCount); + return renamedCount; + } public async ValueTask GetSettingsAsync(CancellationToken token = default) { if (_settings != null) diff --git a/KaizokuBackend/Startup.cs b/KaizokuBackend/Startup.cs index 8365ba4..54d5eac 100644 --- a/KaizokuBackend/Startup.cs +++ b/KaizokuBackend/Startup.cs @@ -93,8 +93,9 @@ public void ConfigureServices(IServiceCollection services) services.AddJobServices(); services.AddProviderServices(); services.AddSearchServices(); - services.AddDownloadServices(); + services.AddDownloadServices(); services.AddHelperServices(); + services.AddNamingServices(); @@ -134,11 +135,43 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) // Add or update .txt mapping to ensure react/next.js fragments work provider.Mappings[".txt"] = "text/plain; charset=utf-8"; + var wwwrootPath = Path.Combine(EnvironmentSetup.Configuration!["runtimeDirectory"]!, "wwwroot"); + + // Rewrite RSC payload requests: /{route}.txt -> /{route}/index.txt + // Next.js 15 with trailingSlash: true generates RSC payloads at /{route}/index.txt + // but the client requests them at /{route}.txt + app.Use(async (context, next) => + { + var path = context.Request.Path.Value; + + // Check if this is an RSC payload request (*.txt with _rsc query param) + // and it's not already targeting index.txt or an API route + if (path != null && + path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) && + !path.EndsWith("/index.txt", StringComparison.OrdinalIgnoreCase) && + !path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) && + context.Request.Query.ContainsKey("_rsc")) + { + // Transform /route.txt to /route/index.txt + var routePath = path.Substring(0, path.Length - 4); // Remove .txt + var newPath = routePath + "/index.txt"; + + // Check if the rewritten file exists before rewriting + var physicalPath = Path.Combine(wwwrootPath, newPath.TrimStart('/')); + if (File.Exists(physicalPath)) + { + context.Request.Path = newPath; + } + } + + await next(); + }); + // Serve default files (index.html) app.UseDefaultFiles(new DefaultFilesOptions { DefaultFileNames = new List { "index.html" }, - FileProvider = new PhysicalFileProvider(Path.Combine(EnvironmentSetup.Configuration!["runtimeDirectory"]!, "wwwroot")) + FileProvider = new PhysicalFileProvider(wwwrootPath) }); // Serve static files with custom content type provider diff --git a/KaizokuBackend/Utils/EnvironmentSetup.cs b/KaizokuBackend/Utils/EnvironmentSetup.cs index 5f938c9..830a179 100644 --- a/KaizokuBackend/Utils/EnvironmentSetup.cs +++ b/KaizokuBackend/Utils/EnvironmentSetup.cs @@ -29,6 +29,8 @@ public static class EnvironmentSetup public const string SuwayomiJar = "Suwayomi-Server-{version}.jar"; public const string SuwayomiJarUrl = "https://github.com/Suwayomi/Suwayomi-Server/releases/download/{version}/{jar}"; public const string SuwayomiJarPreviewUrl = "https://github.com/Suwayomi/Suwayomi-Server-preview/releases/download/{version}/{jar}"; + public const string SuwayomiLatestReleaseUrl = "https://api.github.com/repos/Suwayomi/Suwayomi-Server/releases/latest"; + public const string SuwayomiLatestPreviewReleaseUrl = "https://api.github.com/repos/Suwayomi/Suwayomi-Server-preview/releases/latest"; public const string AppKaizokuNET = "Kaiz.NET"; public const string AppSuwayomi = "Suwayomi"; @@ -509,6 +511,59 @@ public static bool CheckJavaVersion() return null; } + /// + /// Fetches the latest Suwayomi release version tag from the GitHub API. + /// + /// Whether to check the preview repository + /// Cancellation token + /// The latest version tag (e.g. "v2.0.1833"), or null if the request fails + public static async Task GetLatestSuwayomiVersionAsync(bool usePreview, CancellationToken token = default) + { + string apiUrl = usePreview ? SuwayomiLatestPreviewReleaseUrl : SuwayomiLatestReleaseUrl; + try + { + _logger?.LogInformation("Fetching latest Suwayomi version from GitHub ({repo})...", + usePreview ? "preview" : "stable"); + + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("User-Agent", "KaizokuNET"); + httpClient.Timeout = TimeSpan.FromSeconds(10); + + using var response = await httpClient.GetAsync(apiUrl, token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + _logger?.LogWarning("GitHub API returned {StatusCode} when fetching latest Suwayomi version.", response.StatusCode); + return null; + } + + using var stream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + using var json = await JsonDocument.ParseAsync(stream, cancellationToken: token).ConfigureAwait(false); + + string? tag = json.RootElement.TryGetProperty("tag_name", out var tagElement) + ? tagElement.GetString() + : null; + + if (string.IsNullOrWhiteSpace(tag)) + { + _logger?.LogWarning("GitHub API response did not contain a valid tag_name."); + return null; + } + + _logger?.LogInformation("Latest Suwayomi version: {Version}", tag); + return tag; + } + catch (TaskCanceledException) + { + _logger?.LogWarning("Timed out fetching latest Suwayomi version from GitHub."); + return null; + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to fetch latest Suwayomi version from GitHub."); + return null; + } + } + public static async Task DownloadSuwayomiIfNeededAsync(CancellationToken token = default) { _logger?.LogInformation("Checking if Suwayomi is downloaded and up to date."); @@ -516,13 +571,22 @@ public static async Task DownloadSuwayomiIfNeededAsync(CancellationToken t if (useCustomApi) return true; bool usePreview = Configuration!.GetValue("Suwayomi:UsePreview", false); - string version = Configuration!.GetValue("Suwayomi:Version", "v2.0.1727"); - string jar = SuwayomiJar; - string url = SuwayomiJarUrl; - if (usePreview) - url = SuwayomiJarPreviewUrl; - jar = jar.Replace("{version}", version); - url = url.Replace("{version}", version).Replace("{jar}", jar); + bool autoUpdate = Configuration!.GetValue("Suwayomi:AutoUpdate", true); + string configVersion = Configuration!.GetValue("Suwayomi:Version", "v2.0.1727"); + + string version = configVersion; + if (autoUpdate) + { + string? latestVersion = await GetLatestSuwayomiVersionAsync(usePreview, token).ConfigureAwait(false); + version = latestVersion ?? configVersion; + if (latestVersion == null) + _logger?.LogWarning("Could not fetch latest version from GitHub, falling back to configured version {Version}.", configVersion); + } + + string jar = SuwayomiJar.Replace("{version}", version); + string url = (usePreview ? SuwayomiJarPreviewUrl : SuwayomiJarUrl) + .Replace("{version}", version) + .Replace("{jar}", jar); string suwayomiPath = System.IO.Path.Combine(Path, "Suwayomi"); string suwayomiJarFullPath = System.IO.Path.Combine(suwayomiPath, jar); if (File.Exists(suwayomiJarFullPath)) diff --git a/KaizokuBackend/Utils/KeyedAsyncLock.cs b/KaizokuBackend/Utils/KeyedAsyncLock.cs index 138c139..4d4b7f8 100644 --- a/KaizokuBackend/Utils/KeyedAsyncLock.cs +++ b/KaizokuBackend/Utils/KeyedAsyncLock.cs @@ -7,23 +7,93 @@ namespace KaizokuBackend.Utils { public class KeyedAsyncLock { - private readonly ConcurrentDictionary _locks = new(); + private readonly ConcurrentDictionary _locks = new(); public async Task LockAsync(string key, CancellationToken token = default) { - var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - await semaphore.WaitAsync(token).ConfigureAwait(false); - return new Releaser(this, key, semaphore); + while (true) + { + // Check cancellation at start of each iteration + token.ThrowIfCancellationRequested(); + + var semaphore = _locks.GetOrAdd(key, _ => new RefCountedSemaphore()); + + // Increment ref count before waiting - if it was 0, the semaphore is being disposed + if (semaphore.TryAddRef()) + { + try + { + await semaphore.Semaphore.WaitAsync(token).ConfigureAwait(false); + return new Releaser(this, key, semaphore); + } + catch + { + // If WaitAsync fails (e.g., cancellation), release the ref we added + ReleaseRef(key, semaphore); + throw; + } + } + + // Semaphore is being disposed, yield to allow disposal to complete before retry + await Task.Yield(); + } + } + + private void ReleaseRef(string key, RefCountedSemaphore semaphore) + { + if (semaphore.ReleaseRef() == 0) + { + // No more references - try to remove from dictionary atomically + // Only remove if it's still the same instance (another thread might have replaced it) + ((ICollection>)_locks) + .Remove(new System.Collections.Generic.KeyValuePair(key, semaphore)); + semaphore.Semaphore.Dispose(); + } + } + + private sealed class RefCountedSemaphore + { + public SemaphoreSlim Semaphore { get; } = new(1, 1); + private int _refCount; + + // Returns true if ref was added, false if semaphore is being disposed (refCount was 0) + public bool TryAddRef() + { + while (true) + { + int current = Volatile.Read(ref _refCount); + if (current < 0) + return false; // Marked for disposal + + if (Interlocked.CompareExchange(ref _refCount, current + 1, current) == current) + return true; + } + } + + // Returns the new ref count after decrement. Marks as disposed when reaching 0. + public int ReleaseRef() + { + while (true) + { + int current = Volatile.Read(ref _refCount); + int newValue = current - 1; + if (newValue == 0) + newValue = -1; // Mark as disposed to prevent new refs + + if (Interlocked.CompareExchange(ref _refCount, newValue, current) == current) + return current - 1; // Return the logical count (0 means no refs) + } + } } private sealed class Releaser : IDisposable { private readonly KeyedAsyncLock _parent; private readonly string _key; - private readonly SemaphoreSlim _semaphore; - private bool _disposed; + private readonly RefCountedSemaphore _semaphore; + private int _disposed; - public Releaser(KeyedAsyncLock parent, string key, SemaphoreSlim semaphore) + public Releaser(KeyedAsyncLock parent, string key, RefCountedSemaphore semaphore) { _parent = parent; _key = key; @@ -32,17 +102,10 @@ public Releaser(KeyedAsyncLock parent, string key, SemaphoreSlim semaphore) public void Dispose() { - if (_disposed) return; - _semaphore.Release(); - - // Try to remove the semaphore if no one is waiting and count is 1 (unlocked) - if (_semaphore.CurrentCount == 1 && _parent._locks.TryGetValue(_key, out var sem) && sem == _semaphore) - { - _parent._locks.TryRemove(_key, out _); - _semaphore.Dispose(); - } + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; - _disposed = true; + _semaphore.Semaphore.Release(); + _parent.ReleaseRef(_key, _semaphore); } } } diff --git a/KaizokuBackend/Utils/PathValidationHelper.cs b/KaizokuBackend/Utils/PathValidationHelper.cs new file mode 100644 index 0000000..f429d4f --- /dev/null +++ b/KaizokuBackend/Utils/PathValidationHelper.cs @@ -0,0 +1,43 @@ +namespace KaizokuBackend.Utils +{ + public static class PathValidationHelper + { + public static bool IsValidPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + // Check for path traversal patterns + if (path.Contains("..")) + return false; + + // Check for invalid characters + var invalidChars = Path.GetInvalidPathChars(); + if (path.Any(c => invalidChars.Contains(c))) + return false; + + return true; + } + + public static bool IsValidGuid(string? guid) + { + return !string.IsNullOrEmpty(guid) && Guid.TryParse(guid, out _); + } + + /// + /// Validates package/APK names used in provider routes. + /// Package names should only contain alphanumeric, dots, underscores, and hyphens. + /// + public static bool IsValidPackageName(string? packageName) + { + if (string.IsNullOrWhiteSpace(packageName)) + return false; + + // Check for path traversal patterns + if (packageName.Contains("..") || packageName.Contains('/') || packageName.Contains('\\')) + return false; + + return true; + } + } +} diff --git a/KaizokuBackend/appsettings.json b/KaizokuBackend/appsettings.json index 34c3fb5..98084a2 100644 --- a/KaizokuBackend/appsettings.json +++ b/KaizokuBackend/appsettings.json @@ -1,6 +1,7 @@ { "StorageFolder": "", "Suwayomi": { + "AutoUpdate": true, "UsePreview": true, "Version": "v2.0.1833", "UseCustomApi": false, diff --git a/KaizokuFrontend/pnpm-lock.yaml b/KaizokuFrontend/pnpm-lock.yaml index a664015..1178490 100644 --- a/KaizokuFrontend/pnpm-lock.yaml +++ b/KaizokuFrontend/pnpm-lock.yaml @@ -524,78 +524,92 @@ packages: resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.0': resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.0': resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.0': resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.0': resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.0': resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.0': resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.3': resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.3': resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.3': resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.3': resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.3': resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.3': resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.3': resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.3': resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==} @@ -666,24 +680,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.3.5': resolution: {integrity: sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.3.5': resolution: {integrity: sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.3.5': resolution: {integrity: sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.3.5': resolution: {integrity: sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==} @@ -1232,24 +1250,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.11': resolution: {integrity: sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.11': resolution: {integrity: sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.11': resolution: {integrity: sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.11': resolution: {integrity: sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==} @@ -1461,41 +1483,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2571,24 +2601,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} diff --git a/KaizokuFrontend/src/app/library/page.tsx b/KaizokuFrontend/src/app/library/page.tsx index 1eda644..372ac54 100644 --- a/KaizokuFrontend/src/app/library/page.tsx +++ b/KaizokuFrontend/src/app/library/page.tsx @@ -167,12 +167,13 @@ export default function RootPage() {
-
-
+
+ {/* Filter row - wraps on mobile */} +
{/* Status Filter - first select */} -
+
- -
- -
-
- -
-
- {/* Order Select - immediately after tabs, to the left */} -
- setSelectedGenre(value === "__ALL__" ? null : value)} + > + + + + + All Genres + {genres.map((genre) => ( + + {genre} + + ))} + + +
+
+
- {/* Card Size Select */} -
+ + {/* Right side controls */} +
+ {/* Order Select */} +
+ +
+ {/* Card Size Select */} +
-
+
-
+
-
- {/* Header section with thumbnail and info */} -
-
- {/* Action buttons */} -
- - - - - {!provider.isUnknown && !provider.isUninstalled && ( - - )} + +
+ {/* Action buttons - mobile/tablet: top row, desktop: top-right absolute */} +
+ - {provider.isUnknown && ( - - )} -
- {!provider.isUnknown && ( -
- Continue After Chapter:  setLocalFromChapter(e.target.value)} - placeholder="Start" - className=" mr-0 h-8 w-24 text-sm bg-background text-right tabular-nums font-mono inline-block" - disabled={provider.isDisabled} - onBlur={handleFromChapterBlur} - onKeyDown={handleFromChapterKeyDown} - /> -
- )} -
+ {!provider.isUnknown && !provider.isUninstalled && ( + + )} + + {provider.isUnknown && ( + + )} +
+ + {/* Continue After Chapter input - mobile/tablet: below buttons, desktop: top-right */} + {!provider.isUnknown && ( +
+ Continue After Chapter: + setLocalFromChapter(e.target.value)} + placeholder="Start" + className="h-8 w-24 text-sm bg-background text-right tabular-nums font-mono" + disabled={provider.isDisabled} + onBlur={handleFromChapterBlur} + onKeyDown={handleFromChapterKeyDown} + />
-
+ )} + + {/* Header section with thumbnail and info */} +
+
{/* Provider Thumbnail */} -
+
{provider.title}
-
- {provider.title} +
+ {provider.title} { provider.url ? ( -
{ e.stopPropagation(); if (provider.url) { @@ -247,48 +245,48 @@ const ProviderCard = ({ provider, } }} title="Click to open in the source" - > - {provider.provider}{(provider.provider != provider.scanlator && provider.scanlator) ? ` • ${provider.scanlator}` : ''} + > + {provider.provider}{(provider.provider != provider.scanlator && provider.scanlator) ? ` • ${provider.scanlator}` : ''} - + {getStatusDisplay(provider.status).text}
) : ( -
- {provider.provider}{(provider.provider != provider.scanlator && provider.scanlator) ? ` • ${provider.scanlator}` : ''} +
+ {provider.provider}{(provider.provider != provider.scanlator && provider.scanlator) ? ` • ${provider.scanlator}` : ''} - + {getStatusDisplay(provider.status).text}
)}
{/* Stats grid */} -
-
+
+
{provider.chapterList} {provider.lastChapter && ( - -   - Last:  {provider.lastChapter} + + + Last: {provider.lastChapter} {provider.lastChangeUTC && ( -    + {(() => { - const utcString = provider.lastChangeUTC.includes('Z') || provider.lastChangeUTC.includes('+') || provider.lastChangeUTC.includes('-', 10) - ? provider.lastChangeUTC + const utcString = provider.lastChangeUTC.includes('Z') || provider.lastChangeUTC.includes('+') || provider.lastChangeUTC.includes('-', 10) + ? provider.lastChangeUTC : provider.lastChangeUTC + 'Z'; const date = new Date(utcString); return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; @@ -303,77 +301,67 @@ const ProviderCard = ({ provider,
-
+
{provider.author && ( -
+
Author: - {provider.author} + {provider.author}
)} {provider.artist && ( -
+
Artist: - {provider.artist} + {provider.artist}
)}
-
+
{provider.genre && provider.genre.length > 0 && ( - <> -
-
- {provider.genre.map((genre) => ( - - {genre} - - ))} -
-
- )} - - + provider.genre.map((genre) => ( + + {genre} + + )) + )}
-
- +
{provider.description && ( - <> -
-

{provider.description}

-
- +

{provider.description}

)} -
{/* Switches */} {!provider.isUnknown && ( -
-
+
+
onUseStorageChange(provider.id, checked)} disabled={provider.isDisabled} + className="flex-shrink-0" /> -
onUseCoverChange(provider.id, checked)} disabled={provider.isDisabled || hasUnknownThumbnail} + className="flex-shrink-0" /> -
-
+
onUseTitleChange(provider.id, checked)} disabled={provider.isDisabled} + className="flex-shrink-0" /> -
@@ -519,9 +507,9 @@ const DownloadItem = ({ download }: { download: DownloadInfo }) => { // Do not show for RUNNING status return ( - + -
+
{download.title { target.src = '/kaizoku.net.png'; }} /> -
- +
+ {download.title || 'Unknown Series'} -

+

{download.chapterTitle ? download.chapterTitle : `Chapter ${download.chapter}`}

-
+
{(download.provider || download.scanlator) && ( download.url ? (

{ e.stopPropagation(); if (download.url) { @@ -553,12 +541,12 @@ const DownloadItem = ({ download }: { download: DownloadInfo }) => { }} title="Click to open the chapter in the source" > - - {download.provider} - {(download.provider !== download.scanlator && download.scanlator) ? ` • ${download.scanlator}` : ''} + + {download.provider} + {(download.provider !== download.scanlator && download.scanlator) ? ` • ${download.scanlator}` : ''}

) : ( -

+

{download.provider} {(download.provider !== download.scanlator && download.scanlator) ? ` • ${download.scanlator}` : ''}

@@ -566,7 +554,7 @@ const DownloadItem = ({ download }: { download: DownloadInfo }) => { )} {getStatusIcon(download.status, download.status === QueueStatus.WAITING && displayDate > now)} {showDate && ( -
+
{download.status === QueueStatus.COMPLETED || download.status === QueueStatus.FAILED ? ( <> {displayDate.toLocaleDateString()}  @@ -578,7 +566,7 @@ const DownloadItem = ({ download }: { download: DownloadInfo }) => {
)} {download.retries > 0 && ( -
+
Retries: {download.retries}
)} @@ -687,14 +675,14 @@ const DownloadsPanel = memo(({ seriesId, isDeleting }: { seriesId: string; isDel if (downloadsError) { return ( - + - - Latest Downloads + + Latest Downloads - +

Failed to load downloads

@@ -705,22 +693,22 @@ const DownloadsPanel = memo(({ seriesId, isDeleting }: { seriesId: string; isDel } return ( - + - - - Latest Downloads + + + Latest Downloads {sortedDownloads.length > 0 && ( - + {sortedDownloads.length} )} {downloadsLoading && ( -
+
)}
- + {sortedDownloads.length > 0 ? (
{sortedDownloads.map((download, index) => ( @@ -1792,40 +1780,40 @@ function SeriesPageContent() { return (<> {/* Three-area layout */} -
{/* Left Column - Two rows (80% width) */} -
- {/* Top Left: Series Details */} +
{/* Left Column - Two rows (80% width) */} +
+ {/* Top Left: Series Details */} -
+
{/* Poster */} -
+
{displayTitle}
{/* Series Info */} -
+
{/* Status Badge - Top Right of Info Pane */} -
+
{statusDisplay.text}
-
- {displayTitle} -
+
+ {displayTitle} +
{series.chapterList} {series.lastChapter && ( - + - Last:  {series.lastChapter} + Last: {series.lastChapter} {series.lastChangeUTC && ( -    + {new Date(series.lastChangeUTC).toLocaleDateString()} {new Date(series.lastChangeUTC).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} )} @@ -1833,50 +1821,43 @@ function SeriesPageContent() { )}
-
+
{series.author && ( -
+
Author: - {series.author} + {series.author}
)} {series.artist && ( -
+
Artist: - {series.artist} + {series.artist}
)}
{series.genre && series.genre.length > 0 && ( -
-
- {series.genre.map((genre) => ( - - {genre} - - ))} -
-
)} - +
+ {series.genre.map((genre) => ( + + {genre} + + ))} +
+ )} + {/* Description - Flexible area that fills available space */} {series.description && ( -
-

{series.description}

+
+

{series.description}

)} {/* Series Path Display - Bottom of info section */} {series.path && ( -
+
{series.path}
@@ -1884,7 +1865,7 @@ function SeriesPageContent() { )} {/* Action Buttons - Delete, Verify, and Pause/Resume Downloads */} -
+
{/* Delete Series Button */} - + {/* Verify Integrity Button */} - + {/* Pause/Resume Downloads Button */} @@ -1929,12 +1913,14 @@ function SeriesPageContent() {
{/* Bottom Left: Providers */} - - -
- Sources - {visibleProvidersCount} - + +
+ + Sources + {visibleProvidersCount} + +
-
{series.providers + + +
{series.providers .filter(provider => !providerDeletedStates[provider.id]) // Filter out deleted providers .map((provider) => { const switches = providerSwitches[provider.id] || { useTitle: false, useCover: false, useStorage: false }; diff --git a/KaizokuFrontend/src/app/queue/page.tsx b/KaizokuFrontend/src/app/queue/page.tsx index d898ad0..0680cf3 100644 --- a/KaizokuFrontend/src/app/queue/page.tsx +++ b/KaizokuFrontend/src/app/queue/page.tsx @@ -2,14 +2,14 @@ import React, { useMemo, memo } from 'react'; import { useQueue, useRemoveFromQueue, useDownloadProgress } from '@/lib/api/hooks/useQueue'; -import { useCompletedDownloadsWithCount, useWaitingDownloadsWithCount, useFailedDownloadsWithCount, useManageErrorDownload } from '@/lib/api/hooks/useDownloads'; +import { useCompletedDownloadsWithCount, useWaitingDownloadsWithCount, useFailedDownloadsWithCount, useManageErrorDownload, useRemoveScheduledDownload } from '@/lib/api/hooks/useDownloads'; import { useSettings } from '@/lib/api/hooks/useSettings'; import { useSearch } from '@/contexts/search-context'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; -import { Trash2, Download, AlertTriangle, CheckCircle, Clock, Smile, Calendar, ExternalLink, RotateCcw } from 'lucide-react'; +import { Trash2, Download, AlertTriangle, CheckCircle, Clock, Smile, Calendar, ExternalLink, RotateCcw, X } from 'lucide-react'; import { ProgressStatus, QueueStatus, type DownloadInfo, type DownloadInfoList, ErrorDownloadAction } from '@/lib/api/types'; import { getApiConfig } from '@/lib/api/config'; import Image from 'next/image'; @@ -41,7 +41,7 @@ interface ExtendedQueueItem { } // Download Card Component - Shared UI for all download panels -const DownloadCard = memo(({ item }: { item: ExtendedQueueItem | DownloadInfo }) => { +const DownloadCard = memo(({ item, onClear, isClearing }: { item: ExtendedQueueItem | DownloadInfo; onClear?: () => void; isClearing?: boolean }) => { // Helper function to normalize UTC date strings const normalizeUtcString = (dateString: string) => { return dateString.includes('Z') || dateString.includes('+') || dateString.includes('-', 10) @@ -136,7 +136,22 @@ const DownloadCard = memo(({ item }: { item: ExtendedQueueItem | DownloadInfo }) }; return ( - + + {/* Clear button positioned at top right when onClear is provided */} + {onClear && ( +
+ +
+ )}
{ const { data: settings } = useSettings(); const { debouncedSearchTerm } = useSearch(); const limit = settings?.numberOfSimultaneousDownloads || 10; - + const removeScheduledDownloadMutation = useRemoveScheduledDownload(); + const { data: scheduledDownloadsData, isLoading } = useWaitingDownloadsWithCount( limit, debouncedSearchTerm.trim() || undefined, // Pass search term to server @@ -515,6 +531,10 @@ const ScheduledDownloadsPanel = memo(() => { const memoizedDownloads = useMemo(() => scheduledDownloadsData?.downloads || [], [scheduledDownloadsData?.downloads]); const totalCount = scheduledDownloadsData?.totalCount || 0; + const handleClearDownload = (id: string) => { + removeScheduledDownloadMutation.mutate(id); + }; + return ( @@ -545,7 +565,12 @@ const ScheduledDownloadsPanel = memo(() => { ) : (
{memoizedDownloads.map((download) => ( - + handleClearDownload(download.id)} + isClearing={removeScheduledDownloadMutation.isPending} + /> ))}
)} diff --git a/KaizokuFrontend/src/components/kzk/import-wizard/index.tsx b/KaizokuFrontend/src/components/kzk/import-wizard/index.tsx index ad119f3..27d7df5 100644 --- a/KaizokuFrontend/src/components/kzk/import-wizard/index.tsx +++ b/KaizokuFrontend/src/components/kzk/import-wizard/index.tsx @@ -50,7 +50,7 @@ export function ImportWizard() { return ( { /* Prevent closing */ }} modal> e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()} > @@ -61,7 +61,7 @@ export function ImportWizard() { -
+
+
-
+
Step {currentStep + 1} of {totalSteps}
diff --git a/KaizokuFrontend/src/components/kzk/layout/sidebar.tsx b/KaizokuFrontend/src/components/kzk/layout/sidebar.tsx index 9e09a8b..16bab70 100644 --- a/KaizokuFrontend/src/components/kzk/layout/sidebar.tsx +++ b/KaizokuFrontend/src/components/kzk/layout/sidebar.tsx @@ -12,6 +12,12 @@ import { } from "@/components/ui/tooltip"; export const sidebarItems = [ + { + name: "Library", + href: "/library", + icon: , + topSide: true, + }, { name: "Newly Minted", href: "/cloud-latest", diff --git a/KaizokuFrontend/src/components/kzk/provider-manager.tsx b/KaizokuFrontend/src/components/kzk/provider-manager.tsx index 20290ae..0aaf6aa 100644 --- a/KaizokuFrontend/src/components/kzk/provider-manager.tsx +++ b/KaizokuFrontend/src/components/kzk/provider-manager.tsx @@ -13,6 +13,10 @@ import { getCountryCodeForLanguage } from "@/lib/utils/language-country-mapping" import { LazyImage } from "@/components/ui/lazy-image"; import { ProviderSettingsButton } from "@/components/kzk/provider-settings-button"; import { ProviderPreferencesRequester } from "@/components/kzk/provider-preferences-requester"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { MultiSelect } from "@/components/ui/multi-select"; +import { useSettings } from "@/lib/api/hooks/useSettings"; interface ProviderCardProps { extension: Provider; @@ -116,12 +120,12 @@ function ProviderCard({ ) : extension.installed ? ( <> - {isCompact ? "Remove" : "Uninstall"} + {isCompact ? "Remove" : "Uninstall"} ) : ( <> - Install + Install )} @@ -243,7 +247,10 @@ export function ProviderManager({ // Filter available extensions based on search term const availableExtensions = useMemo(() => { - const available = extensions.filter(ext => !ext.installed); + let available = extensions.filter(ext => !ext.installed); + if (filteredLanguages && filteredLanguages.length > 0) { + available = available.filter(ext => filteredLanguages.includes(ext.lang)); + } if (!searchTerm.trim()) { return available; } @@ -252,10 +259,15 @@ export function ProviderManager({ ext.name.toLowerCase().includes(search) || ext.lang.toLowerCase().includes(search) ); - }, [extensions, searchTerm]); + }, [extensions, searchTerm, filteredLanguages]); const availableTotalCount = extensions.filter(ext => !ext.installed).length; + const availableLanguageOptions = useMemo(() => { + const langs = new Set(extensions.filter(ext => !ext.installed).map(ext => ext.lang)); + return Array.from(langs).sort().map(lang => ({ value: lang, label: lang })); + }, [extensions]); + const handleInstall = async (pkgName: string) => { try { setActionLoading(pkgName); @@ -459,12 +471,11 @@ export function ProviderManager({ className="gap-2" > - {isUploadingApk ? 'Installing...' : 'Install From APK'} + {isUploadingApk ? 'Installing...' : 'Install From APK'}
-
-
+
{nsfwVisibility !== NsfwVisibility.AlwaysHide && (
)}
-
- {availableExtensions.length > 0 && (
diff --git a/KaizokuFrontend/src/components/kzk/provider-settings-button.tsx b/KaizokuFrontend/src/components/kzk/provider-settings-button.tsx index a677e72..67ec063 100644 --- a/KaizokuFrontend/src/components/kzk/provider-settings-button.tsx +++ b/KaizokuFrontend/src/components/kzk/provider-settings-button.tsx @@ -31,7 +31,7 @@ export function ProviderSettingsButton({ onClick={() => setPreferencesOpen(true)} > - {size !== "icon" && Settings} + {size !== "icon" && Settings} void; + item: LatestSeriesInfo; + onAddSeries: () => void; +} + +// Helper function to format thumbnail URL +const formatThumbnailUrl = (thumbnailUrl?: string): string => { + if (!thumbnailUrl) { + return '/kaizoku.net.png'; + } + + // If it already starts with http, return as is + if (thumbnailUrl.startsWith('http')) { + return thumbnailUrl; + } + + // Otherwise, prefix with base URL and API path + const config = getApiConfig(); + return `${config.baseUrl}/api/${thumbnailUrl}`; +}; + +export const CloudLatestDetailsModal: React.FC = ({ + open, + onOpenChange, + item, + onAddSeries, +}) => { + const statusDisplay = getStatusDisplay(item.status); + + const handleViewSource = () => { + if (item.url) { + window.open(item.url, '_blank', 'noopener,noreferrer'); + } + }; + + const handleAddSeries = () => { + onOpenChange(false); + onAddSeries(); + }; + + return ( + + + {/* Header with title and status badge */} + +
+
+ {item.latestChapter && ( + + {item.latestChapter} + + )} + + {item.title} + +
+ + {statusDisplay.text} + +
+ + Details for {item.title} + +
+ + {/* Content - responsive layout */} +
+
+ {/* Thumbnail */} +
+ {item.title} { + const target = e.target as HTMLImageElement; + if (target.src !== window.location.origin + '/kaizoku.net.png') { + target.src = '/kaizoku.net.png'; + } + }} + /> +
+ + {/* Details */} +
+ {/* Author/Artist */} + {(item.author || item.artist) && ( +
+ {item.author && by {item.author}} + {item.artist && item.artist !== item.author && ( + art by {item.artist} + )} +
+ )} + + {/* Genre Tags */} + {item.genre && item.genre.length > 0 && ( + + )} + + {/* Description - scrollable */} +
+

+ {item.description || "No description available"} +

+
+ + {/* Provider with country flag */} +
+ {item.url ? ( + + + {item.provider} + + + ) : ( + + {item.provider} + + + )} +
+
+
+
+ + {/* Footer with action buttons */} + + {item.url && ( + + )} + {item.inLibrary === InLibraryStatus.NotInLibrary && ( + + )} + +
+
+ ); +}; diff --git a/KaizokuFrontend/src/components/kzk/series/cloud-latest-grid.tsx b/KaizokuFrontend/src/components/kzk/series/cloud-latest-grid.tsx index e95b324..2740580 100644 --- a/KaizokuFrontend/src/components/kzk/series/cloud-latest-grid.tsx +++ b/KaizokuFrontend/src/components/kzk/series/cloud-latest-grid.tsx @@ -21,6 +21,7 @@ import { LastChapterBadge } from "@/components/ui/last-chapter-badge"; import { SeriesStatus } from "@/lib/api/types"; import { getStatusDisplay } from "@/lib/utils/series-status"; import { useRouter } from 'next/navigation'; +import { CloudLatestDetailsModal } from '@/components/kzk/series/cloud-latest-details-modal'; // Color array for the fetch date ring (31 colors from green to blue) const FETCH_DATE_COLORS = [ @@ -71,6 +72,7 @@ interface CloudLatestCardProps { const CloudLatestCard: React.FC = ({ item, cardWidth, textSize }) => { const [showAddSeries, setShowAddSeries] = useState(false); + const [showDetailsModal, setShowDetailsModal] = useState(false); const router = useRouter(); // Helper function to format thumbnail URL @@ -89,14 +91,14 @@ const CloudLatestCard: React.FC = ({ item, cardWidth, text return `${config.baseUrl}/api/${thumbnailUrl}`; }; - // Handle card click navigation + // Handle card click - open details modal for items not in library, navigate for library items const handleCardClick = () => { if (item.seriesId) { // Navigate to individual series page using query parameter router.push(`/library/series?id=${item.seriesId}`); - } else if (item.url) { - // Open external URL in new tab - window.open(item.url, '_blank', 'noopener,noreferrer'); + } else { + // Open details modal for items not in library + setShowDetailsModal(true); } }; @@ -274,12 +276,20 @@ const CloudLatestCard: React.FC = ({ item, cardWidth, text {/* Add Series Modal */} {showAddSeries && ( - )} + + {/* Details Modal - for mobile/touch users */} + setShowAddSeries(true)} + /> ); }; diff --git a/KaizokuFrontend/src/components/kzk/settings-manager.tsx b/KaizokuFrontend/src/components/kzk/settings-manager.tsx index 779b8a1..3e736cd 100644 --- a/KaizokuFrontend/src/components/kzk/settings-manager.tsx +++ b/KaizokuFrontend/src/components/kzk/settings-manager.tsx @@ -1,25 +1,15 @@ "use client"; -import React, { useState } from "react"; +import React, { useState } from 'react'; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Plus, X, Save, Loader2, GripVertical } from "lucide-react"; -import { - useSettings, - useAvailableLanguages, - useUpdateSettings, -} from "@/lib/api/hooks/useSettings"; -import { type Settings, NsfwVisibility } from "@/lib/api/types"; +import { useSettings, useAvailableLanguages, useUpdateSettings } from "@/lib/api/hooks/useSettings"; +import { type Settings } from "@/lib/api/types"; import { useToast } from "@/hooks/use-toast"; import ReactCountryFlag from "react-country-flag"; import { @@ -30,17 +20,19 @@ import { useSensor, useSensors, type DragEndEvent, -} from "@dnd-kit/core"; +} from '@dnd-kit/core'; import { getCountryCodeForLanguage } from "@/lib/utils/language-country-mapping"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; +} from '@dnd-kit/sortable'; +import { + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { NamingFormatSection } from './settings-sections/naming-format-section'; // Helper functions const isValidUrl = (url: string): boolean => { @@ -54,79 +46,65 @@ const isValidUrl = (url: string): boolean => { const timeSpanToTimeInput = (timeSpan: string): string => { if (!timeSpan) return "00:00"; - - const parts = timeSpan.split("."); + + const parts = timeSpan.split('.'); let timePart = timeSpan; - + if (parts.length === 2 && parts[1]) { timePart = parts[1]; } - - const [hours = 0, minutes = 0] = timePart - .split(":") - .map((p) => parseInt(p) || 0); - - const paddedHours = hours.toString().padStart(2, "0"); - const paddedMinutes = minutes.toString().padStart(2, "0"); - + + const [hours = 0, minutes = 0] = timePart.split(':').map(p => parseInt(p) || 0); + + const paddedHours = hours.toString().padStart(2, '0'); + const paddedMinutes = minutes.toString().padStart(2, '0'); + return `${paddedHours}:${paddedMinutes}`; }; const timeSpanToTimeInputSeconds = (timeSpan: string): string => { if (!timeSpan) return "00:00:00"; - - const parts = timeSpan.split("."); + + const parts = timeSpan.split('.'); let timePart = timeSpan; - + if (parts.length === 2 && parts[1]) { timePart = parts[1]; } - - const [hours = 0, minutes = 0, seconds = 0] = timePart - .split(":") - .map((p) => parseInt(p) || 0); - - const paddedHours = hours.toString().padStart(2, "0"); - const paddedMinutes = minutes.toString().padStart(2, "0"); - const paddedSeconds = seconds.toString().padStart(2, "0"); + + const [hours = 0, minutes = 0, seconds = 0] = timePart.split(':').map(p => parseInt(p) || 0); + + const paddedHours = hours.toString().padStart(2, '0'); + const paddedMinutes = minutes.toString().padStart(2, '0'); + const paddedSeconds = seconds.toString().padStart(2, '0'); return `${paddedHours}:${paddedMinutes}:${paddedSeconds}`; }; const timeInputToTimeSpan = (timeInput: string): string => { if (!timeInput) return "00:00:00"; - - const [hours = 0, minutes = 0] = timeInput - .split(":") - .map((p) => parseInt(p) || 0); - - const paddedHours = hours.toString().padStart(2, "0"); - const paddedMinutes = minutes.toString().padStart(2, "0"); - + + const [hours = 0, minutes = 0] = timeInput.split(':').map(p => parseInt(p) || 0); + + const paddedHours = hours.toString().padStart(2, '0'); + const paddedMinutes = minutes.toString().padStart(2, '0'); + return `${paddedHours}:${paddedMinutes}:00`; }; const timeInputToTimeSpanSeconds = (timeInput: string): string => { if (!timeInput) return "00:00:00"; - - const [hours = 0, minutes = 0, seconds = 0] = timeInput - .split(":") - .map((p) => parseInt(p) || 0); - - const paddedHours = hours.toString().padStart(2, "0"); - const paddedMinutes = minutes.toString().padStart(2, "0"); - const paddedSeconds = seconds.toString().padStart(2, "0"); - + + const [hours = 0, minutes = 0, seconds = 0] = timeInput.split(':').map(p => parseInt(p) || 0); + + const paddedHours = hours.toString().padStart(2, '0'); + const paddedMinutes = minutes.toString().padStart(2, '0'); + const paddedSeconds = seconds.toString().padStart(2, '0'); + return `${paddedHours}:${paddedMinutes}:${paddedSeconds}`; }; // Sortable Language Badge Component -function SortableLanguageBadge({ - language, - onRemove, -}: { - language: string; - onRemove: (language: string) => void; -}) { +function SortableLanguageBadge({ language, onRemove }: { language: string; onRemove: (language: string) => void }) { const { attributes, listeners, @@ -145,27 +123,35 @@ function SortableLanguageBadge({ const countryCode = getCountryCodeForLanguage(language); return ( -
+
-
- +
+
{language} -
+ {localSettings.categorizedFolders && ( +
+
+ +

+ Define categories for organizing series. Category will be selectable when adding series. +

- )} +
+ {(localSettings.categories || []).map((category) => ( + + {category} + removeCategory(category)} + /> + + ))} +
+
+ setNewCategory(e.target.value)} + className="flex-1" + /> + +
+
+ )} ); } // FlareSolverr Section -function FlareSolverrSection({ - localSettings, - setLocalSettings, -}: { - localSettings: Settings; - setLocalSettings: (updater: (prev: Settings) => Settings) => void; +function FlareSolverrSection({ + localSettings, + setLocalSettings +}: { + localSettings: Settings; + setLocalSettings: (updater: (prev: Settings) => Settings) => void }) { return ( @@ -778,29 +645,25 @@ function FlareSolverrSection({ - setLocalSettings((prev) => ({ - ...prev, - flareSolverrEnabled: checked, - })) - } + onCheckedChange={(checked) => setLocalSettings(prev => ({ + ...prev, + flareSolverrEnabled: checked + }))} />
- + {localSettings.flareSolverrEnabled && ( -
+
- setLocalSettings((prev) => ({ - ...prev, - flareSolverrUrl: e.target.value, - })) - } + onChange={(e) => setLocalSettings(prev => ({ + ...prev, + flareSolverrUrl: e.target.value + }))} placeholder="http://localhost:8191" />
@@ -809,23 +672,17 @@ function FlareSolverrSection({ - setLocalSettings((prev) => ({ - ...prev, - flareSolverrTimeout: timeInputToTimeSpanSeconds( - e.target.value, - ), - })) - } + value={timeSpanToTimeInputSeconds(localSettings.flareSolverrTimeout)} + onChange={(e) => setLocalSettings(prev => ({ + ...prev, + flareSolverrTimeout: timeInputToTimeSpanSeconds(e.target.value) + }))} /> -

+

Request timeout for FlareSolverr operations

@@ -834,37 +691,31 @@ function FlareSolverrSection({ - setLocalSettings((prev) => ({ - ...prev, - flareSolverrSessionTtl: timeInputToTimeSpan(e.target.value), - })) - } + onChange={(e) => setLocalSettings(prev => ({ + ...prev, + flareSolverrSessionTtl: timeInputToTimeSpan(e.target.value) + }))} /> -

+

How long FlareSolverr sessions should remain active

- +
- setLocalSettings((prev) => ({ - ...prev, - flareSolverrAsResponseFallback: checked, - })) - } + onCheckedChange={(checked) => setLocalSettings(prev => ({ + ...prev, + flareSolverrAsResponseFallback: checked + }))} /> - +
)} @@ -875,39 +726,45 @@ function FlareSolverrSection({ // Available settings sections const AVAILABLE_SECTIONS: SettingsSection[] = [ { - id: "content-preferences", - title: "Content Preferences", - description: "Configure your preferred languages and content filters.", + id: 'content-preferences', + title: 'Content Preferences', + description: 'Select your preferred languages.', component: ContentPreferencesSection, }, { - id: "mihon-repositories", - title: "Mihon Repositories", - description: "Configure external repositories for additional sources.", + id: 'mihon-repositories', + title: 'Mihon Repositories', + description: 'Configure external repositories for additional sources.', component: MihonRepositoriesSection, }, { - id: "download-settings", - title: "Download Settings", - description: "Configure download behavior and limits.", + id: 'download-settings', + title: 'Download Settings', + description: 'Configure download behavior and limits.', component: DownloadSettingsSection, }, { - id: "schedule-tasks", - title: "Schedule Tasks", - description: "Configure automatic update schedules and timings.", + id: 'schedule-tasks', + title: 'Schedule Tasks', + description: 'Configure automatic update schedules and timings.', component: ScheduleTasksSection, }, { - id: "storage", - title: "Storage", - description: "Configure how archives are stored and organized.", + id: 'storage', + title: 'Storage', + description: 'Configure how archives are stored and organized.', component: StorageSection, }, { - id: "flaresolverr", - title: "FlareSolverr Settings", - description: "Configure FlareSolverr for bypassing Cloudflare protection.", + id: 'naming-format', + title: 'Naming & Format', + description: 'Configure file naming templates and output format.', + component: NamingFormatSection, + }, + { + id: 'flaresolverr', + title: 'FlareSolverr Settings', + description: 'Configure FlareSolverr for bypassing Cloudflare protection.', component: FlareSolverrSection, }, ]; @@ -945,11 +802,8 @@ export function SettingsManager({ onSettingsChange, useLocalState = false, initialSettings, - className = "", -}: SettingsManagerProps) { - const [localSettings, setLocalSettings] = useState( - initialSettings ?? null, - ); + className = "" +}: SettingsManagerProps) { const [localSettings, setLocalSettings] = useState(initialSettings ?? null); const { toast } = useToast(); const isInitialMount = React.useRef(true); @@ -959,20 +813,16 @@ export function SettingsManager({ }); // Determine if we should fetch settings from the server - const shouldFetchSettings = - !useLocalState || (useLocalState && !initialSettings); - + const shouldFetchSettings = !useLocalState || (useLocalState && !initialSettings); + // Always call the hook, but conditionally use the data const { data: settings, isLoading: settingsLoading } = useSettings(); const updateSettingsMutation = useUpdateSettings(); // Memoize settings update handler - const handleSettingsUpdate = React.useCallback( - (updater: (prev: Settings) => Settings) => { - setLocalSettings((prev) => (prev ? updater(prev) : prev)); - }, - [], - ); + const handleSettingsUpdate = React.useCallback((updater: (prev: Settings) => Settings) => { + setLocalSettings(prev => prev ? updater(prev) : prev); + }, []); // Initialize local settings when data is loaded React.useEffect(() => { if (settings && shouldFetchSettings) { @@ -980,7 +830,7 @@ export function SettingsManager({ setLocalSettings(settings); } else if (useLocalState && !initialSettings) { // In local state mode, use fetched settings as fallback if no initial settings provided - setLocalSettings((prev) => prev ?? settings); + setLocalSettings(prev => prev ?? settings); } } }, [settings, useLocalState, initialSettings, shouldFetchSettings]); @@ -990,11 +840,11 @@ export function SettingsManager({ isInitialMount.current = false; return; } - + if (localSettings && onSettingsChangeRef.current) { onSettingsChangeRef.current(localSettings); } - }, [localSettings]); // Show loading state while settings are being fetched (only in server state mode) + }, [localSettings]); // Show loading state while settings are being fetched (only in server state mode) if (!useLocalState && (settingsLoading || !localSettings)) { return (
@@ -1034,7 +884,7 @@ export function SettingsManager({ const handleSave = async () => { if (!localSettings) return; - + try { if (onSave) { onSave(localSettings); @@ -1051,12 +901,11 @@ export function SettingsManager({ description: "Failed to save settings", variant: "destructive", }); - } - }; + } }; // Filter sections based on props - const sectionsToShow = sections - ? AVAILABLE_SECTIONS.filter((section) => sections.includes(section.id)) + const sectionsToShow = sections + ? AVAILABLE_SECTIONS.filter(section => sections.includes(section.id)) : AVAILABLE_SECTIONS; return ( @@ -1067,10 +916,7 @@ export function SettingsManager({

{description}

{showSaveButton && ( - + + + + Rename All Files? + + This will rename all existing downloaded files to match your current naming scheme. + Make sure you have saved your settings first. This operation cannot be undone. + + + + + + + +
+
+ + ); +} diff --git a/KaizokuFrontend/src/components/kzk/setup-wizard/index.tsx b/KaizokuFrontend/src/components/kzk/setup-wizard/index.tsx index f5878ed..8ed0d37 100644 --- a/KaizokuFrontend/src/components/kzk/setup-wizard/index.tsx +++ b/KaizokuFrontend/src/components/kzk/setup-wizard/index.tsx @@ -61,7 +61,7 @@ export function SetupWizard() { return ( { /* Prevent closing */ }} modal> e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()} > @@ -69,7 +69,7 @@ export function SetupWizard() { Configure your Kaizoku.NET installation by following these steps to set up preferences, add sources, and import existing series. -
+
-
+
Step {currentStep + 1} of {totalSteps}
- )} {/* Show Skip button when enabled */} + )} + {/* Show Skip button when enabled */} {showSkipButton && ( - )} {/* Show Add button when enabled and there is at least one series */} - {showAddButton && ( + )} + {/* Show Add button when enabled and there is at least one series */} + {showAddButton && ( )} {/* Conditionally show Action combobox */} {showActionCombobox && (
- Action: + Action:
{actionSelect}
@@ -631,15 +647,17 @@ const ImportCard = React.memo(function ImportCard({ import: importItem, isUpdati
- -
{/* Available Providers */} + +
+ {/* Available Providers */} {importItem.series && importItem.series.length > 0 && (
- {/* Grid layout for provider cards */} -
{importItem.series.map((series: SmallSeries, index: number) => ( + {/* Grid layout for provider cards - responsive */} +
+ {importItem.series.map((series: SmallSeries, index: number) => ( {
{/* Switch controls */} -
+
e.stopPropagation()} > - - Permanent + + Perm @@ -819,10 +837,10 @@ const SeriesCard = React.memo((props: SeriesCardProps) => { />
e.stopPropagation()} > - + Cover { />
e.stopPropagation()} > - + Title { + return () => { + Object.keys(debounceTimeoutsRef.current).forEach(path => { + clearTimeout(debounceTimeoutsRef.current[path]); + delete debounceTimeoutsRef.current[path]; + }); + // Flush the current global state to backend + globalImports.forEach(importItem => { + updateMutation.mutate(importItem); + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Fetch imports on mount useEffect(() => { refetch().catch((error) => { @@ -1088,10 +1121,25 @@ export function ConfirmImportsStep({ setError, setIsLoading, setCanProgress }: C if (globalImports.length === 0) { return ( -
+
- Loading Series + No series found to import.
+
+ Please go back to the previous step and select a folder containing manga/comic files, or check that your library path is correctly configured. +
+
); } @@ -1107,23 +1155,23 @@ export function ConfirmImportsStep({ setError, setIsLoading, setCanProgress }: C
-
- - - - Add ({importsToProcess.length}) +
+ + + + Add ({importsToProcess.length}) - - - Finished ({completedImports.length}) + + + Finished ({completedImports.length}) - - - Already Imported ({unchangedImports.length}) + + + Already ImportedImported ({unchangedImports.length}) - - - Not Matched or Mismatched ({skippedImports.length}) + + + Not Matched or MismatchedSkipped ({skippedImports.length})
diff --git a/KaizokuFrontend/src/components/kzk/setup-wizard/steps/import-local-step.tsx b/KaizokuFrontend/src/components/kzk/setup-wizard/steps/import-local-step.tsx index eb6ec75..547f07f 100644 --- a/KaizokuFrontend/src/components/kzk/setup-wizard/steps/import-local-step.tsx +++ b/KaizokuFrontend/src/components/kzk/setup-wizard/steps/import-local-step.tsx @@ -10,7 +10,8 @@ import { useSetupWizardSearchSeries, useSignalRProgress } from "@/lib/api/hooks/useSetupWizard"; -import { JobType } from "@/lib/api/types"; +import { setupWizardService } from "@/lib/api/services/setupWizardService"; +import { JobType, QueueStatus } from "@/lib/api/types"; // Custom hook to detect if scrollbar is visible function useScrollbarDetection() { @@ -193,21 +194,88 @@ export function ImportLocalStep({ setError, setIsLoading, setCanProgress }: Impo }, ]; - // Auto-start the import process when component mounts (only once) + // Resume or start the import process when component mounts (only once) useEffect(() => { - if (!hasStartedRef.current && !scanMutation.isPending) { - hasStartedRef.current = true; - setError(null); - setCurrentActionIndex(0); - - // Start the scan process - scanMutation.mutateAsync().catch((error) => { - console.error('Scan failed:', error); - setError('Failed to start scan process'); - setCurrentActionIndex(-1); - hasStartedRef.current = false; // Reset on error to allow retry - }); - } + if (hasStartedRef.current) return; + hasStartedRef.current = true; + setError(null); + + const resumeOrStart = async () => { + try { + const status = await setupWizardService.getWizardJobStatus(); + + const scanStatus = status.scanLocalFiles; + const installStatus = status.installAdditionalExtensions; + const searchStatus = status.searchProviders; + + // Check from the latest step backwards to find where we are + if (searchStatus === QueueStatus.Completed) { + // All done — mark everything completed + completedJobsRef.current.add(JobType.ScanLocalFiles); + completedJobsRef.current.add(JobType.InstallAdditionalExtensions); + completedJobsRef.current.add(JobType.SearchProviders); + setAllActionsCompleted(true); + setCurrentActionIndex(-1); + return; + } + + if (searchStatus === QueueStatus.Running || searchStatus === QueueStatus.Waiting) { + // Search is in progress — mark scan+install as completed, wait for SignalR + completedJobsRef.current.add(JobType.ScanLocalFiles); + completedJobsRef.current.add(JobType.InstallAdditionalExtensions); + setCurrentActionIndex(2); + return; + } + + if (installStatus === QueueStatus.Completed) { + // Install done but search not started — trigger search + completedJobsRef.current.add(JobType.ScanLocalFiles); + completedJobsRef.current.add(JobType.InstallAdditionalExtensions); + setCurrentActionIndex(2); + await searchMutation.mutateAsync(); + return; + } + + if (installStatus === QueueStatus.Running || installStatus === QueueStatus.Waiting) { + // Install in progress — mark scan completed, wait for SignalR + completedJobsRef.current.add(JobType.ScanLocalFiles); + setCurrentActionIndex(1); + return; + } + + if (scanStatus === QueueStatus.Completed) { + // Scan done but install not started — trigger install + completedJobsRef.current.add(JobType.ScanLocalFiles); + setCurrentActionIndex(1); + await installMutation.mutateAsync(); + return; + } + + if (scanStatus === QueueStatus.Running || scanStatus === QueueStatus.Waiting) { + // Scan in progress — wait for SignalR + setCurrentActionIndex(0); + return; + } + + // Nothing running — start fresh scan + setCurrentActionIndex(0); + await scanMutation.mutateAsync(); + } catch (error) { + console.error('Failed to resume/start import process:', error); + // Fallback: try starting a fresh scan + try { + setCurrentActionIndex(0); + await scanMutation.mutateAsync(); + } catch (scanError) { + console.error('Scan failed:', scanError); + setError('Failed to start scan process'); + setCurrentActionIndex(-1); + hasStartedRef.current = false; + } + } + }; + + void resumeOrStart(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Empty dependency array - only run once on mount @@ -232,7 +300,7 @@ export function ImportLocalStep({ setError, setIsLoading, setCanProgress }: Impo {actions.map((action, index) => { const isActive = currentActionIndex === index; - const isCompleted = isJobCompleted(action.jobType); + const isCompleted: boolean = isJobCompleted(action.jobType) || completedJobsRef.current.has(action.jobType); const isFailed = isJobFailed(action.jobType); const progress = isCompleted ? 100 : getJobProgress(action.jobType); const progressData = getProgressForJob(action.jobType); diff --git a/KaizokuFrontend/src/components/kzk/setup-wizard/steps/preferences-step.tsx b/KaizokuFrontend/src/components/kzk/setup-wizard/steps/preferences-step.tsx index ca31669..5236b4c 100644 --- a/KaizokuFrontend/src/components/kzk/setup-wizard/steps/preferences-step.tsx +++ b/KaizokuFrontend/src/components/kzk/setup-wizard/steps/preferences-step.tsx @@ -79,13 +79,13 @@ export function PreferencesStep({ setStepData(0, settings); // Note: Settings will be actually saved to backend when next step is triggered }; return ( -
+
- Configure your content preferences, download settings, and other preferences. + Configure your content preferences, download settings, and other preferences. These settings can be changed later in the Settings page. -
; + lastUpdated?: number; // Timestamp for state expiration } interface ImportWizardContextType { @@ -48,19 +49,36 @@ export function ImportWizardProvider({ children }: { children: React.ReactNode } if (saved) { try { const parsedState = JSON.parse(saved) as ImportWizardState; - setWizardState(parsedState); + + // Check if state is stale (older than 24 hours) + const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours + const isStale = parsedState.lastUpdated && + (Date.now() - parsedState.lastUpdated > STALE_THRESHOLD_MS); + + if (isStale) { + // Clear stale state + localStorage.removeItem(IMPORT_WIZARD_STORAGE_KEY); + console.log('Cleared stale import wizard state'); + } else { + setWizardState(parsedState); + } } catch { - // If parsing fails, keep default state + // If parsing fails, clear invalid state + localStorage.removeItem(IMPORT_WIZARD_STORAGE_KEY); } } }, []); // Save state to localStorage whenever it changes useEffect(() => { - if (typeof window !== 'undefined') { - localStorage.setItem(IMPORT_WIZARD_STORAGE_KEY, JSON.stringify(wizardState)); + if (typeof window !== 'undefined' && isClient) { + const stateWithTimestamp = { + ...wizardState, + lastUpdated: Date.now(), + }; + localStorage.setItem(IMPORT_WIZARD_STORAGE_KEY, JSON.stringify(stateWithTimestamp)); } - }, [wizardState]); + }, [wizardState, isClient]); const startWizard = useCallback(() => { setWizardState({ diff --git a/KaizokuFrontend/src/components/ui/dialog.tsx b/KaizokuFrontend/src/components/ui/dialog.tsx index 95b0d38..00029a1 100644 --- a/KaizokuFrontend/src/components/ui/dialog.tsx +++ b/KaizokuFrontend/src/components/ui/dialog.tsx @@ -38,13 +38,13 @@ const DialogContent = React.forwardRef< {children} - + Close diff --git a/KaizokuFrontend/src/lib/api/hooks/useDownloads.ts b/KaizokuFrontend/src/lib/api/hooks/useDownloads.ts index 7e55ad8..b89305c 100644 --- a/KaizokuFrontend/src/lib/api/hooks/useDownloads.ts +++ b/KaizokuFrontend/src/lib/api/hooks/useDownloads.ts @@ -270,7 +270,7 @@ export const useDownloadsMetrics = ( */ export const useManageErrorDownload = () => { const queryClient = useQueryClient(); - + return useMutation({ mutationFn: ({ id, action }: { id: string; action: ErrorDownloadAction }) => downloadsService.manageErrorDownload(id, action), @@ -282,3 +282,21 @@ export const useManageErrorDownload = () => { }, }); }; + +/** + * Hook to remove a scheduled download from the queue + * @returns Mutation for removing scheduled downloads + */ +export const useRemoveScheduledDownload = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => downloadsService.removeScheduledDownload(id), + onSuccess: () => { + // Invalidate waiting downloads queries + queryClient.invalidateQueries({ queryKey: ['downloads', 'waiting'] }); + queryClient.invalidateQueries({ queryKey: ['downloads', 'waiting-with-count'] }); + queryClient.invalidateQueries({ queryKey: ['downloads', 'metrics'] }); + }, + }); +}; diff --git a/KaizokuFrontend/src/lib/api/hooks/useSetupWizard.ts b/KaizokuFrontend/src/lib/api/hooks/useSetupWizard.ts index cac3581..75fbf38 100644 --- a/KaizokuFrontend/src/lib/api/hooks/useSetupWizard.ts +++ b/KaizokuFrontend/src/lib/api/hooks/useSetupWizard.ts @@ -105,6 +105,14 @@ export function useSetupWizardUpdateImport() { }); } +export function useSetupWizardJobStatus() { + return useQuery({ + queryKey: ['setup-wizard', 'job-status'], + queryFn: () => setupWizardService.getWizardJobStatus(), + enabled: false, // Only fetch when explicitly requested + }); +} + export function useSetupWizardLookupSeries() { return useMutation({ mutationFn: ({ keyword, searchSources }: { keyword: string; searchSources?: string[] }) => diff --git a/KaizokuFrontend/src/lib/api/services/downloadsService.ts b/KaizokuFrontend/src/lib/api/services/downloadsService.ts index 1682b0e..987bc35 100644 --- a/KaizokuFrontend/src/lib/api/services/downloadsService.ts +++ b/KaizokuFrontend/src/lib/api/services/downloadsService.ts @@ -121,7 +121,16 @@ export const downloadsService = { const params = new URLSearchParams(); params.append('id', id); params.append('action', action.toString()); - + return apiClient.patch(`/api/downloads?${params.toString()}`); }, + + /** + * Remove a scheduled download from the queue + * @param id - The ID of the download to remove + * @returns Promise resolving when the download is removed + */ + async removeScheduledDownload(id: string): Promise { + return apiClient.delete(`/api/downloads/${id}`); + }, }; diff --git a/KaizokuFrontend/src/lib/api/services/setupWizardService.ts b/KaizokuFrontend/src/lib/api/services/setupWizardService.ts index 579f4d8..8364331 100644 --- a/KaizokuFrontend/src/lib/api/services/setupWizardService.ts +++ b/KaizokuFrontend/src/lib/api/services/setupWizardService.ts @@ -1,5 +1,5 @@ import { apiClient } from '@/lib/api/client'; -import type { ImportInfo, LinkedSeries, SetupOperationResponse, ImportResponse, ImportTotals } from '../types'; +import type { ImportInfo, LinkedSeries, SetupOperationResponse, ImportResponse, ImportTotals, WizardJobStatus } from '../types'; export const setupWizardService = { /** @@ -65,6 +65,13 @@ export const setupWizardService = { await apiClient.post('/api/setup/update', importInfo); }, + /** + * Get current status of wizard-related jobs + */ + async getWizardJobStatus(): Promise { + return await apiClient.get('/api/setup/job-status'); + }, + /** * Look up series by keyword search (uses SearchController) */ diff --git a/KaizokuFrontend/src/lib/api/signalr/progressHub.ts b/KaizokuFrontend/src/lib/api/signalr/progressHub.ts index 183bead..ae0e6af 100644 --- a/KaizokuFrontend/src/lib/api/signalr/progressHub.ts +++ b/KaizokuFrontend/src/lib/api/signalr/progressHub.ts @@ -104,14 +104,38 @@ export class ProgressHub { const signalR = await this.loadSignalR(); if (!signalR || !this.connection) return; - // Handle all non-connected states - if (this.connection.state !== signalR.HubConnectionState.Connected) { + // Only start connection if in Disconnected state + // start() throws if called in Connecting, Connected, Disconnecting, or Reconnecting states + if (this.connection.state === signalR.HubConnectionState.Disconnected) { try { await this.connection.start(); } catch (err) { console.error('SignalR Connection Error:', err); throw err; } + } else if (this.connection.state === signalR.HubConnectionState.Connecting || + this.connection.state === signalR.HubConnectionState.Reconnecting) { + // Wait for connection to complete if currently connecting + await new Promise((resolve) => { + const checkState = setInterval(async () => { + const currentSignalR = await this.loadSignalR(); + if (!currentSignalR || !this.connection) { + clearInterval(checkState); + resolve(); + return; + } + if (this.connection.state === currentSignalR.HubConnectionState.Connected || + this.connection.state === currentSignalR.HubConnectionState.Disconnected) { + clearInterval(checkState); + resolve(); + } + }, 100); + // Timeout after 10 seconds + setTimeout(() => { + clearInterval(checkState); + resolve(); + }, 10000); + }); } } diff --git a/KaizokuFrontend/src/lib/api/types.ts b/KaizokuFrontend/src/lib/api/types.ts index 7b598ec..209150a 100644 --- a/KaizokuFrontend/src/lib/api/types.ts +++ b/KaizokuFrontend/src/lib/api/types.ts @@ -64,6 +64,11 @@ export interface Settings { // Setup Wizard properties isWizardSetupComplete: boolean; wizardSetupStepCompleted: number; + // Naming & Format properties + fileNameTemplate: string; + folderTemplate: string; + outputFormat: number; // 0=CBZ, 1=PDF + includeChapterTitle: boolean; } export interface LinkedSeries { @@ -257,6 +262,22 @@ export enum ProgressStatus { Failed = 3, } +// Wizard Job Status (from GET /api/setup/job-status) +export interface WizardJobStatus { + scanLocalFiles: number | null; + installAdditionalExtensions: number | null; + searchProviders: number | null; + importSeries: number | null; +} + +// QueueStatus enum matching backend values +export enum QueueStatus { + Waiting = 0, + Running = 1, + Completed = 2, + Failed = 3, +} + // Setup Wizard API Response Types export interface SetupOperationResponse { success: boolean; diff --git a/unraid/kaizoku.xml.example b/unraid/kaizoku.xml.example new file mode 100644 index 0000000..bd1e995 --- /dev/null +++ b/unraid/kaizoku.xml.example @@ -0,0 +1,47 @@ + + + Kaizoku.NET + ghcr.io/quickkill0/kaizoku.net:main + https://github.com/Quickkill0/Kaizoku.NET/pkgs/container/kaizoku.net + + main + Latest stable release from main branch + + bridge + + bash + false + https://github.com/Quickkill0/Kaizoku.NET/issues + https://github.com/Quickkill0/Kaizoku.NET + Kaizoku.NET is a manga management application that integrates with Suwayomi Server to download and organize manga from various sources. It provides a web interface for managing your manga library, automatic downloads, and organization features. + +Features: +- Automatic manga downloads from multiple sources via Suwayomi +- Series management and organization +- Job queue for background processing +- Web-based UI for easy management + +Ports: +- 9833: Kaizoku.NET Web UI +- 4567: Suwayomi Server (internal) + MediaApp:Other + http://[IP]:[PORT:9833] + https://raw.githubusercontent.com/Quickkill0/Kaizoku.NET/main/unraid/kaizoku.xml + https://raw.githubusercontent.com/Quickkill0/Kaizoku.NET/main/KaizokuFrontend/public/kaizoku.net.png + --restart unless-stopped --gpus all + + + + + + + 9833 + 4567 + /mnt/user/appdata/kaizoku + /mnt/user/data/media/manga + 99 + 100 + America/New_York + all + all +