diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f20901c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,61 @@
+## A streamlined .gitignore for modern .NET projects
+## including temporary files, build results, and
+## files generated by popular .NET tools. If you are
+## developing with Visual Studio, the VS .gitignore
+## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+## has more thorough IDE-specific entries.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+
+# dotenv environment variables file
+.env
+
+# Others
+~$*
+*~
+CodeCoverage/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Custom
+Properties/launchsettings.json
+*.user
\ No newline at end of file
diff --git a/Program.cs b/Program.cs
new file mode 100644
index 0000000..675566d
--- /dev/null
+++ b/Program.cs
@@ -0,0 +1,194 @@
+using System.Diagnostics;
+using System.Net;
+using PdfSharp.Drawing;
+using PdfSharp.Pdf;
+
+namespace WSiPBookDownloader;
+
+internal class BookMissingException : Exception
+{
+ public int BookId { get; }
+
+ public BookMissingException(int bookId)
+ : base($"Book with ID {bookId} does not exist.")
+ {
+ BookId = bookId;
+ }
+}
+
+internal class Program
+{
+ private static readonly HttpClient httpClient = new
+ (
+ new HttpClientHandler
+ {
+ AllowAutoRedirect = false,
+ }
+ )
+ {
+ BaseAddress = new Uri("https://appwsipnet.eduranga.pl/"),
+ };
+
+ private static readonly Lock _consoleLock = new();
+
+ private static void Log(string message, ConsoleColor? color = null)
+ {
+ lock (_consoleLock)
+ {
+ if (color.HasValue)
+ Console.ForegroundColor = color.Value;
+
+ Console.WriteLine(message);
+ Console.ResetColor();
+ }
+ }
+
+ static async Task Main(string[] args)
+ {
+ if (args.Length < 2 || args.Length > 4)
+ {
+ Log("Usage: [outputDirectory] [startBookId] [endBookId] [--only-covers]", ConsoleColor.DarkRed);
+ return;
+ }
+
+ var outputDirectory = args[0];
+ if (!Path.Exists(outputDirectory))
+ {
+ Log($"Output directory \"{outputDirectory}\" does not exist.", ConsoleColor.DarkRed);
+ return;
+ }
+
+ var startBookId = int.Parse(args[1]);
+ var endBookId = (args.Length >= 3 && int.TryParse(args[2], out var parsedEndBookId))
+ ? parsedEndBookId
+ : startBookId;
+ var onlyCovers = args.FirstOrDefault(arg => arg.Equals("--only-covers", StringComparison.OrdinalIgnoreCase)) != null;
+
+ var bookIds = Enumerable.Range(startBookId, endBookId - startBookId + 1);
+ var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = onlyCovers ? 100 : 10 };
+
+ await Parallel.ForEachAsync(bookIds, parallelOptions, async (bookId, cancellationToken) =>
+ {
+ var outputPath = Path.Combine(outputDirectory, $"{bookId}.pdf");
+ if (File.Exists(outputPath))
+ {
+ Log($"[!] Book with ID {bookId} already downloaded, skipping...", ConsoleColor.Yellow);
+ return;
+ }
+
+ Log($"[?] Starting download of book with ID {bookId}", ConsoleColor.Cyan);
+ var jpgDir = Path.Combine(Path.GetTempPath(), "WSiPBookDownloader", bookId.ToString());
+
+ try
+ {
+ if (onlyCovers)
+ {
+ await DownloadBookCoverJpg(bookId, outputDirectory, cancellationToken);
+ Log($"[✓] Downloaded book cover {bookId}", ConsoleColor.DarkGreen);
+ return;
+ }
+
+ await DownloadBookJpg(bookId, jpgDir, cancellationToken);
+ BuildPdf(bookId, jpgDir, outputPath);
+
+ Log($"[✓] Downloaded book {bookId}", ConsoleColor.DarkGreen);
+ }
+ catch (Exception ex)
+ {
+ Log($"[×] Failed to download book {bookId}:\n {ex.Message}", ConsoleColor.DarkRed);
+ } finally
+ {
+ if (Directory.Exists(jpgDir))
+ Directory.Delete(jpgDir, recursive: true);
+ }
+ });
+ }
+
+ private static async Task DownloadBookCoverJpg(int bookId, string outputDir, CancellationToken cancellationToken = default)
+ {
+ var outputPath = Path.Combine(outputDir, $"{bookId}.jpg");
+ if (File.Exists(outputPath))
+ {
+ Debug.WriteLine($"Cover for book {bookId} already exists, skipping...");
+ return;
+ }
+
+ var response = await httpClient.GetAsync($"e-podreczniki/podglad/{bookId}/files/mobile/1.jpg", cancellationToken);
+ if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Found)
+ throw new BookMissingException(bookId);
+
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ Directory.CreateDirectory(outputDir);
+ await File.WriteAllBytesAsync(outputPath, content, cancellationToken);
+
+ Debug.WriteLine($"Downloaded cover for book {bookId}");
+ }
+
+ private static async Task DownloadBookJpg(int bookId, string outputDir, CancellationToken cancellationToken = default)
+ {
+ var currentPage = 1;
+ while (true)
+ {
+ var outputPath = Path.Combine(outputDir, $"{currentPage}.jpg");
+ if (File.Exists(outputPath))
+ {
+ Debug.WriteLine($"Page {currentPage} of book {bookId} already exists, skipping...");
+
+ currentPage++;
+ continue;
+ }
+
+ var response = await httpClient.GetAsync($"e-podreczniki/podglad/{bookId}/files/mobile/{currentPage}.jpg", cancellationToken);
+ if (
+ (response.StatusCode == HttpStatusCode.Found || response.StatusCode == HttpStatusCode.NotFound) &&
+ currentPage > 1
+ )
+ {
+ Debug.WriteLine($"No more pages for book {bookId}, stopping...");
+ break;
+ } else if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.Found)
+ throw new BookMissingException(bookId);
+
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ Directory.CreateDirectory(outputDir);
+ await File.WriteAllBytesAsync(outputPath, content, cancellationToken);
+
+ Debug.WriteLine($"Downloaded page {currentPage} of book {bookId}");
+
+ currentPage++;
+ }
+ }
+
+ private static void BuildPdf(int bookId, string jpgDir, string outputPath)
+ {
+ var pages = Directory.GetFiles(jpgDir, "*.jpg")
+ .OrderBy(file => int.Parse(Path.GetFileNameWithoutExtension(file)))
+ .ToList();
+
+ using var document = new PdfDocument();
+ document.Options.FlateEncodeMode = PdfFlateEncodeMode.BestCompression;
+ document.Options.UseFlateDecoderForJpegImages = PdfUseFlateDecoderForJpegImages.Never;
+
+ foreach (var pagePath in pages)
+ {
+ using var stream = new MemoryStream(File.ReadAllBytes(pagePath));
+ using var image = XImage.FromStream(stream);
+
+ var pdfPage = document.AddPage();
+ pdfPage.Width = XUnit.FromPoint(image.PointWidth);
+ pdfPage.Height = XUnit.FromPoint(image.PointHeight);
+
+ using var gfx = XGraphics.FromPdfPage(pdfPage);
+ gfx.DrawImage(image, 0, 0, image.PointWidth, image.PointHeight);
+
+ Debug.WriteLine($"Added page {Path.GetFileNameWithoutExtension(pagePath)} to PDF for book {bookId}");
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
+ document.Save(outputPath);
+ }
+}
\ No newline at end of file
diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml
new file mode 100644
index 0000000..1407c55
--- /dev/null
+++ b/Properties/PublishProfiles/FolderProfile.pubxml
@@ -0,0 +1,16 @@
+
+
+
+
+ Release
+ Any CPU
+ bin\Release\net10.0\win-x64\publish\win-x64\
+ FileSystem
+ <_TargetId>Folder
+ net10.0
+ win-x64
+ true
+ false
+ false
+
+
\ No newline at end of file
diff --git a/WSiPBookDownloader.csproj b/WSiPBookDownloader.csproj
new file mode 100644
index 0000000..89383bb
--- /dev/null
+++ b/WSiPBookDownloader.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ net10.0
+ true
+ true
+ win-x64
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/WSiPBookDownloader.slnx b/WSiPBookDownloader.slnx
new file mode 100644
index 0000000..218b9ca
--- /dev/null
+++ b/WSiPBookDownloader.slnx
@@ -0,0 +1,3 @@
+
+
+