Project scaffolding

This commit is contained in:
2026-03-15 22:31:31 +02:00
parent 501528897d
commit cbd7f52535
106 changed files with 11222 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
@@ -0,0 +1,3 @@
global using Xunit;
global using FluentAssertions;
global using NSubstitute;
@@ -0,0 +1,70 @@
using PageManager.Api.Data.Models;
namespace PageManager.Api.Tests.Helpers;
public static class BookFactory
{
public static Book Create(
int id = 1,
string title = "Test Book",
int? year = 2024,
string? publisher = "Test Publisher",
int? pages = 300,
string? description = "A test book.",
string[] formats = null!,
string color = "#6366f1",
string[] genres = null!,
string? coverUrl = null,
string? isbn = null,
int? hardcoverId = null)
{
return new Book
{
Id = id,
Title = title,
Year = year,
Publisher = publisher,
Pages = pages,
Description = description,
Formats = formats ?? ["epub"],
Color = color,
Genres = genres ?? ["Fiction"],
CoverUrl = coverUrl,
Isbn = isbn,
HardcoverId = hardcoverId,
BookAuthors = [],
SeriesEntries = [],
};
}
public static Book WithAuthors(this Book book, params (int Id, string Name)[] authors)
{
foreach (var (id, name) in authors)
{
var author = new Author { Id = id, Name = name };
book.BookAuthors.Add(new BookAuthor
{
BookId = book.Id,
AuthorId = id,
Author = author,
Book = book,
});
}
return book;
}
public static Book WithSeries(this Book book, int seriesId = 1, string seriesName = "Test Series", double position = 1.0, string? arc = null)
{
var series = new Series { Id = seriesId, Name = seriesName };
book.SeriesEntries.Add(new SeriesEntry
{
SeriesId = seriesId,
BookId = book.Id,
Series = series,
Book = book,
Position = position,
Arc = arc,
});
return book;
}
}
@@ -0,0 +1,278 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PageManager.Api.Services;
using PageManager.Api.Tests.Integration.Fixtures;
namespace PageManager.Api.Tests.Integration;
[Collection("Postgres")]
public class BooksControllerTests : IAsyncLifetime
{
private readonly TestWebAppFactory _factory;
private readonly HttpClient _client;
public BooksControllerTests(PostgresFixture postgres)
{
_factory = new TestWebAppFactory(postgres);
_client = _factory.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.ExecuteSqlRawAsync(
"TRUNCATE book_authors, series_entries, books, authors, series RESTART IDENTITY CASCADE");
}
public Task DisposeAsync()
{
_client.Dispose();
_factory.Dispose();
return Task.CompletedTask;
}
// ── GET /api/books ────────────────────────────────────────────────────────
[Fact]
public async Task GetBooks_EmptyDb_Returns200WithEmptyArray()
{
var response = await _client.GetAsync("/api/books");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var books = await response.Content.ReadFromJsonAsync<BookDto[]>();
books.Should().BeEmpty();
}
[Fact]
public async Task GetBooks_SeededBooks_Returns200WithCorrectCountAndArrayFields()
{
await SeedBookAsync(title: "Dune", formats: ["epub", "mobi"], genres: ["Science Fiction"]);
await SeedBookAsync(title: "Foundation", formats: ["pdf"], genres: ["Science Fiction", "Classic"]);
var response = await _client.GetAsync("/api/books");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var books = await response.Content.ReadFromJsonAsync<BookDto[]>();
books.Should().HaveCount(2);
var dune = books!.Single(b => b.Title == "Dune");
dune.Formats.Should().BeEquivalentTo(new[] { "epub", "mobi" });
dune.Genres.Should().BeEquivalentTo(new[] { "Science Fiction" });
var foundation = books.Single(b => b.Title == "Foundation");
foundation.Formats.Should().BeEquivalentTo(new[] { "pdf" });
}
// ── GET /api/books/{id} ───────────────────────────────────────────────────
[Fact]
public async Task GetBook_Exists_Returns200WithDtoAndAuthors()
{
var id = await SeedBookAsync(title: "The Name of the Wind", authorName: "Patrick Rothfuss");
var response = await _client.GetAsync($"/api/books/{id}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var book = await response.Content.ReadFromJsonAsync<BookDto>();
book.Should().NotBeNull();
book!.Title.Should().Be("The Name of the Wind");
book.Authors.Should().ContainSingle(a => a.Name == "Patrick Rothfuss");
}
[Fact]
public async Task GetBook_NotFound_Returns404()
{
var response = await _client.GetAsync("/api/books/99999");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
// ── PUT /api/books/{id} ───────────────────────────────────────────────────
[Fact]
public async Task UpdateBook_Exists_Returns200WithUpdatedFields()
{
var id = await SeedBookAsync(title: "Original Title");
var req = new UpdateBookRequest
{
Title = "Updated Title",
Year = 2025,
Publisher = "New Publisher",
Pages = 500,
Formats = ["epub", "mobi"],
Genres = ["Fantasy"],
Color = "#ff0000",
};
var response = await _client.PutAsJsonAsync($"/api/books/{id}", req);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var book = await response.Content.ReadFromJsonAsync<BookDto>();
book!.Title.Should().Be("Updated Title");
book.Year.Should().Be(2025);
book.Publisher.Should().Be("New Publisher");
}
[Fact]
public async Task UpdateBook_NotFound_Returns404()
{
var req = new UpdateBookRequest { Title = "Whatever" };
var response = await _client.PutAsJsonAsync("/api/books/99999", req);
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task UpdateBook_PersistsArrayFieldsToDb()
{
var id = await SeedBookAsync(title: "Array Test");
var req = new UpdateBookRequest
{
Title = "Array Test",
Formats = ["epub", "pdf", "mobi"],
Genres = ["Fantasy", "Adventure"],
Color = "#6366f1",
};
await _client.PutAsJsonAsync($"/api/books/{id}", req);
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var book = await db.Books.FindAsync(id);
book!.Formats.Should().BeEquivalentTo(["epub", "pdf", "mobi"]);
book.Genres.Should().BeEquivalentTo(["Fantasy", "Adventure"]);
}
// ── POST /api/books ───────────────────────────────────────────────────────
[Fact]
public async Task CreateBook_HardcoverReturnsDetails_Returns200WithCreatedBook()
{
var mockHardcover = Substitute.For<IHardcoverService>();
mockHardcover.GetBookDetailsAsync(999).Returns(new HardcoverBookDetails
{
Id = 999,
Title = "Dune",
Year = 1965,
Publisher = "Ace Books",
Pages = 412,
Authors = ["Frank Herbert"],
Genres = ["Science Fiction"],
Isbn = "9780441013593",
CoverColor = "#c4a35a",
});
using var client = _factory
.WithWebHostBuilder(b => b.ConfigureServices(services =>
{
services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService)));
services.AddScoped(_ => mockHardcover);
}))
.CreateClient();
var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 999 });
response.StatusCode.Should().Be(HttpStatusCode.OK);
var book = await response.Content.ReadFromJsonAsync<BookDto>();
book.Should().NotBeNull();
book!.Title.Should().Be("Dune");
book.HardcoverId.Should().Be(999);
book.Authors.Should().ContainSingle(a => a.Name == "Frank Herbert");
}
[Fact]
public async Task CreateBook_HardcoverReturnsNull_Returns404()
{
var mockHardcover = Substitute.For<IHardcoverService>();
mockHardcover.GetBookDetailsAsync(Arg.Any<int>()).Returns((HardcoverBookDetails?)null);
using var client = _factory
.WithWebHostBuilder(b => b.ConfigureServices(services =>
{
services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService)));
services.AddScoped(_ => mockHardcover);
}))
.CreateClient();
var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 0 });
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task CreateBook_HardcoverIdAlreadyInDb_Returns200WithExistingBook()
{
await SeedBookWithHardcoverIdAsync(hardcoverId: 777, title: "Already Here");
var mockHardcover = Substitute.For<IHardcoverService>();
using var client = _factory
.WithWebHostBuilder(b => b.ConfigureServices(services =>
{
services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService)));
services.AddScoped(_ => mockHardcover);
}))
.CreateClient();
var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 777 });
response.StatusCode.Should().Be(HttpStatusCode.OK);
var book = await response.Content.ReadFromJsonAsync<BookDto>();
book!.Title.Should().Be("Already Here");
await mockHardcover.DidNotReceive().GetBookDetailsAsync(Arg.Any<int>());
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<int> SeedBookAsync(
string title = "Test Book",
string[] formats = null!,
string[] genres = null!,
string? authorName = null)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var book = new Book
{
Title = title,
Formats = formats ?? [],
Genres = genres ?? [],
};
db.Books.Add(book);
if (authorName is not null)
{
var author = new Author { Name = authorName };
db.Authors.Add(author);
await db.SaveChangesAsync();
db.BookAuthors.Add(new BookAuthor
{
BookId = book.Id,
AuthorId = author.Id,
});
}
await db.SaveChangesAsync();
return book.Id;
}
private async Task SeedBookWithHardcoverIdAsync(int hardcoverId, string title)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Books.Add(new Book { Title = title, HardcoverId = hardcoverId, Formats = [], Genres = [] });
await db.SaveChangesAsync();
}
}
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data;
using Testcontainers.PostgreSql;
namespace PageManager.Api.Tests.Integration.Fixtures;
public class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(ConnectionString)
.UseSnakeCaseNamingConvention()
.Options;
await using var db = new AppDbContext(options);
await db.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}
[CollectionDefinition("Postgres")]
public class PostgresCollection : ICollectionFixture<PostgresFixture> { }
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using PageManager.Api.Data;
namespace PageManager.Api.Tests.Integration.Fixtures;
public class TestWebAppFactory(PostgresFixture postgres) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor is not null)
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(postgres.ConnectionString)
.UseSnakeCaseNamingConvention());
});
}
}
@@ -0,0 +1,158 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PageManager.Api.Tests.Integration.Fixtures;
namespace PageManager.Api.Tests.Integration;
[Collection("Postgres")]
public class ImportControllerTests : IAsyncLifetime
{
private readonly TestWebAppFactory _factory;
private readonly HttpClient _client;
public ImportControllerTests(PostgresFixture postgres)
{
_factory = new TestWebAppFactory(postgres);
_client = _factory.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions
.ExecuteSqlRawAsync(db.Database,
"TRUNCATE import_queue_items, import_sources RESTART IDENTITY CASCADE");
}
public Task DisposeAsync()
{
_client.Dispose();
_factory.Dispose();
return Task.CompletedTask;
}
// ── GET /api/sources ──────────────────────────────────────────────────────
[Fact]
public async Task GetSources_EmptyDb_Returns200WithEmptyArray()
{
var response = await _client.GetAsync("/api/sources");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var sources = await response.Content.ReadFromJsonAsync<ImportSourceDto[]>();
sources.Should().BeEmpty();
}
[Fact]
public async Task GetSources_SeededSource_ReturnsMappedDto()
{
await SeedSourceAsync(name: "My Library", type: ImportSourceType.Folder, path: "/books");
var response = await _client.GetAsync("/api/sources");
var sources = await response.Content.ReadFromJsonAsync<ImportSourceDto[]>();
sources.Should().HaveCount(1);
sources![0].Name.Should().Be("My Library");
sources[0].Type.Should().Be("folder");
sources[0].Enabled.Should().BeTrue();
}
// ── PATCH /api/sources/{id} ───────────────────────────────────────────────
[Fact]
public async Task UpdateSource_Found_Returns200WithUpdatedEnabled()
{
var id = await SeedSourceAsync();
var response = await _client.PatchAsJsonAsync($"/api/sources/{id}", new { enabled = false });
response.StatusCode.Should().Be(HttpStatusCode.OK);
var dto = await response.Content.ReadFromJsonAsync<ImportSourceDto>();
dto!.Enabled.Should().BeFalse();
}
[Fact]
public async Task UpdateSource_NotFound_Returns404()
{
var response = await _client.PatchAsJsonAsync("/api/sources/missing-id", new { enabled = false });
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
// ── GET /api/queue ────────────────────────────────────────────────────────
[Fact]
public async Task GetQueue_EmptyDb_Returns200WithEmptyArray()
{
var response = await _client.GetAsync("/api/queue");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var items = await response.Content.ReadFromJsonAsync<QueueItemDto[]>();
items.Should().BeEmpty();
}
// ── DELETE /api/queue/{id} ────────────────────────────────────────────────
[Fact]
public async Task RemoveQueueItem_Found_Returns204()
{
var id = await SeedQueueItemAsync();
var response = await _client.DeleteAsync($"/api/queue/{id}");
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
public async Task RemoveQueueItem_NotFound_Returns404()
{
var response = await _client.DeleteAsync("/api/queue/missing-id");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
// ── POST /api/queue/{id}/retry ────────────────────────────────────────────
[Fact]
public async Task RetryQueueItem_FailedItem_Returns204()
{
var id = await SeedQueueItemAsync(status: QueueItemStatus.Failed);
var response = await _client.PostAsync($"/api/queue/{id}/retry", null);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
public async Task RetryQueueItem_NonFailedItem_Returns404()
{
var id = await SeedQueueItemAsync(status: QueueItemStatus.Completed);
var response = await _client.PostAsync($"/api/queue/{id}/retry", null);
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<string> SeedSourceAsync(
string name = "Test Source",
ImportSourceType type = ImportSourceType.Folder,
string path = "/test")
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var source = new ImportSource { Name = name, Type = type, Path = path };
db.ImportSources.Add(source);
await db.SaveChangesAsync();
return source.Id;
}
private async Task<string> SeedQueueItemAsync(
string filename = "book.epub",
QueueItemStatus status = QueueItemStatus.Queued)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var item = new ImportQueueItem { Filename = filename, Status = status, Source = "https://example.com" };
db.ImportQueueItems.Add(item);
await db.SaveChangesAsync();
return item.Id;
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\PageManager.Api\PageManager.Api.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
<!-- Pin EF Core versions to match the main project (avoids version conflict from Testcontainers) -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,248 @@
using FluentAssertions;
using NSubstitute;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Repositories;
using PageManager.Api.Services;
using PageManager.Api.Tests.Helpers;
namespace PageManager.Api.Tests.Unit.Services;
public class BooksServiceTests
{
private readonly IBooksRepository _repo = Substitute.For<IBooksRepository>();
private readonly IHardcoverService _hardcover = Substitute.For<IHardcoverService>();
private readonly BooksService _sut;
public BooksServiceTests()
{
_sut = new BooksService(_repo, _hardcover);
}
// ── GetAllAsync ───────────────────────────────────────────────────────────
[Fact]
public async Task GetAllAsync_MultipleBooks_ReturnsMappedDtos()
{
var books = new[]
{
BookFactory.Create(id: 1, title: "Book One").WithAuthors((1, "Author A")),
BookFactory.Create(id: 2, title: "Book Two").WithAuthors((2, "Author B")),
};
_repo.GetAllAsync().Returns(books);
var result = (await _sut.GetAllAsync()).ToArray();
result.Should().HaveCount(2);
result[0].Title.Should().Be("Book One");
result[1].Title.Should().Be("Book Two");
}
[Fact]
public async Task GetAllAsync_EmptyRepo_ReturnsEmptyCollection()
{
_repo.GetAllAsync().Returns([]);
var result = await _sut.GetAllAsync();
result.Should().BeEmpty();
}
// ── GetByIdAsync ──────────────────────────────────────────────────────────
[Fact]
public async Task GetByIdAsync_BookExists_ReturnsDtoWithCorrectFields()
{
var book = BookFactory.Create(id: 42, title: "Found Book", year: 2020, publisher: "Pub", pages: 100)
.WithAuthors((7, "Jane Doe"))
.WithSeries(seriesName: "Great Series", position: 2.0, arc: "Part One");
_repo.GetByIdAsync(42).Returns(book);
var result = await _sut.GetByIdAsync(42);
result.Should().NotBeNull();
result!.Id.Should().Be(42);
result.Title.Should().Be("Found Book");
result.Year.Should().Be(2020);
result.Publisher.Should().Be("Pub");
result.Pages.Should().Be(100);
result.Authors.Should().ContainSingle(a => a.Id == 7 && a.Name == "Jane Doe");
result.Series.Should().NotBeNull();
result.Series!.Name.Should().Be("Great Series");
result.Series.Position.Should().Be(2.0);
result.Series.Arc.Should().Be("Part One");
}
[Fact]
public async Task GetByIdAsync_BookNotFound_ReturnsNull()
{
_repo.GetByIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null);
var result = await _sut.GetByIdAsync(99);
result.Should().BeNull();
}
// ── UpdateAsync ───────────────────────────────────────────────────────────
[Fact]
public async Task UpdateAsync_BookExists_AppliesAllFieldsAndSaves()
{
var book = BookFactory.Create(id: 5).WithAuthors((1, "Author"));
_repo.GetByIdAsync(5).Returns(book);
var req = new UpdateBookRequest
{
Title = "Updated Title",
Year = 2025,
Publisher = "New Publisher",
Pages = 999,
Description = "New description",
Formats = ["epub", "mobi"],
Color = "#ff0000",
Genres = ["Fantasy", "Adventure"],
CoverUrl = "https://example.com/cover.jpg",
Isbn = "978-0-00-000000-0",
HardcoverId = 123,
};
var result = await _sut.UpdateAsync(5, req);
result.Should().NotBeNull();
result!.Title.Should().Be("Updated Title");
result.Year.Should().Be(2025);
result.Publisher.Should().Be("New Publisher");
result.Pages.Should().Be(999);
result.Description.Should().Be("New description");
result.Formats.Should().BeEquivalentTo(["epub", "mobi"]);
result.Color.Should().Be("#ff0000");
result.Genres.Should().BeEquivalentTo(["Fantasy", "Adventure"]);
result.CoverUrl.Should().Be("https://example.com/cover.jpg");
result.Isbn.Should().Be("978-0-00-000000-0");
result.HardcoverId.Should().Be(123);
await _repo.Received(1).SaveAsync();
}
[Fact]
public async Task UpdateAsync_BookNotFound_ReturnsNullWithoutSaving()
{
_repo.GetByIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null);
var result = await _sut.UpdateAsync(99, new UpdateBookRequest());
result.Should().BeNull();
await _repo.DidNotReceive().SaveAsync();
}
// ── DTO mapping ───────────────────────────────────────────────────────────
[Fact]
public async Task GetByIdAsync_BookWithSeries_MapsSeriesNamePositionArc()
{
var book = BookFactory.Create(id: 1)
.WithAuthors((1, "Author"))
.WithSeries(seriesId: 10, seriesName: "The Stormlight Archive", position: 1.0, arc: "Book One");
_repo.GetByIdAsync(1).Returns(book);
var result = await _sut.GetByIdAsync(1);
result!.Series.Should().NotBeNull();
result.Series!.Name.Should().Be("The Stormlight Archive");
result.Series.Position.Should().Be(1.0);
result.Series.Arc.Should().Be("Book One");
}
[Fact]
public async Task GetByIdAsync_BookWithoutSeries_SeriesIsNull()
{
var book = BookFactory.Create(id: 2).WithAuthors((1, "Author"));
_repo.GetByIdAsync(2).Returns(book);
var result = await _sut.GetByIdAsync(2);
result!.Series.Should().BeNull();
}
[Fact]
public async Task GetByIdAsync_BookWithMultipleAuthors_MapsAllAuthors()
{
var book = BookFactory.Create(id: 3)
.WithAuthors((1, "Alice"), (2, "Bob"), (3, "Carol"));
_repo.GetByIdAsync(3).Returns(book);
var result = await _sut.GetByIdAsync(3);
result!.Authors.Should().HaveCount(3);
result.Authors.Select(a => a.Name).Should().BeEquivalentTo(["Alice", "Bob", "Carol"]);
}
// ── CreateFromHardcoverAsync ──────────────────────────────────────────────
[Fact]
public async Task CreateFromHardcoverAsync_AlreadyInDb_ReturnsExistingWithoutCallingHardcover()
{
var existing = BookFactory.Create(id: 7, title: "Existing").WithAuthors((1, "Author"));
_repo.FindByHardcoverIdAsync(42).Returns(existing);
var result = await _sut.CreateFromHardcoverAsync(42);
result.Should().NotBeNull();
result!.Id.Should().Be(7);
result.Title.Should().Be("Existing");
await _hardcover.DidNotReceive().GetBookDetailsAsync(Arg.Any<int>());
}
[Fact]
public async Task CreateFromHardcoverAsync_HardcoverReturnsNull_ReturnsNull()
{
_repo.FindByHardcoverIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null);
_hardcover.GetBookDetailsAsync(99).Returns((HardcoverBookDetails?)null);
var result = await _sut.CreateFromHardcoverAsync(99);
result.Should().BeNull();
await _repo.DidNotReceive().CreateBookAsync(
Arg.Any<PageManager.Api.Data.Models.Book>(),
Arg.Any<IReadOnlyList<string>>(),
Arg.Any<(string, double, string?)?> ());
}
[Fact]
public async Task CreateFromHardcoverAsync_Success_CreatesBookAndReturnsDto()
{
_repo.FindByHardcoverIdAsync(123).Returns((PageManager.Api.Data.Models.Book?)null);
var details = new HardcoverBookDetails
{
Id = 123,
Title = "Dune",
Year = 1965,
Publisher = "Ace Books",
Pages = 412,
Description = "A sci-fi classic.",
Authors = ["Frank Herbert"],
Genres = ["Science Fiction"],
Isbn = "9780441013593",
CoverUrl = "https://example.com/cover.jpg",
CoverColor = "#c4a35a",
Series = new HardcoverSeriesInfo { Name = "Dune Chronicles", Position = 1.0 },
};
_hardcover.GetBookDetailsAsync(123).Returns(details);
var createdBook = BookFactory.Create(id: 55, title: "Dune")
.WithAuthors((1, "Frank Herbert"))
.WithSeries(seriesName: "Dune Chronicles", position: 1.0);
_repo.CreateBookAsync(Arg.Any<PageManager.Api.Data.Models.Book>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<(string, double, string?)?>())
.Returns(createdBook);
var result = await _sut.CreateFromHardcoverAsync(123);
result.Should().NotBeNull();
result!.Title.Should().Be("Dune");
result.Series.Should().NotBeNull();
result.Series!.Name.Should().Be("Dune Chronicles");
await _repo.Received(1).CreateBookAsync(
Arg.Is<PageManager.Api.Data.Models.Book>(b => b.HardcoverId == 123 && b.Title == "Dune"),
Arg.Is<IReadOnlyList<string>>(a => a.Contains("Frank Herbert")),
Arg.Is<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles"));
}
}
@@ -0,0 +1,123 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PageManager.Api.Services;
namespace PageManager.Api.Tests.Unit.Services;
public class ImportServiceTests : IDisposable
{
private readonly AppDbContext _db;
private readonly ImportService _sut;
public ImportServiceTests()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new AppDbContext(options);
_sut = new ImportService(_db);
}
public void Dispose() => _db.Dispose();
// ── Sources ───────────────────────────────────────────────────────────────
[Fact]
public async Task GetSourcesAsync_ReturnsMappedDtos()
{
_db.ImportSources.Add(new ImportSource { Id = "s1", Name = "My Books", Type = ImportSourceType.Folder, Path = "/books", Enabled = true });
await _db.SaveChangesAsync();
var result = (await _sut.GetSourcesAsync()).ToArray();
result.Should().HaveCount(1);
result[0].Id.Should().Be("s1");
result[0].Name.Should().Be("My Books");
result[0].Type.Should().Be("folder");
result[0].Enabled.Should().BeTrue();
}
[Fact]
public async Task UpdateSourceAsync_Found_TogglesEnabled()
{
_db.ImportSources.Add(new ImportSource { Id = "s1", Name = "Lib", Type = ImportSourceType.Folder, Path = "/", Enabled = true });
await _db.SaveChangesAsync();
var result = await _sut.UpdateSourceAsync("s1", false);
result.Should().NotBeNull();
result!.Enabled.Should().BeFalse();
(await _db.ImportSources.FindAsync("s1"))!.Enabled.Should().BeFalse();
}
[Fact]
public async Task UpdateSourceAsync_NotFound_ReturnsNull()
{
var result = await _sut.UpdateSourceAsync("missing", true);
result.Should().BeNull();
}
// ── Queue ─────────────────────────────────────────────────────────────────
[Fact]
public async Task GetQueueAsync_ReturnsMappedDtos()
{
_db.ImportQueueItems.Add(new ImportQueueItem
{
Id = "q1", Filename = "book.epub", SizeBytes = 1024, DownloadedBytes = 512,
Status = QueueItemStatus.Downloading, Source = "https://example.com",
});
await _db.SaveChangesAsync();
var result = (await _sut.GetQueueAsync()).ToArray();
result.Should().HaveCount(1);
result[0].Id.Should().Be("q1");
result[0].Status.Should().Be("downloading");
}
[Fact]
public async Task RemoveQueueItemAsync_Found_RemovesAndReturnsTrue()
{
_db.ImportQueueItems.Add(new ImportQueueItem { Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Completed });
await _db.SaveChangesAsync();
var result = await _sut.RemoveQueueItemAsync("q1");
result.Should().BeTrue();
_db.ImportQueueItems.Should().BeEmpty();
}
[Fact]
public async Task RemoveQueueItemAsync_NotFound_ReturnsFalse()
{
(await _sut.RemoveQueueItemAsync("missing")).Should().BeFalse();
}
[Fact]
public async Task RetryQueueItemAsync_FailedItem_SetsStatusToQueued()
{
_db.ImportQueueItems.Add(new ImportQueueItem
{
Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Failed, Error = "timeout",
});
await _db.SaveChangesAsync();
var result = await _sut.RetryQueueItemAsync("q1");
result.Should().BeTrue();
var item = await _db.ImportQueueItems.FindAsync("q1");
item!.Status.Should().Be(QueueItemStatus.Queued);
item.Error.Should().BeNull();
}
[Fact]
public async Task RetryQueueItemAsync_NonFailedItem_ReturnsFalse()
{
_db.ImportQueueItems.Add(new ImportQueueItem { Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Completed });
await _db.SaveChangesAsync();
(await _sut.RetryQueueItemAsync("q1")).Should().BeFalse();
}
}
+7
View File
@@ -0,0 +1,7 @@
<Solution>
<Folder Name="/Solution Items/">
<File Path="compose.yaml" />
</Folder>
<Project Path="PageManager.Api/PageManager.Api.csproj" />
<Project Path="PageManager.Api.Tests/PageManager.Api.Tests.csproj" />
</Solution>
@@ -0,0 +1,7 @@
namespace PageManager.Api.Api.Dtos;
public class AuthorDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
@@ -0,0 +1,26 @@
namespace PageManager.Api.Api.Dtos;
public class BookSeriesDto
{
public string Name { get; set; } = string.Empty;
public double Position { get; set; }
public string? Arc { get; set; }
}
public class BookDto
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public int? Year { get; set; }
public string? Publisher { get; set; }
public int? Pages { get; set; }
public string? Description { get; set; }
public string[] Formats { get; set; } = [];
public string Color { get; set; } = "#6366f1";
public string[] Genres { get; set; } = [];
public AuthorDto[] Authors { get; set; } = [];
public BookSeriesDto? Series { get; set; }
public string? CoverUrl { get; set; }
public string? Isbn { get; set; }
public int? HardcoverId { get; set; }
}
@@ -0,0 +1,37 @@
namespace PageManager.Api.Api.Dtos;
public class HardcoverBookResult
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string[] Authors { get; set; } = [];
public int? Year { get; set; }
public string[] Genres { get; set; } = [];
}
public class HardcoverBookDetails
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public int? Pages { get; set; }
public int? Year { get; set; }
public string[] Authors { get; set; } = [];
public string[] Genres { get; set; } = [];
public string? Isbn { get; set; }
public string? Publisher { get; set; }
public string? CoverUrl { get; set; }
public string? CoverColor { get; set; }
public HardcoverSeriesInfo? Series { get; set; }
}
public class HardcoverSeriesInfo
{
public string Name { get; set; } = string.Empty;
public double Position { get; set; }
}
public class CreateBookFromHardcoverRequest
{
public int HardcoverId { get; set; }
}
@@ -0,0 +1,26 @@
namespace PageManager.Api.Api.Dtos;
public class ImportSourceDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public bool Enabled { get; set; }
}
public class UpdateSourceRequest
{
public bool Enabled { get; set; }
}
public class QueueItemDto
{
public string Id { get; set; } = string.Empty;
public string Filename { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public long DownloadedBytes { get; set; }
public string Status { get; set; } = string.Empty;
public string Source { get; set; } = string.Empty;
public string? Error { get; set; }
}
@@ -0,0 +1,16 @@
namespace PageManager.Api.Api.Dtos;
public class UpdateBookRequest
{
public string Title { get; set; } = string.Empty;
public int? Year { get; set; }
public string? Publisher { get; set; }
public int? Pages { get; set; }
public string? Description { get; set; }
public string[] Formats { get; set; } = [];
public string Color { get; set; } = "#6366f1";
public string[] Genres { get; set; } = [];
public string? CoverUrl { get; set; }
public string? Isbn { get; set; }
public int? HardcoverId { get; set; }
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Services;
namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BooksController(IBooksService books) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<BookDto>> GetBooks() =>
await books.GetAllAsync();
[HttpGet("{id}")]
public async Task<ActionResult<BookDto>> GetBook(int id)
{
var book = await books.GetByIdAsync(id);
return book is null ? NotFound() : Ok(book);
}
[HttpPost]
public async Task<ActionResult<BookDto>> CreateBook(CreateBookFromHardcoverRequest req)
{
var book = await books.CreateFromHardcoverAsync(req.HardcoverId);
return book is null ? NotFound() : Ok(book);
}
[HttpPut("{id}")]
public async Task<ActionResult<BookDto>> UpdateBook(int id, UpdateBookRequest req)
{
var book = await books.UpdateAsync(id, req);
return book is null ? NotFound() : Ok(book);
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Services;
namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class QueueController(IImportService import) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<QueueItemDto>> GetQueue() =>
await import.GetQueueAsync();
[HttpDelete("{id}")]
public async Task<IActionResult> RemoveItem(string id) =>
await import.RemoveQueueItemAsync(id) ? NoContent() : NotFound();
[HttpPost("{id}/retry")]
public async Task<IActionResult> RetryItem(string id) =>
await import.RetryQueueItemAsync(id) ? NoContent() : NotFound();
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Mvc;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Services;
namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SearchController(IHardcoverService hardcover) : ControllerBase
{
[HttpGet("books")]
public async Task<ActionResult<IEnumerable<HardcoverBookResult>>> SearchBooks([FromQuery] string q)
{
if (string.IsNullOrWhiteSpace(q))
return BadRequest("Query parameter 'q' is required.");
var results = await hardcover.SearchBooksAsync(q);
return Ok(results);
}
}
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Services;
namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SourcesController(IImportService import) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<ImportSourceDto>> GetSources() =>
await import.GetSourcesAsync();
[HttpPatch("{id}")]
public async Task<ActionResult<ImportSourceDto>> UpdateSource(string id, UpdateSourceRequest req)
{
var result = await import.UpdateSourceAsync(id, req.Enabled);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Book> Books => Set<Book>();
public DbSet<Author> Authors => Set<Author>();
public DbSet<Series> Series => Set<Series>();
public DbSet<BookAuthor> BookAuthors => Set<BookAuthor>();
public DbSet<SeriesEntry> SeriesEntries => Set<SeriesEntry>();
public DbSet<ImportSource> ImportSources => Set<ImportSource>();
public DbSet<ImportQueueItem> ImportQueueItems => Set<ImportQueueItem>();
protected override void OnModelCreating(ModelBuilder model)
{
// ── BookAuthor (composite PK) ────────────────────────────────────────
model.Entity<BookAuthor>(e =>
{
e.HasKey(ba => new { ba.BookId, ba.AuthorId });
e.HasOne(ba => ba.Book)
.WithMany(b => b.BookAuthors)
.HasForeignKey(ba => ba.BookId);
e.HasOne(ba => ba.Author)
.WithMany(a => a.BookAuthors)
.HasForeignKey(ba => ba.AuthorId);
e.Property(ba => ba.Role)
.HasConversion<string>();
});
// ── SeriesEntry (composite PK) ───────────────────────────────────────
model.Entity<SeriesEntry>(e =>
{
e.HasKey(se => new { se.SeriesId, se.BookId });
e.HasOne(se => se.Series)
.WithMany(s => s.Entries)
.HasForeignKey(se => se.SeriesId);
e.HasOne(se => se.Book)
.WithMany(b => b.SeriesEntries)
.HasForeignKey(se => se.BookId);
});
// ── Series ───────────────────────────────────────────────────────────
model.Entity<Series>(e =>
{
e.Property(s => s.Type)
.HasConversion<string>();
});
// ── Book ─────────────────────────────────────────────────────────────
model.Entity<Book>(e =>
{
e.Property(b => b.Title).IsRequired();
// Formats and Genres are stored as native PostgreSQL text[] columns.
e.Property(b => b.Formats).HasColumnType("text[]");
e.Property(b => b.Genres).HasColumnType("text[]");
e.HasIndex(b => b.HardcoverId);
e.HasIndex(b => b.Isbn);
});
// ── Author ───────────────────────────────────────────────────────────
model.Entity<Author>(e =>
{
e.Property(a => a.Name).IsRequired();
});
// ── ImportSource ──────────────────────────────────────────────────────
model.Entity<ImportSource>(e =>
{
e.Property(s => s.Id).ValueGeneratedNever();
e.Property(s => s.Type).HasConversion<string>();
});
// ── ImportQueueItem ───────────────────────────────────────────────────
model.Entity<ImportQueueItem>(e =>
{
e.Property(i => i.Id).ValueGeneratedNever();
e.Property(i => i.Status).HasConversion<string>();
});
}
}
@@ -0,0 +1,9 @@
namespace PageManager.Api.Data.Models;
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<BookAuthor> BookAuthors { get; set; } = [];
}
@@ -0,0 +1,30 @@
namespace PageManager.Api.Data.Models;
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public int? Year { get; set; }
public string? Publisher { get; set; }
public int? Pages { get; set; }
public string? Description { get; set; }
/// <summary>Hex color used as cover placeholder when CoverUrl is absent.</summary>
public string Color { get; set; } = "#6366f1";
public string? CoverUrl { get; set; }
/// <summary>ISBN-13 sourced from Hardcover.</summary>
public string? Isbn { get; set; }
/// <summary>Hardcover book ID — used to re-fetch metadata without a search round-trip.</summary>
public int? HardcoverId { get; set; }
/// <summary>File formats present in the library (e.g. epub, mobi, pdf). Stored as text[].</summary>
public string[] Formats { get; set; } = [];
/// <summary>Genre tags sourced from Hardcover cached_tags. Stored as text[].</summary>
public string[] Genres { get; set; } = [];
public ICollection<BookAuthor> BookAuthors { get; set; } = [];
public ICollection<SeriesEntry> SeriesEntries { get; set; } = [];
}
@@ -0,0 +1,14 @@
namespace PageManager.Api.Data.Models;
public enum AuthorRole { Author, Editor }
public class BookAuthor
{
public int BookId { get; set; }
public Book Book { get; set; } = null!;
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
public AuthorRole Role { get; set; } = AuthorRole.Author;
}
@@ -0,0 +1,14 @@
namespace PageManager.Api.Data.Models;
public enum QueueItemStatus { Queued, Downloading, Completed, Failed }
public class ImportQueueItem
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Filename { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public long DownloadedBytes { get; set; }
public QueueItemStatus Status { get; set; } = QueueItemStatus.Queued;
public string Source { get; set; } = string.Empty;
public string? Error { get; set; }
}
@@ -0,0 +1,12 @@
namespace PageManager.Api.Data.Models;
public enum ImportSourceType { Folder, Calibre, Opds, Url }
public class ImportSource
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Name { get; set; } = string.Empty;
public ImportSourceType Type { get; set; } = ImportSourceType.Folder;
public string Path { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
}
@@ -0,0 +1,12 @@
namespace PageManager.Api.Data.Models;
public enum SeriesType { SingleAuthor, MultiAuthor }
public class Series
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public SeriesType Type { get; set; } = SeriesType.SingleAuthor;
public ICollection<SeriesEntry> Entries { get; set; } = [];
}
@@ -0,0 +1,18 @@
namespace PageManager.Api.Data.Models;
public class SeriesEntry
{
public int SeriesId { get; set; }
public Series Series { get; set; } = null!;
public int BookId { get; set; }
public Book Book { get; set; } = null!;
/// <summary>
/// Fractional position allows inserting novellas between whole-number entries (e.g. 1.5).
/// </summary>
public double Position { get; set; }
/// <summary>Sub-arc name within the series (e.g. "The Way of Kings Prime").</summary>
public string? Arc { get; set; }
}
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
public class BooksRepository(AppDbContext db) : IBooksRepository
{
public Task<IEnumerable<Book>> GetAllAsync() =>
db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.ToListAsync()
.ContinueWith(t => (IEnumerable<Book>)t.Result);
public Task<Book?> GetByIdAsync(int id) =>
db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.FirstOrDefaultAsync(b => b.Id == id);
public Task<Book?> FindByHardcoverIdAsync(int hardcoverId) =>
db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.FirstOrDefaultAsync(b => b.HardcoverId == hardcoverId);
public async Task<Book> CreateBookAsync(
Book book,
IReadOnlyList<string> authorNames,
(string name, double position, string? arc)? series)
{
db.Books.Add(book);
await db.SaveChangesAsync();
// Resolve / create authors
var authors = new List<Author>();
foreach (var name in authorNames)
{
var author = await db.Authors.FirstOrDefaultAsync(a => a.Name == name)
?? new Author { Name = name };
if (author.Id == 0) db.Authors.Add(author);
authors.Add(author);
}
if (authors.Any(a => a.Id == 0)) await db.SaveChangesAsync();
foreach (var author in authors)
db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id });
// Resolve / create series
if (series is { } si)
{
var s = await db.Series.FirstOrDefaultAsync(x => x.Name == si.name)
?? new Series { Name = si.name };
if (s.Id == 0) db.Series.Add(s);
if (s.Id == 0) await db.SaveChangesAsync();
db.SeriesEntries.Add(new SeriesEntry
{
SeriesId = s.Id,
BookId = book.Id,
Position = si.position,
Arc = si.arc,
});
}
await db.SaveChangesAsync();
return await db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.FirstAsync(b => b.Id == book.Id);
}
public Task SaveAsync() => db.SaveChangesAsync();
}
@@ -0,0 +1,15 @@
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
public interface IBooksRepository
{
Task<IEnumerable<Book>> GetAllAsync();
Task<Book?> GetByIdAsync(int id);
Task<Book?> FindByHardcoverIdAsync(int hardcoverId);
Task<Book> CreateBookAsync(
Book book,
IReadOnlyList<string> authorNames,
(string name, double position, string? arc)? series);
Task SaveAsync();
}
@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["PageManager.Api/PageManager.Api.csproj", "PageManager.Api/"]
RUN dotnet restore "PageManager.Api/PageManager.Api.csproj"
COPY . .
WORKDIR "/src/PageManager.Api"
RUN dotnet build "./PageManager.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./PageManager.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PageManager.Api.dll"]
@@ -0,0 +1,253 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using PageManager.Api.Data;
#nullable disable
namespace PageManager.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260315170137_InitialMigration")]
partial class InitialMigration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("PageManager.Api.Data.Models.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_authors");
b.ToTable("authors", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Book", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Color")
.IsRequired()
.HasColumnType("text")
.HasColumnName("color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.PrimitiveCollection<string[]>("Formats")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("formats");
b.PrimitiveCollection<string[]>("Genres")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("genres");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<int?>("Year")
.HasColumnType("integer")
.HasColumnName("year");
b.HasKey("Id")
.HasName("pk_books");
b.HasIndex("HardcoverId")
.HasDatabaseName("ix_books_hardcover_id");
b.HasIndex("Isbn")
.HasDatabaseName("ix_books_isbn");
b.ToTable("books", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int>("AuthorId")
.HasColumnType("integer")
.HasColumnName("author_id");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text")
.HasColumnName("role");
b.HasKey("BookId", "AuthorId")
.HasName("pk_book_authors");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_book_authors_author_id");
b.ToTable("book_authors", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_series");
b.ToTable("series", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("integer")
.HasColumnName("series_id");
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Arc")
.HasColumnType("text")
.HasColumnName("arc");
b.Property<double>("Position")
.HasColumnType("double precision")
.HasColumnName("position");
b.HasKey("SeriesId", "BookId")
.HasName("pk_series_entries");
b.HasIndex("BookId")
.HasDatabaseName("ix_series_entries_book_id");
b.ToTable("series_entries", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b =>
{
b.HasOne("PageManager.Api.Data.Models.Author", "Author")
.WithMany("BookAuthors")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_book_authors_authors_author_id");
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("BookAuthors")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_book_authors_books_book_id");
b.Navigation("Author");
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("SeriesEntries")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_series_entries_books_book_id");
b.HasOne("PageManager.Api.Data.Models.Series", "Series")
.WithMany("Entries")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_series_entries_series_series_id");
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Author", b =>
{
b.Navigation("BookAuthors");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Book", b =>
{
b.Navigation("BookAuthors");
b.Navigation("SeriesEntries");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Navigation("Entries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,155 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace PageManager.Api.Migrations
{
/// <inheritdoc />
public partial class InitialMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "authors",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_authors", x => x.id);
});
migrationBuilder.CreateTable(
name: "books",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
title = table.Column<string>(type: "text", nullable: false),
year = table.Column<int>(type: "integer", nullable: true),
publisher = table.Column<string>(type: "text", nullable: true),
pages = table.Column<int>(type: "integer", nullable: true),
description = table.Column<string>(type: "text", nullable: true),
color = table.Column<string>(type: "text", nullable: false),
cover_url = table.Column<string>(type: "text", nullable: true),
isbn = table.Column<string>(type: "text", nullable: true),
hardcover_id = table.Column<int>(type: "integer", nullable: true),
formats = table.Column<string[]>(type: "text[]", nullable: false),
genres = table.Column<string[]>(type: "text[]", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_books", x => x.id);
});
migrationBuilder.CreateTable(
name: "series",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "text", nullable: false),
type = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_series", x => x.id);
});
migrationBuilder.CreateTable(
name: "book_authors",
columns: table => new
{
book_id = table.Column<int>(type: "integer", nullable: false),
author_id = table.Column<int>(type: "integer", nullable: false),
role = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_book_authors", x => new { x.book_id, x.author_id });
table.ForeignKey(
name: "fk_book_authors_authors_author_id",
column: x => x.author_id,
principalTable: "authors",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_book_authors_books_book_id",
column: x => x.book_id,
principalTable: "books",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "series_entries",
columns: table => new
{
series_id = table.Column<int>(type: "integer", nullable: false),
book_id = table.Column<int>(type: "integer", nullable: false),
position = table.Column<double>(type: "double precision", nullable: false),
arc = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_series_entries", x => new { x.series_id, x.book_id });
table.ForeignKey(
name: "fk_series_entries_books_book_id",
column: x => x.book_id,
principalTable: "books",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_series_entries_series_series_id",
column: x => x.series_id,
principalTable: "series",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_book_authors_author_id",
table: "book_authors",
column: "author_id");
migrationBuilder.CreateIndex(
name: "ix_books_hardcover_id",
table: "books",
column: "hardcover_id");
migrationBuilder.CreateIndex(
name: "ix_books_isbn",
table: "books",
column: "isbn");
migrationBuilder.CreateIndex(
name: "ix_series_entries_book_id",
table: "series_entries",
column: "book_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "book_authors");
migrationBuilder.DropTable(
name: "series_entries");
migrationBuilder.DropTable(
name: "authors");
migrationBuilder.DropTable(
name: "books");
migrationBuilder.DropTable(
name: "series");
}
}
}
@@ -0,0 +1,253 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using PageManager.Api.Data;
#nullable disable
namespace PageManager.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260315184420_AddImportTables")]
partial class AddImportTables
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("PageManager.Api.Data.Models.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_authors");
b.ToTable("authors", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Book", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Color")
.IsRequired()
.HasColumnType("text")
.HasColumnName("color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.PrimitiveCollection<string[]>("Formats")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("formats");
b.PrimitiveCollection<string[]>("Genres")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("genres");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<int?>("Year")
.HasColumnType("integer")
.HasColumnName("year");
b.HasKey("Id")
.HasName("pk_books");
b.HasIndex("HardcoverId")
.HasDatabaseName("ix_books_hardcover_id");
b.HasIndex("Isbn")
.HasDatabaseName("ix_books_isbn");
b.ToTable("books", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int>("AuthorId")
.HasColumnType("integer")
.HasColumnName("author_id");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text")
.HasColumnName("role");
b.HasKey("BookId", "AuthorId")
.HasName("pk_book_authors");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_book_authors_author_id");
b.ToTable("book_authors", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_series");
b.ToTable("series", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("integer")
.HasColumnName("series_id");
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Arc")
.HasColumnType("text")
.HasColumnName("arc");
b.Property<double>("Position")
.HasColumnType("double precision")
.HasColumnName("position");
b.HasKey("SeriesId", "BookId")
.HasName("pk_series_entries");
b.HasIndex("BookId")
.HasDatabaseName("ix_series_entries_book_id");
b.ToTable("series_entries", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b =>
{
b.HasOne("PageManager.Api.Data.Models.Author", "Author")
.WithMany("BookAuthors")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_book_authors_authors_author_id");
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("BookAuthors")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_book_authors_books_book_id");
b.Navigation("Author");
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("SeriesEntries")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_series_entries_books_book_id");
b.HasOne("PageManager.Api.Data.Models.Series", "Series")
.WithMany("Entries")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_series_entries_series_series_id");
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Author", b =>
{
b.Navigation("BookAuthors");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Book", b =>
{
b.Navigation("BookAuthors");
b.Navigation("SeriesEntries");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Navigation("Entries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PageManager.Api.Migrations
{
/// <inheritdoc />
public partial class AddImportTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,323 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using PageManager.Api.Data;
#nullable disable
namespace PageManager.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260315185207_PendingMigration")]
partial class PendingMigration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("PageManager.Api.Data.Models.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_authors");
b.ToTable("authors", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Book", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Color")
.IsRequired()
.HasColumnType("text")
.HasColumnName("color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.PrimitiveCollection<string[]>("Formats")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("formats");
b.PrimitiveCollection<string[]>("Genres")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("genres");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<int?>("Year")
.HasColumnType("integer")
.HasColumnName("year");
b.HasKey("Id")
.HasName("pk_books");
b.HasIndex("HardcoverId")
.HasDatabaseName("ix_books_hardcover_id");
b.HasIndex("Isbn")
.HasDatabaseName("ix_books_isbn");
b.ToTable("books", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int>("AuthorId")
.HasColumnType("integer")
.HasColumnName("author_id");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text")
.HasColumnName("role");
b.HasKey("BookId", "AuthorId")
.HasName("pk_book_authors");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_book_authors_author_id");
b.ToTable("book_authors", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<long>("DownloadedBytes")
.HasColumnType("bigint")
.HasColumnName("downloaded_bytes");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size_bytes");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_import_queue_items");
b.ToTable("import_queue_items", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.ImportSource", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("boolean")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text")
.HasColumnName("path");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_import_sources");
b.ToTable("import_sources", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_series");
b.ToTable("series", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("integer")
.HasColumnName("series_id");
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Arc")
.HasColumnType("text")
.HasColumnName("arc");
b.Property<double>("Position")
.HasColumnType("double precision")
.HasColumnName("position");
b.HasKey("SeriesId", "BookId")
.HasName("pk_series_entries");
b.HasIndex("BookId")
.HasDatabaseName("ix_series_entries_book_id");
b.ToTable("series_entries", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b =>
{
b.HasOne("PageManager.Api.Data.Models.Author", "Author")
.WithMany("BookAuthors")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_book_authors_authors_author_id");
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("BookAuthors")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_book_authors_books_book_id");
b.Navigation("Author");
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("SeriesEntries")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_series_entries_books_book_id");
b.HasOne("PageManager.Api.Data.Models.Series", "Series")
.WithMany("Entries")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_series_entries_series_series_id");
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Author", b =>
{
b.Navigation("BookAuthors");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Book", b =>
{
b.Navigation("BookAuthors");
b.Navigation("SeriesEntries");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Navigation("Entries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PageManager.Api.Migrations
{
/// <inheritdoc />
public partial class PendingMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "import_queue_items",
columns: table => new
{
id = table.Column<string>(type: "text", nullable: false),
filename = table.Column<string>(type: "text", nullable: false),
size_bytes = table.Column<long>(type: "bigint", nullable: false),
downloaded_bytes = table.Column<long>(type: "bigint", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
source = table.Column<string>(type: "text", nullable: false),
error = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_import_queue_items", x => x.id);
});
migrationBuilder.CreateTable(
name: "import_sources",
columns: table => new
{
id = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
type = table.Column<string>(type: "text", nullable: false),
path = table.Column<string>(type: "text", nullable: false),
enabled = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_import_sources", x => x.id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "import_queue_items");
migrationBuilder.DropTable(
name: "import_sources");
}
}
}
@@ -0,0 +1,320 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using PageManager.Api.Data;
#nullable disable
namespace PageManager.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("PageManager.Api.Data.Models.Author", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_authors");
b.ToTable("authors", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Book", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Color")
.IsRequired()
.HasColumnType("text")
.HasColumnName("color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.PrimitiveCollection<string[]>("Formats")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("formats");
b.PrimitiveCollection<string[]>("Genres")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("genres");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<int?>("Year")
.HasColumnType("integer")
.HasColumnName("year");
b.HasKey("Id")
.HasName("pk_books");
b.HasIndex("HardcoverId")
.HasDatabaseName("ix_books_hardcover_id");
b.HasIndex("Isbn")
.HasDatabaseName("ix_books_isbn");
b.ToTable("books", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int>("AuthorId")
.HasColumnType("integer")
.HasColumnName("author_id");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text")
.HasColumnName("role");
b.HasKey("BookId", "AuthorId")
.HasName("pk_book_authors");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_book_authors_author_id");
b.ToTable("book_authors", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<long>("DownloadedBytes")
.HasColumnType("bigint")
.HasColumnName("downloaded_bytes");
b.Property<string>("Error")
.HasColumnType("text")
.HasColumnName("error");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size_bytes");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_import_queue_items");
b.ToTable("import_queue_items", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.ImportSource", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("boolean")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text")
.HasColumnName("path");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_import_sources");
b.ToTable("import_sources", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_series");
b.ToTable("series", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("integer")
.HasColumnName("series_id");
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Arc")
.HasColumnType("text")
.HasColumnName("arc");
b.Property<double>("Position")
.HasColumnType("double precision")
.HasColumnName("position");
b.HasKey("SeriesId", "BookId")
.HasName("pk_series_entries");
b.HasIndex("BookId")
.HasDatabaseName("ix_series_entries_book_id");
b.ToTable("series_entries", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b =>
{
b.HasOne("PageManager.Api.Data.Models.Author", "Author")
.WithMany("BookAuthors")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_book_authors_authors_author_id");
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("BookAuthors")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_book_authors_books_book_id");
b.Navigation("Author");
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("SeriesEntries")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_series_entries_books_book_id");
b.HasOne("PageManager.Api.Data.Models.Series", "Series")
.WithMany("Entries")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_series_entries_series_series_id");
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Author", b =>
{
b.Navigation("BookAuthors");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Book", b =>
{
b.Navigation("BookAuthors");
b.Navigation("SeriesEntries");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Navigation("Entries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Scalar.AspNetCore" Version="2.13.8" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
@PageManager.Api_HostAddress = http://localhost:5278
GET {{PageManager.Api_HostAddress}}/api/books
Accept: application/json
###
GET {{PageManager.Api_HostAddress}}/api/books/1
Accept: application/json
###
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data;
using PageManager.Api.Data.Repositories;
using PageManager.Api.Services;
using Scalar.AspNetCore;
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.StaticFiles", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", Serilog.Events.LogEventLevel.Warning)
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateBootstrapLogger();
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((ctx, services, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.ReadFrom.Services(services)
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.StaticFiles", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", Serilog.Events.LogEventLevel.Warning)
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"));
builder.Services.AddControllers();
builder.Services.AddScoped<IBooksRepository, BooksRepository>();
builder.Services.AddScoped<IBooksService, BooksService>();
builder.Services.AddHttpClient<IHardcoverService, HardcoverService>();
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddOpenApi();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"),
o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery))
.UseSnakeCaseNamingConvention());
builder.Services.AddCors(options =>
{
options.AddPolicy("DevCors", policy =>
policy.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.AllowAnyMethod());
});
var app = builder.Build();
app.UseSerilogRequestLogging(opts =>
{
opts.MessageTemplate = "{RequestMethod} {RequestPath} → {StatusCode} ({Elapsed:0.0}ms)";
});
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
app.UseCors("DevCors");
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
public partial class Program { }
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "scalar/v1",
"applicationUrl": "http://localhost:5278",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "scalar/v1",
"applicationUrl": "https://localhost:7196;http://localhost:5278",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,103 @@
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Models;
using PageManager.Api.Data.Repositories;
namespace PageManager.Api.Services;
public class BooksService(IBooksRepository repo, IHardcoverService hardcover) : IBooksService
{
public async Task<IEnumerable<BookDto>> GetAllAsync()
{
var books = await repo.GetAllAsync();
return books.Select(ToDto);
}
public async Task<BookDto?> GetByIdAsync(int id)
{
var book = await repo.GetByIdAsync(id);
return book is null ? null : ToDto(book);
}
public async Task<BookDto?> UpdateAsync(int id, UpdateBookRequest req)
{
var book = await repo.GetByIdAsync(id);
if (book is null) return null;
book.Title = req.Title;
book.Year = req.Year;
book.Publisher = req.Publisher;
book.Pages = req.Pages;
book.Description = req.Description;
book.Formats = req.Formats;
book.Color = req.Color;
book.Genres = req.Genres;
book.CoverUrl = req.CoverUrl;
book.Isbn = req.Isbn;
book.HardcoverId = req.HardcoverId;
await repo.SaveAsync();
return ToDto(book);
}
public async Task<BookDto?> CreateFromHardcoverAsync(int hardcoverId)
{
// Idempotent: return existing book if already in DB
var existing = await repo.FindByHardcoverIdAsync(hardcoverId);
if (existing is not null) return ToDto(existing);
var details = await hardcover.GetBookDetailsAsync(hardcoverId);
if (details is null) return null;
var color = details.CoverColor is { Length: > 0 } c && c.StartsWith('#') ? c : "#6366f1";
var book = new Book
{
Title = details.Title,
Year = details.Year,
Publisher = details.Publisher,
Pages = details.Pages,
Description = details.Description,
Formats = [],
Color = color,
Genres = details.Genres,
CoverUrl = details.CoverUrl,
Isbn = details.Isbn,
HardcoverId = details.Id,
};
(string name, double position, string? arc)? seriesArg = details.Series is { } s
? (s.Name, s.Position, (string?)null) : null;
var created = await repo.CreateBookAsync(book, details.Authors, seriesArg);
return ToDto(created);
}
private static BookDto ToDto(Book book)
{
var entry = book.SeriesEntries.FirstOrDefault();
return new BookDto
{
Id = book.Id,
Title = book.Title,
Year = book.Year,
Publisher = book.Publisher,
Pages = book.Pages,
Description = book.Description,
Formats = book.Formats,
Color = book.Color,
Genres = book.Genres,
Authors = book.BookAuthors
.Select(ba => new AuthorDto { Id = ba.Author.Id, Name = ba.Author.Name })
.ToArray(),
Series = entry is null ? null : new BookSeriesDto
{
Name = entry.Series.Name,
Position = entry.Position,
Arc = entry.Arc,
},
CoverUrl = book.CoverUrl,
Isbn = book.Isbn,
HardcoverId = book.HardcoverId,
};
}
}
@@ -0,0 +1,287 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using PageManager.Api.Api.Dtos;
namespace PageManager.Api.Services;
public class HardcoverService(HttpClient http, IConfiguration config, ILogger<HardcoverService> logger) : IHardcoverService
{
private const string Endpoint = "https://api.hardcover.app/v1/graphql";
private const string SearchQuery = """
query SearchBooks($q: String!) {
search(query: $q, query_type: "Book", per_page: 15, page: 1) {
results
}
}
""";
private const string DetailsQuery = """
query GetBookDetails($id: Int!) {
books(where: {id: {_eq: $id}}, limit: 1) {
id
title
description
pages
release_year
cached_contributors
cached_tags
book_series(order_by: {position: asc}, limit: 1) {
position
series { id name }
}
editions(order_by: {users_count: desc}, limit: 3) {
isbn_13
publisher { name }
image { url color }
}
}
}
""";
public async Task<IEnumerable<HardcoverBookResult>> SearchBooksAsync(string query)
{
using var doc = await SendQueryAsync(SearchQuery, new { q = query });
if (!doc.RootElement.TryGetProperty("data", out var data) ||
!data.TryGetProperty("search", out var search) ||
!search.TryGetProperty("results", out var results))
return [];
// results is a Typesense JSON blob — may be embedded object or string
if (results.ValueKind == JsonValueKind.String)
{
using var inner = JsonDocument.Parse(results.GetString()!);
return inner.RootElement.TryGetProperty("hits", out var h) ? ParseHits(h) : [];
}
return results.TryGetProperty("hits", out var hits) ? ParseHits(hits) : [];
}
public async Task<HardcoverBookDetails?> GetBookDetailsAsync(int hardcoverId)
{
logger.LogInformation("Fetching Hardcover book details for id={Id}", hardcoverId);
using var doc = await SendQueryAsync(DetailsQuery, new { id = hardcoverId });
if (!doc.RootElement.TryGetProperty("data", out var data))
{
logger.LogWarning("Hardcover response missing 'data' for id={Id}. Response: {Json}",
hardcoverId, doc.RootElement.GetRawText());
return null;
}
if (!data.TryGetProperty("books", out var books))
{
logger.LogWarning("Hardcover response missing 'books' for id={Id}. Data: {Json}",
hardcoverId, data.GetRawText());
return null;
}
if (books.GetArrayLength() == 0)
{
logger.LogWarning("Hardcover returned empty books array for id={Id}", hardcoverId);
return null;
}
var details = ParseBookDetails(books[0]);
if (details.Authors.Length == 0)
logger.LogWarning("No authors parsed for book id={Id}. cached_contributors: {Raw}",
hardcoverId,
books[0].TryGetProperty("cached_contributors", out var cc) ? cc.GetRawText() : "missing");
return details;
}
// ── Parsing ───────────────────────────────────────────────────────────────
private static List<HardcoverBookResult> ParseHits(JsonElement hitsEl)
{
var list = new List<HardcoverBookResult>();
foreach (var hit in hitsEl.EnumerateArray())
{
if (!hit.TryGetProperty("document", out var d)) continue;
var id = 0;
if (d.TryGetProperty("id", out var idEl))
id = idEl.ValueKind == JsonValueKind.Number
? idEl.GetInt32()
: int.TryParse(idEl.GetString(), out var p) ? p : 0;
if (id == 0) continue;
var title = d.TryGetProperty("title", out var tEl) ? tEl.GetString() ?? "" : "";
var authors = new List<string>();
if (d.TryGetProperty("author_names", out var aNamesEl))
foreach (var a in aNamesEl.EnumerateArray())
if (a.GetString() is string n) authors.Add(n);
int? year = d.TryGetProperty("release_year", out var yEl) && yEl.ValueKind == JsonValueKind.Number
? yEl.GetInt32() : null;
var genres = new List<string>();
if (d.TryGetProperty("genres", out var gEl))
foreach (var g in gEl.EnumerateArray())
if (g.GetString() is string gn) genres.Add(gn);
list.Add(new HardcoverBookResult
{
Id = id,
Title = title,
Authors = [.. authors],
Year = year,
Genres = [.. genres],
});
}
return list;
}
private static HardcoverBookDetails ParseBookDetails(JsonElement book)
{
var id = book.TryGetProperty("id", out var idEl) ? idEl.GetInt32() : 0;
var title = book.TryGetProperty("title", out var tEl) ? tEl.GetString() ?? "" : "";
var desc = book.TryGetProperty("description", out var dEl) && dEl.ValueKind != JsonValueKind.Null ? dEl.GetString() : null;
int? pages = book.TryGetProperty("pages", out var pEl) && pEl.ValueKind == JsonValueKind.Number ? pEl.GetInt32() : null;
int? year = book.TryGetProperty("release_year",out var yEl) && yEl.ValueKind == JsonValueKind.Number ? yEl.GetInt32() : null;
var authors = ParseCachedContributors(book);
var genres = ParseCachedTags(book);
var series = ParseBookSeries(book);
var (isbn, publisher, coverUrl, coverColor) = ParseEditions(book);
return new HardcoverBookDetails
{
Id = id,
Title = title,
Description = desc,
Pages = pages,
Year = year,
Authors = [.. authors],
Genres = [.. genres],
Isbn = isbn,
Publisher = publisher,
CoverUrl = coverUrl,
CoverColor = coverColor,
Series = series,
};
}
private static List<string> ParseCachedContributors(JsonElement book)
{
var authors = new List<string>();
if (!book.TryGetProperty("cached_contributors", out var el)) return authors;
if (el.ValueKind == JsonValueKind.String)
{
if (el.GetString() is not string s) return authors;
using var inner = JsonDocument.Parse(s);
ExtractContributorNames(inner.RootElement, authors);
return authors;
}
ExtractContributorNames(el, authors);
return authors;
}
private static void ExtractContributorNames(JsonElement el, List<string> authors)
{
if (el.ValueKind == JsonValueKind.Array)
{
foreach (var c in el.EnumerateArray())
if (c.TryGetProperty("name", out var n) && n.GetString() is string name)
authors.Add(name);
}
else if (el.ValueKind == JsonValueKind.Object)
{
// Format: { "Author": [{name, ...}], "Narrator": [...] }
foreach (var role in el.EnumerateObject())
ExtractContributorNames(role.Value, authors);
}
}
private static List<string> ParseCachedTags(JsonElement book)
{
var tags = new List<string>();
if (!book.TryGetProperty("cached_tags", out var el)) return tags;
if (el.ValueKind == JsonValueKind.String)
{
if (el.GetString() is not string s) return tags;
using var inner = JsonDocument.Parse(s);
ExtractTagNames(inner.RootElement, tags);
return tags;
}
ExtractTagNames(el, tags);
return tags;
}
private static void ExtractTagNames(JsonElement el, List<string> tags)
{
if (el.ValueKind != JsonValueKind.Array) return;
foreach (var t in el.EnumerateArray())
if (t.TryGetProperty("tag", out var n) && n.GetString() is string tag)
tags.Add(tag);
}
private static HardcoverSeriesInfo? ParseBookSeries(JsonElement book)
{
if (!book.TryGetProperty("book_series", out var arr) || arr.GetArrayLength() == 0)
return null;
var entry = arr[0];
var position = entry.TryGetProperty("position", out var posEl) && posEl.ValueKind == JsonValueKind.Number
? posEl.GetDouble() : 1.0;
string? seriesName = null;
if (entry.TryGetProperty("series", out var seriesEl) && seriesEl.ValueKind != JsonValueKind.Null)
seriesName = seriesEl.TryGetProperty("name", out var snEl) ? snEl.GetString() : null;
return seriesName is null ? null : new HardcoverSeriesInfo { Name = seriesName, Position = position };
}
private static (string? isbn, string? publisher, string? coverUrl, string? coverColor) ParseEditions(JsonElement book)
{
string? isbn = null, publisher = null, coverUrl = null, coverColor = null;
if (!book.TryGetProperty("editions", out var editions)) return (isbn, publisher, coverUrl, coverColor);
foreach (var ed in editions.EnumerateArray())
{
if (isbn is null && ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null)
isbn = isbnEl.GetString();
if (publisher is null && ed.TryGetProperty("publisher", out var pubEl) && pubEl.ValueKind != JsonValueKind.Null)
if (pubEl.TryGetProperty("name", out var pnEl)) publisher = pnEl.GetString();
if (coverUrl is null && ed.TryGetProperty("image", out var imgEl) && imgEl.ValueKind != JsonValueKind.Null)
{
if (imgEl.TryGetProperty("url", out var urlEl)) coverUrl = urlEl.GetString();
if (imgEl.TryGetProperty("color", out var colorEl)) coverColor = colorEl.GetString();
}
if (isbn is not null && publisher is not null && coverUrl is not null) break;
}
return (isbn, publisher, coverUrl, coverColor);
}
// ── HTTP ──────────────────────────────────────────────────────────────────
private async Task<JsonDocument> SendQueryAsync(string query, object? variables = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, Endpoint)
{
Content = JsonContent.Create(new { query, variables })
};
var apiKey = config["Hardcover:ApiKey"];
if (!string.IsNullOrEmpty(apiKey))
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync();
return await JsonDocument.ParseAsync(stream);
}
}
@@ -0,0 +1,16 @@
using PageManager.Api.Api.Dtos;
namespace PageManager.Api.Services;
public interface IBooksService
{
Task<IEnumerable<BookDto>> GetAllAsync();
Task<BookDto?> GetByIdAsync(int id);
Task<BookDto?> UpdateAsync(int id, UpdateBookRequest req);
/// <summary>
/// Fetches full metadata from Hardcover and persists the book.
/// Returns the existing book if hardcoverId already exists in the DB.
/// Returns null if the Hardcover API does not recognise the id.
/// </summary>
Task<BookDto?> CreateFromHardcoverAsync(int hardcoverId);
}
@@ -0,0 +1,9 @@
using PageManager.Api.Api.Dtos;
namespace PageManager.Api.Services;
public interface IHardcoverService
{
Task<IEnumerable<HardcoverBookResult>> SearchBooksAsync(string query);
Task<HardcoverBookDetails?> GetBookDetailsAsync(int hardcoverId);
}
@@ -0,0 +1,12 @@
using PageManager.Api.Api.Dtos;
namespace PageManager.Api.Services;
public interface IImportService
{
Task<IEnumerable<ImportSourceDto>> GetSourcesAsync();
Task<ImportSourceDto?> UpdateSourceAsync(string id, bool enabled);
Task<IEnumerable<QueueItemDto>> GetQueueAsync();
Task<bool> RemoveQueueItemAsync(string id);
Task<bool> RetryQueueItemAsync(string id);
}
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Services;
public class ImportService(AppDbContext db) : IImportService
{
public async Task<IEnumerable<ImportSourceDto>> GetSourcesAsync() =>
(await db.ImportSources.ToListAsync()).Select(ToDto);
public async Task<ImportSourceDto?> UpdateSourceAsync(string id, bool enabled)
{
var source = await db.ImportSources.FindAsync(id);
if (source is null) return null;
source.Enabled = enabled;
await db.SaveChangesAsync();
return ToDto(source);
}
public async Task<IEnumerable<QueueItemDto>> GetQueueAsync() =>
(await db.ImportQueueItems
.OrderByDescending(i => i.Status == QueueItemStatus.Downloading)
.ThenByDescending(i => i.Status == QueueItemStatus.Queued)
.ToListAsync())
.Select(ToDto);
public async Task<bool> RemoveQueueItemAsync(string id)
{
var item = await db.ImportQueueItems.FindAsync(id);
if (item is null) return false;
db.ImportQueueItems.Remove(item);
await db.SaveChangesAsync();
return true;
}
public async Task<bool> RetryQueueItemAsync(string id)
{
var item = await db.ImportQueueItems.FindAsync(id);
if (item is null || item.Status != QueueItemStatus.Failed) return false;
item.Status = QueueItemStatus.Queued;
item.Error = null;
await db.SaveChangesAsync();
return true;
}
private static ImportSourceDto ToDto(ImportSource s) => new()
{
Id = s.Id,
Name = s.Name,
Type = s.Type.ToString().ToLowerInvariant(),
Path = s.Path,
Enabled = s.Enabled,
};
private static QueueItemDto ToDto(ImportQueueItem i) => new()
{
Id = i.Id,
Filename = i.Filename,
SizeBytes = i.SizeBytes,
DownloadedBytes = i.DownloadedBytes,
Status = i.Status.ToString().ToLowerInvariant(),
Source = i.Source,
Error = i.Error,
};
}
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Hardcover": {
"ApiKey": ""
}
}
+7
View File
@@ -0,0 +1,7 @@
services:
pagemanager.api:
image: pagemanager.api
build:
context: .
dockerfile: PageManager.Api/Dockerfile