Changed design language. Added editions, better support for authors. Base for file handling

This commit is contained in:
2026-03-28 15:17:20 +02:00
parent cbd7f52535
commit 5acde17a53
84 changed files with 5861 additions and 1983 deletions
@@ -34,6 +34,7 @@ public static class BookFactory
HardcoverId = hardcoverId,
BookAuthors = [],
SeriesEntries = [],
Editions = [],
};
}
@@ -53,6 +54,13 @@ public static class BookFactory
return book;
}
public static Book WithEditions(this Book book, params (string? Isbn, string? Publisher, string? CoverUrl)[] editions)
{
foreach (var (isbn, publisher, coverUrl) in editions)
book.Editions.Add(new Edition { BookId = book.Id, Isbn = isbn, Publisher = publisher, CoverUrl = coverUrl });
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 };
@@ -30,7 +30,7 @@ public class BooksControllerTests : IAsyncLifetime
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");
"TRUNCATE book_files, book_authors, series_entries, editions, books, authors, series RESTART IDENTITY CASCADE");
}
public Task DisposeAsync()
@@ -166,7 +166,7 @@ public class BooksControllerTests : IAsyncLifetime
Year = 1965,
Publisher = "Ace Books",
Pages = 412,
Authors = ["Frank Herbert"],
Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }],
Genres = ["Science Fiction"],
Isbn = "9780441013593",
CoverColor = "#c4a35a",
@@ -0,0 +1,189 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
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 FilesControllerTests : IAsyncLifetime
{
private readonly TestWebAppFactory _factory;
private readonly HttpClient _client;
public FilesControllerTests(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_files, book_authors, series_entries, editions, books, authors, series, import_sources RESTART IDENTITY CASCADE");
}
public Task DisposeAsync()
{
_client.Dispose();
_factory.Dispose();
return Task.CompletedTask;
}
// ── GET /api/books/{id}/files ─────────────────────────────────────────────
[Fact]
public async Task GetBookFiles_NoFiles_Returns200WithEmptyArray()
{
var bookId = await SeedBookAsync();
var response = await _client.GetAsync($"/api/books/{bookId}/files");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var files = await response.Content.ReadFromJsonAsync<BookFileDto[]>();
files.Should().BeEmpty();
}
[Fact]
public async Task GetBookFiles_WithFiles_Returns200WithCorrectData()
{
var bookId = await SeedBookAsync("Dune");
await SeedFileAsync(filename: "dune.epub", format: FileFormat.Epub, bookId: bookId);
await SeedFileAsync(filename: "dune.mobi", format: FileFormat.Mobi, bookId: bookId);
var response = await _client.GetAsync($"/api/books/{bookId}/files");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var files = await response.Content.ReadFromJsonAsync<BookFileDto[]>();
files.Should().HaveCount(2);
files!.Select(f => f.Filename).Should().BeEquivalentTo(["dune.epub", "dune.mobi"]);
files.First(f => f.Filename == "dune.epub").Format.Should().Be("epub");
}
// ── GET /api/files?unmatched=true ─────────────────────────────────────────
[Fact]
public async Task GetUnmatchedFiles_MixedFiles_ReturnsOnlyUnmatched()
{
var bookId = await SeedBookAsync();
await SeedFileAsync(filename: "unmatched.epub", bookId: null);
await SeedFileAsync(filename: "matched.epub", bookId: bookId);
var response = await _client.GetAsync("/api/files?unmatched=true");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var files = await response.Content.ReadFromJsonAsync<BookFileDto[]>();
files.Should().ContainSingle(f => f.Filename == "unmatched.epub");
files.Should().NotContain(f => f.Filename == "matched.epub");
}
[Fact]
public async Task GetFiles_NoUnmatchedParam_Returns400()
{
var response = await _client.GetAsync("/api/files");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// ── PATCH /api/files/{id} ─────────────────────────────────────────────────
[Fact]
public async Task AssignFile_Exists_Returns200WithUpdatedBookId()
{
var bookId = await SeedBookAsync();
var fileId = await SeedFileAsync(filename: "orphan.epub", bookId: null);
var response = await _client.PatchAsJsonAsync($"/api/files/{fileId}", new { bookId });
response.StatusCode.Should().Be(HttpStatusCode.OK);
var file = await response.Content.ReadFromJsonAsync<BookFileDto>();
file!.BookId.Should().Be(bookId);
}
[Fact]
public async Task AssignFile_NotFound_Returns404()
{
var response = await _client.PatchAsJsonAsync("/api/files/99999", new { bookId = 1 });
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task AssignFile_ClearsBookId_WhenSetToNull()
{
var bookId = await SeedBookAsync();
var fileId = await SeedFileAsync(filename: "book.epub", bookId: bookId);
var response = await _client.PatchAsJsonAsync($"/api/files/{fileId}", new { bookId = (int?)null });
response.StatusCode.Should().Be(HttpStatusCode.OK);
var file = await response.Content.ReadFromJsonAsync<BookFileDto>();
file!.BookId.Should().BeNull();
}
// ── DELETE /api/files/{id} ────────────────────────────────────────────────
[Fact]
public async Task DeleteFile_Exists_Returns204AndRemovesRecord()
{
var fileId = await SeedFileAsync();
var response = await _client.DeleteAsync($"/api/files/{fileId}");
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var exists = await db.BookFiles.AnyAsync(f => f.Id == fileId);
exists.Should().BeFalse();
}
[Fact]
public async Task DeleteFile_NotFound_Returns404()
{
var response = await _client.DeleteAsync("/api/files/99999");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<int> SeedBookAsync(string title = "Test Book")
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var book = new Book { Title = title, Formats = [], Genres = [] };
db.Books.Add(book);
await db.SaveChangesAsync();
return book.Id;
}
private async Task<int> SeedFileAsync(
string filename = "test.epub",
FileFormat format = FileFormat.Epub,
int? bookId = null,
long sizeBytes = 1024)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var file = new BookFile
{
Filename = filename,
Path = filename,
Format = format,
SizeBytes = sizeBytes,
BookId = bookId,
AddedAt = DateTime.UtcNow,
};
db.BookFiles.Add(file);
await db.SaveChangesAsync();
return file.Id;
}
}
@@ -175,6 +175,39 @@ public class BooksServiceTests
result.Authors.Select(a => a.Name).Should().BeEquivalentTo(["Alice", "Bob", "Carol"]);
}
// ── Editions DTO mapping ──────────────────────────────────────────────────
[Fact]
public async Task GetByIdAsync_BookWithEditions_MapsEditionsToDto()
{
var book = BookFactory.Create(id: 10)
.WithAuthors((1, "Author"))
.WithEditions(
("9780441013593", "Ace Books", "https://example.com/cover1.jpg"),
(null, "Bantam", null));
_repo.GetByIdAsync(10).Returns(book);
var result = await _sut.GetByIdAsync(10);
result!.Editions.Should().HaveCount(2);
result.Editions[0].Isbn.Should().Be("9780441013593");
result.Editions[0].Publisher.Should().Be("Ace Books");
result.Editions[0].CoverUrl.Should().Be("https://example.com/cover1.jpg");
result.Editions[1].Isbn.Should().BeNull();
result.Editions[1].Publisher.Should().Be("Bantam");
}
[Fact]
public async Task GetByIdAsync_BookWithNoEditions_ReturnsEmptyEditionsArray()
{
var book = BookFactory.Create(id: 11).WithAuthors((1, "Author"));
_repo.GetByIdAsync(11).Returns(book);
var result = await _sut.GetByIdAsync(11);
result!.Editions.Should().BeEmpty();
}
// ── CreateFromHardcoverAsync ──────────────────────────────────────────────
[Fact]
@@ -202,7 +235,7 @@ public class BooksServiceTests
result.Should().BeNull();
await _repo.DidNotReceive().CreateBookAsync(
Arg.Any<PageManager.Api.Data.Models.Book>(),
Arg.Any<IReadOnlyList<string>>(),
Arg.Any<IReadOnlyList<HardcoverAuthor>>(),
Arg.Any<(string, double, string?)?> ());
}
@@ -219,7 +252,7 @@ public class BooksServiceTests
Publisher = "Ace Books",
Pages = 412,
Description = "A sci-fi classic.",
Authors = ["Frank Herbert"],
Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }],
Genres = ["Science Fiction"],
Isbn = "9780441013593",
CoverUrl = "https://example.com/cover.jpg",
@@ -231,7 +264,7 @@ public class BooksServiceTests
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?)?>())
_repo.CreateBookAsync(Arg.Any<PageManager.Api.Data.Models.Book>(), Arg.Any<IReadOnlyList<HardcoverAuthor>>(), Arg.Any<(string, double, string?)?>())
.Returns(createdBook);
var result = await _sut.CreateFromHardcoverAsync(123);
@@ -242,7 +275,7 @@ public class BooksServiceTests
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<IReadOnlyList<HardcoverAuthor>>(a => a.Any(ha => ha.Name == "Frank Herbert")),
Arg.Is<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles"));
}
}
@@ -0,0 +1,90 @@
using FluentAssertions;
using PageManager.Api.Data.Models;
using PageManager.Api.Services;
using PageManager.Api.Tests.Helpers;
namespace PageManager.Api.Tests.Unit.Services;
public class FileScannerServiceTests
{
// ── GetFormatFromExtension ────────────────────────────────────────────────
[Theory]
[InlineData(".epub", FileFormat.Epub)]
[InlineData(".mobi", FileFormat.Mobi)]
[InlineData(".pdf", FileFormat.Pdf)]
[InlineData(".m4b", FileFormat.M4b)]
[InlineData(".mp3", FileFormat.Mp3)]
[InlineData(".aac", FileFormat.Aac)]
[InlineData(".flac", FileFormat.Flac)]
[InlineData(".EPUB", FileFormat.Epub)] // case-insensitive
[InlineData(".PDF", FileFormat.Pdf)]
public void GetFormatFromExtension_KnownExtension_ReturnsFormat(string ext, FileFormat expected)
{
FileScannerService.GetFormatFromExtension(ext).Should().Be(expected);
}
[Theory]
[InlineData(".txt")]
[InlineData(".docx")]
[InlineData(".zip")]
[InlineData("")]
public void GetFormatFromExtension_UnknownExtension_ReturnsNull(string ext)
{
FileScannerService.GetFormatFromExtension(ext).Should().BeNull();
}
// ── FindMatch ─────────────────────────────────────────────────────────────
[Fact]
public void FindMatch_FilenameContainsBookTitle_ReturnsBook()
{
var books = new[] { BookFactory.Create(id: 1, title: "Dune") };
var file = new BookFile { Filename = "Dune - Frank Herbert.epub" };
FileScannerService.FindMatch(file, books)!.Id.Should().Be(1);
}
[Fact]
public void FindMatch_CaseInsensitive_ReturnsBook()
{
var books = new[] { BookFactory.Create(id: 2, title: "The Way of Kings") };
var file = new BookFile { Filename = "the way of kings.epub" };
FileScannerService.FindMatch(file, books)!.Id.Should().Be(2);
}
[Fact]
public void FindMatch_FilenameContainsIsbn_ReturnsBook()
{
var books = new[] { BookFactory.Create(id: 3, title: "Foundation", isbn: "9780553293357") };
var file = new BookFile { Filename = "9780553293357.epub" };
FileScannerService.FindMatch(file, books)!.Id.Should().Be(3);
}
[Fact]
public void FindMatch_NoMatch_ReturnsNull()
{
var books = new[] { BookFactory.Create(id: 1, title: "Dune") };
var file = new BookFile { Filename = "Foundation - Isaac Asimov.epub" };
FileScannerService.FindMatch(file, books).Should().BeNull();
}
[Fact]
public void FindMatch_EmptyBookList_ReturnsNull()
{
FileScannerService.FindMatch(new BookFile { Filename = "anything.epub" }, []).Should().BeNull();
}
[Fact]
public void FindMatch_BookWithNullIsbn_DoesNotThrow()
{
var books = new[] { BookFactory.Create(id: 1, title: "Dune", isbn: null) };
var file = new BookFile { Filename = "some-file.epub" };
var act = () => FileScannerService.FindMatch(file, books);
act.Should().NotThrow();
}
}
@@ -4,4 +4,30 @@ public class AuthorDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Bio { get; set; }
public int? BornYear { get; set; }
public string? ImageUrl { get; set; }
public string? Slug { get; set; }
public string Role { get; set; } = "Author";
}
public class AuthorSummaryDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Bio { get; set; }
public int? BornYear { get; set; }
public string? ImageUrl { get; set; }
public int BookCount { get; set; }
}
public class AuthorDetailDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Bio { get; set; }
public int? BornYear { get; set; }
public string? ImageUrl { get; set; }
public string? Slug { get; set; }
public BookDto[] Books { get; set; } = [];
}
@@ -1,5 +1,21 @@
namespace PageManager.Api.Api.Dtos;
public class EditionDto
{
public int Id { get; set; }
public string? Isbn { get; set; }
public string? Asin { get; set; }
public string? Publisher { get; set; }
public int? ReleaseYear { get; set; }
public PageManager.Api.Data.Models.ReadingFormat? ReadingFormat { get; set; }
public string? EditionFormat { get; set; }
public int? Pages { get; set; }
public int? AudioSeconds { get; set; }
public string? Language { get; set; }
public string? LanguageCode { get; set; }
public string? CoverUrl { get; set; }
}
public class BookSeriesDto
{
public string Name { get; set; } = string.Empty;
@@ -23,4 +39,5 @@ public class BookDto
public string? CoverUrl { get; set; }
public string? Isbn { get; set; }
public int? HardcoverId { get; set; }
public EditionDto[] Editions { get; set; } = [];
}
@@ -0,0 +1,21 @@
namespace PageManager.Api.Api.Dtos;
public class BookFileDto
{
public int Id { get; set; }
public int? BookId { get; set; }
public int? EditionId { get; set; }
public string? SourceId { get; set; }
public string Path { get; set; } = string.Empty;
public string Filename { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public string Format { get; set; } = string.Empty;
public string? Hash { get; set; }
public DateTime AddedAt { get; set; }
}
public class AssignFileRequest
{
public int? BookId { get; set; }
public int? EditionId { get; set; }
}
@@ -9,6 +9,17 @@ public class HardcoverBookResult
public string[] Genres { get; set; } = [];
}
public class HardcoverAuthor
{
public int? HardcoverId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Bio { get; set; }
public int? BornYear { get; set; }
public string? ImageUrl { get; set; }
public string? Slug { get; set; }
public string Role { get; set; } = "Author";
}
public class HardcoverBookDetails
{
public int Id { get; set; }
@@ -16,13 +27,14 @@ public class HardcoverBookDetails
public string? Description { get; set; }
public int? Pages { get; set; }
public int? Year { get; set; }
public string[] Authors { get; set; } = [];
public HardcoverAuthor[] 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 HardcoverEdition[] Editions { get; set; } = [];
}
public class HardcoverSeriesInfo
@@ -31,6 +43,22 @@ public class HardcoverSeriesInfo
public double Position { get; set; }
}
public class HardcoverEdition
{
public string? Isbn { get; set; }
public string? Asin { get; set; }
public string? Publisher { get; set; }
public int? ReleaseYear { get; set; }
public PageManager.Api.Data.Models.ReadingFormat? ReadingFormat { get; set; }
public string? EditionFormat { get; set; }
public int? Pages { get; set; }
public int? AudioSeconds { get; set; }
public string? Language { get; set; }
public string? LanguageCode { get; set; }
public string? CoverUrl { get; set; }
public string? CoverColor { get; set; }
}
public class CreateBookFromHardcoverRequest
{
public int HardcoverId { get; set; }
@@ -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 AuthorsController(IAuthorsService authors) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<AuthorSummaryDto>> GetAuthors() =>
await authors.GetAllAsync();
[HttpGet("{id}")]
public async Task<ActionResult<AuthorDetailDto>> GetAuthor(int id)
{
var author = await authors.GetByIdAsync(id);
return author is null ? NotFound() : Ok(author);
}
}
@@ -32,4 +32,11 @@ public class BooksController(IBooksService books) : ControllerBase
var book = await books.UpdateAsync(id, req);
return book is null ? NotFound() : Ok(book);
}
[HttpPost("{id}/fetch-metadata")]
public async Task<ActionResult<BookDto>> FetchMetadata(int id)
{
var book = await books.RefreshFromHardcoverAsync(id);
return book is null ? NotFound() : Ok(book);
}
}
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Models;
using PageManager.Api.Data.Repositories;
using PageManager.Api.Services;
namespace PageManager.Api.Controllers;
[ApiController]
[Route("api")]
public class FilesController(IFilesRepository filesRepo, IFileScannerService scanner) : ControllerBase
{
// GET /api/books/{id}/files
[HttpGet("books/{id:int}/files")]
public async Task<IActionResult> GetByBook(int id)
{
var files = await filesRepo.GetByBookIdAsync(id);
return Ok(files.Select(ToDto));
}
// GET /api/files?unmatched=true
[HttpGet("files")]
public async Task<IActionResult> GetFiles([FromQuery] bool unmatched = false)
{
if (!unmatched)
return BadRequest("Specify ?unmatched=true to list unmatched files.");
var files = await filesRepo.GetUnmatchedAsync();
return Ok(files.Select(ToDto));
}
// PATCH /api/files/{id}
[HttpPatch("files/{id:int}")]
public async Task<IActionResult> Assign(int id, [FromBody] AssignFileRequest req)
{
var file = await filesRepo.AssignAsync(id, req.BookId, req.EditionId);
if (file is null) return NotFound();
return Ok(ToDto(file));
}
// DELETE /api/files/{id}
[HttpDelete("files/{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var deleted = await filesRepo.DeleteAsync(id);
if (!deleted) return NotFound();
return NoContent();
}
// POST /api/scan
[HttpPost("scan")]
public async Task<IActionResult> TriggerScan(CancellationToken ct)
{
await scanner.ScanAsync(ct);
return Ok(new { message = "Scan complete." });
}
private static BookFileDto ToDto(BookFile f) => new()
{
Id = f.Id,
BookId = f.BookId,
EditionId = f.EditionId,
SourceId = f.SourceId,
Path = f.Path,
Filename = f.Filename,
SizeBytes = f.SizeBytes,
Format = f.Format.ToString().ToLowerInvariant(),
Hash = f.Hash,
AddedAt = f.AddedAt,
};
}
@@ -10,8 +10,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<Series> Series => Set<Series>();
public DbSet<BookAuthor> BookAuthors => Set<BookAuthor>();
public DbSet<SeriesEntry> SeriesEntries => Set<SeriesEntry>();
public DbSet<Edition> Editions => Set<Edition>();
public DbSet<ImportSource> ImportSources => Set<ImportSource>();
public DbSet<ImportQueueItem> ImportQueueItems => Set<ImportQueueItem>();
public DbSet<BookFile> BookFiles => Set<BookFile>();
protected override void OnModelCreating(ModelBuilder model)
{
@@ -28,8 +30,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
.WithMany(a => a.BookAuthors)
.HasForeignKey(ba => ba.AuthorId);
e.Property(ba => ba.Role)
.HasConversion<string>();
// Role is stored as plain text — no conversion needed
});
// ── SeriesEntry (composite PK) ───────────────────────────────────────
@@ -66,6 +67,17 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
e.HasIndex(b => b.Isbn);
});
// ── Edition ──────────────────────────────────────────────────────────
model.Entity<Edition>(e =>
{
e.HasOne(ed => ed.Book)
.WithMany(b => b.Editions)
.HasForeignKey(ed => ed.BookId);
e.Property(ed => ed.ReadingFormat)
.HasConversion<string>();
});
// ── Author ───────────────────────────────────────────────────────────
model.Entity<Author>(e =>
{
@@ -85,5 +97,29 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
e.Property(i => i.Id).ValueGeneratedNever();
e.Property(i => i.Status).HasConversion<string>();
});
// ── BookFile ──────────────────────────────────────────────────────────
model.Entity<BookFile>(e =>
{
e.HasOne(f => f.Book)
.WithMany(b => b.BookFiles)
.HasForeignKey(f => f.BookId)
.OnDelete(DeleteBehavior.SetNull);
e.HasOne(f => f.Edition)
.WithMany(ed => ed.BookFiles)
.HasForeignKey(f => f.EditionId)
.OnDelete(DeleteBehavior.SetNull);
e.HasOne(f => f.Source)
.WithMany()
.HasForeignKey(f => f.SourceId)
.OnDelete(DeleteBehavior.SetNull);
e.Property(f => f.Format).HasConversion<string>();
e.HasIndex(f => f.Hash);
e.HasIndex(f => f.BookId);
e.HasIndex(f => f.EditionId);
});
}
}
@@ -3,7 +3,12 @@ namespace PageManager.Api.Data.Models;
public class Author
{
public int Id { get; set; }
public int? HardcoverId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Bio { get; set; }
public int? BornYear { get; set; }
public string? ImageUrl { get; set; }
public string? Slug { get; set; }
public ICollection<BookAuthor> BookAuthors { get; set; } = [];
}
@@ -27,4 +27,6 @@ public class Book
public ICollection<BookAuthor> BookAuthors { get; set; } = [];
public ICollection<SeriesEntry> SeriesEntries { get; set; } = [];
public ICollection<Edition> Editions { get; set; } = [];
public ICollection<BookFile> BookFiles { get; set; } = [];
}
@@ -1,7 +1,5 @@
namespace PageManager.Api.Data.Models;
public enum AuthorRole { Author, Editor }
public class BookAuthor
{
public int BookId { get; set; }
@@ -10,5 +8,5 @@ public class BookAuthor
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
public AuthorRole Role { get; set; } = AuthorRole.Author;
public string Role { get; set; } = "Author";
}
@@ -0,0 +1,27 @@
namespace PageManager.Api.Data.Models;
public class BookFile
{
public int Id { get; set; }
public int? BookId { get; set; }
public Book? Book { get; set; }
public int? EditionId { get; set; }
public Edition? Edition { get; set; }
public string? SourceId { get; set; }
public ImportSource? Source { get; set; }
/// <summary>Path relative to the source root directory.</summary>
public string Path { get; set; } = string.Empty;
public string Filename { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public FileFormat Format { get; set; }
/// <summary>SHA-256 hex digest — used for deduplication.</summary>
public string? Hash { get; set; }
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,36 @@
namespace PageManager.Api.Data.Models;
public class Edition
{
public int Id { get; set; }
public int BookId { get; set; }
public Book Book { get; set; } = null!;
// Identification
public string? Isbn { get; set; }
public string? Asin { get; set; }
// Publishing
public string? Publisher { get; set; }
public int? ReleaseYear { get; set; }
// Format
public ReadingFormat? ReadingFormat { get; set; }
/// <summary>Detailed edition format (e.g. "Hardcover", "Mass Market Paperback").</summary>
public string? EditionFormat { get; set; }
// Content
public int? Pages { get; set; }
/// <summary>Audiobook duration in seconds.</summary>
public int? AudioSeconds { get; set; }
// Language
public string? Language { get; set; }
public string? LanguageCode { get; set; }
// Cover
public string? CoverUrl { get; set; }
public string? CoverColor { get; set; }
public ICollection<BookFile> BookFiles { get; set; } = [];
}
@@ -0,0 +1,12 @@
namespace PageManager.Api.Data.Models;
public enum FileFormat
{
Epub,
Mobi,
Pdf,
M4b,
Mp3,
Aac,
Flac,
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace PageManager.Api.Data.Models;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ReadingFormat
{
Physical = 1,
Audio = 2,
Both = 3,
Ebook = 4,
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
public class AuthorsRepository(AppDbContext db) : IAuthorsRepository
{
public Task<IEnumerable<Author>> GetAllAsync() =>
db.Authors
.Include(a => a.BookAuthors)
.OrderBy(a => a.Name)
.ToListAsync()
.ContinueWith(t => (IEnumerable<Author>)t.Result);
public Task<Author?> GetByIdAsync(int id) =>
db.Authors
.Include(a => a.BookAuthors)
.ThenInclude(ba => ba.Book)
.ThenInclude(b => b.BookAuthors)
.ThenInclude(ba => ba.Author)
.Include(a => a.BookAuthors)
.ThenInclude(ba => ba.Book)
.ThenInclude(b => b.SeriesEntries)
.ThenInclude(se => se.Series)
.Include(a => a.BookAuthors)
.ThenInclude(ba => ba.Book)
.ThenInclude(b => b.Editions)
.FirstOrDefaultAsync(a => a.Id == id);
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
@@ -9,6 +10,7 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.ToListAsync()
.ContinueWith(t => (IEnumerable<Book>)t.Result);
@@ -16,34 +18,28 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.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)
.Include(b => b.Editions)
.FirstOrDefaultAsync(b => b.HardcoverId == hardcoverId);
public async Task<Book> CreateBookAsync(
Book book,
IReadOnlyList<string> authorNames,
IReadOnlyList<HardcoverAuthor> authors,
(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 authors and link to book
var resolved = await ResolveAuthorsAsync(authors);
foreach (var (author, role) in resolved)
db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id, Role = role });
// Resolve / create series
if (series is { } si)
@@ -66,8 +62,79 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
return await db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.FirstAsync(b => b.Id == book.Id);
}
public async Task<Book> SyncHardcoverDataAsync(Book book, IReadOnlyList<HardcoverAuthor> authors, IReadOnlyList<Edition> editions)
{
// Scalar fields are already mutated on the tracked entity — persist them
await db.SaveChangesAsync();
// Replace authors
var oldAuthors = await db.BookAuthors.Where(ba => ba.BookId == book.Id).ToListAsync();
db.BookAuthors.RemoveRange(oldAuthors);
var resolved = await ResolveAuthorsAsync(authors);
foreach (var (author, role) in resolved)
db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id, Role = role });
// Replace editions
var oldEditions = await db.Editions.Where(e => e.BookId == book.Id).ToListAsync();
db.Editions.RemoveRange(oldEditions);
foreach (var edition in editions)
{
edition.BookId = book.Id;
db.Editions.Add(edition);
}
await db.SaveChangesAsync();
return await db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.FirstAsync(b => b.Id == book.Id);
}
public Task SaveAsync() => db.SaveChangesAsync();
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<List<(Author author, string role)>> ResolveAuthorsAsync(IReadOnlyList<HardcoverAuthor> hardcoverAuthors)
{
var result = new List<(Author, string)>();
foreach (var ha in hardcoverAuthors)
{
Author? author = null;
// Look up by Hardcover ID first (most reliable), then fall back to name
if (ha.HardcoverId.HasValue)
author = await db.Authors.FirstOrDefaultAsync(a => a.HardcoverId == ha.HardcoverId);
author ??= await db.Authors.FirstOrDefaultAsync(a => a.Name == ha.Name);
if (author is null)
{
author = new Author();
db.Authors.Add(author);
}
// Always update with latest Hardcover data
author.HardcoverId = ha.HardcoverId;
author.Name = ha.Name;
author.Bio = ha.Bio;
author.BornYear = ha.BornYear;
author.ImageUrl = ha.ImageUrl;
author.Slug = ha.Slug;
result.Add((author, ha.Role));
}
// Flush new Author rows so they get IDs
if (result.Any(r => r.Item1.Id == 0))
await db.SaveChangesAsync();
return result;
}
}
@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
public class FilesRepository(AppDbContext db) : IFilesRepository
{
public Task<IEnumerable<BookFile>> GetByBookIdAsync(int bookId) =>
db.BookFiles
.Where(f => f.BookId == bookId)
.OrderBy(f => f.Filename)
.ToListAsync()
.ContinueWith(t => (IEnumerable<BookFile>)t.Result);
public Task<IEnumerable<BookFile>> GetUnmatchedAsync() =>
db.BookFiles
.Where(f => f.BookId == null)
.OrderBy(f => f.Filename)
.ToListAsync()
.ContinueWith(t => (IEnumerable<BookFile>)t.Result);
public Task<BookFile?> GetByIdAsync(int id) =>
db.BookFiles.FirstOrDefaultAsync(f => f.Id == id);
public Task<BookFile?> FindByHashAsync(string hash) =>
db.BookFiles.FirstOrDefaultAsync(f => f.Hash == hash);
public async Task<BookFile> AddAsync(BookFile file)
{
db.BookFiles.Add(file);
await db.SaveChangesAsync();
return file;
}
public async Task<BookFile?> AssignAsync(int id, int? bookId, int? editionId)
{
var file = await db.BookFiles.FindAsync(id);
if (file is null) return null;
file.BookId = bookId;
file.EditionId = editionId;
await db.SaveChangesAsync();
return file;
}
public async Task<bool> DeleteAsync(int id)
{
var file = await db.BookFiles.FindAsync(id);
if (file is null) return false;
db.BookFiles.Remove(file);
await db.SaveChangesAsync();
return true;
}
}
@@ -0,0 +1,9 @@
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
public interface IAuthorsRepository
{
Task<IEnumerable<Author>> GetAllAsync();
Task<Author?> GetByIdAsync(int id);
}
@@ -1,3 +1,4 @@
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
@@ -9,7 +10,8 @@ public interface IBooksRepository
Task<Book?> FindByHardcoverIdAsync(int hardcoverId);
Task<Book> CreateBookAsync(
Book book,
IReadOnlyList<string> authorNames,
IReadOnlyList<HardcoverAuthor> authors,
(string name, double position, string? arc)? series);
Task<Book> SyncHardcoverDataAsync(Book book, IReadOnlyList<HardcoverAuthor> authors, IReadOnlyList<Edition> editions);
Task SaveAsync();
}
@@ -0,0 +1,14 @@
using PageManager.Api.Data.Models;
namespace PageManager.Api.Data.Repositories;
public interface IFilesRepository
{
Task<IEnumerable<BookFile>> GetByBookIdAsync(int bookId);
Task<IEnumerable<BookFile>> GetUnmatchedAsync();
Task<BookFile?> GetByIdAsync(int id);
Task<BookFile?> FindByHashAsync(string hash);
Task<BookFile> AddAsync(BookFile file);
Task<BookFile?> AssignAsync(int id, int? bookId, int? editionId);
Task<bool> DeleteAsync(int id);
}
@@ -0,0 +1,375 @@
// <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("20260328000000_AddEditions")]
partial class AddEditions
{
/// <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.Edition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("CoverColor")
.HasColumnType("text")
.HasColumnName("cover_color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.HasKey("Id")
.HasName("pk_editions");
b.HasIndex("BookId")
.HasDatabaseName("ix_editions_book_id");
b.ToTable("editions", (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.Edition", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("Editions")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_editions_books_book_id");
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("Editions");
b.Navigation("SeriesEntries");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Navigation("Entries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace PageManager.Api.Migrations
{
/// <inheritdoc />
public partial class AddEditions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "editions",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
book_id = table.Column<int>(type: "integer", nullable: false),
isbn = table.Column<string>(type: "text", nullable: true),
publisher = table.Column<string>(type: "text", nullable: true),
cover_url = table.Column<string>(type: "text", nullable: true),
cover_color = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_editions", x => x.id);
table.ForeignKey(
name: "fk_editions_books_book_id",
column: x => x.book_id,
principalTable: "books",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_editions_book_id",
table: "editions",
column: "book_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "editions");
}
}
}
@@ -0,0 +1,85 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using PageManager.Api.Data;
#nullable disable
namespace PageManager.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260328000001_AddEditionFields")]
public partial class AddEditionFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "asin",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "audio_seconds",
table: "editions",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "edition_format",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "language",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "language_code",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "pages",
table: "editions",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "reading_format",
table: "editions",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "reading_format_id",
table: "editions",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "release_year",
table: "editions",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "asin", table: "editions");
migrationBuilder.DropColumn(name: "audio_seconds", table: "editions");
migrationBuilder.DropColumn(name: "edition_format", table: "editions");
migrationBuilder.DropColumn(name: "language", table: "editions");
migrationBuilder.DropColumn(name: "language_code", table: "editions");
migrationBuilder.DropColumn(name: "pages", table: "editions");
migrationBuilder.DropColumn(name: "reading_format", table: "editions");
migrationBuilder.DropColumn(name: "reading_format_id", table: "editions");
migrationBuilder.DropColumn(name: "release_year", table: "editions");
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using PageManager.Api.Data;
#nullable disable
namespace PageManager.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260328000002_DropReadingFormatId")]
public partial class DropReadingFormatId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "reading_format_id",
table: "editions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "reading_format_id",
table: "editions",
type: "integer",
nullable: true);
}
}
}
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
using PageManager.Api.Data;
#nullable disable
namespace PageManager.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260328000003_AddAuthorFields")]
public partial class AddAuthorFields : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "hardcover_id",
table: "authors",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "bio",
table: "authors",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "born_year",
table: "authors",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "image_url",
table: "authors",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "slug",
table: "authors",
type: "text",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "hardcover_id", table: "authors");
migrationBuilder.DropColumn(name: "bio", table: "authors");
migrationBuilder.DropColumn(name: "born_year", table: "authors");
migrationBuilder.DropColumn(name: "image_url", table: "authors");
migrationBuilder.DropColumn(name: "slug", table: "authors");
}
}
}
@@ -0,0 +1,532 @@
// <auto-generated />
using System;
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("20260329000000_AddBookFiles")]
partial class AddBookFiles
{
/// <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>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<int?>("BornYear")
.HasColumnType("integer")
.HasColumnName("born_year");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.HasColumnType("text")
.HasColumnName("slug");
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.BookFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("added_at");
b.Property<int?>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int?>("EditionId")
.HasColumnType("integer")
.HasColumnName("edition_id");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<string>("Format")
.IsRequired()
.HasColumnType("text")
.HasColumnName("format")
.HasConversion<string>();
b.Property<string>("Hash")
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text")
.HasColumnName("path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size_bytes");
b.Property<string>("SourceId")
.HasColumnType("text")
.HasColumnName("source_id");
b.HasKey("Id")
.HasName("pk_book_files");
b.HasIndex("BookId")
.HasDatabaseName("ix_book_files_book_id");
b.HasIndex("EditionId")
.HasDatabaseName("ix_book_files_edition_id");
b.HasIndex("Hash")
.HasDatabaseName("ix_book_files_hash");
b.HasIndex("SourceId")
.HasDatabaseName("ix_book_files_source_id");
b.ToTable("book_files", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Asin")
.HasColumnType("text")
.HasColumnName("asin");
b.Property<int?>("AudioSeconds")
.HasColumnType("integer")
.HasColumnName("audio_seconds");
b.Property<string>("CoverColor")
.HasColumnType("text")
.HasColumnName("cover_color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("EditionFormat")
.HasColumnType("text")
.HasColumnName("edition_format");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<string>("Language")
.HasColumnType("text")
.HasColumnName("language");
b.Property<string>("LanguageCode")
.HasColumnType("text")
.HasColumnName("language_code");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("ReadingFormat")
.HasColumnType("text")
.HasColumnName("reading_format")
.HasConversion<string>();
b.Property<int?>("ReleaseYear")
.HasColumnType("integer")
.HasColumnName("release_year");
b.HasKey("Id")
.HasName("pk_editions");
b.HasIndex("BookId")
.HasDatabaseName("ix_editions_book_id");
b.ToTable("editions", (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.BookFile", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("BookFiles")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_book_files_books_book_id");
b.HasOne("PageManager.Api.Data.Models.Edition", "Edition")
.WithMany("BookFiles")
.HasForeignKey("EditionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_book_files_editions_edition_id");
b.HasOne("PageManager.Api.Data.Models.ImportSource", "Source")
.WithMany()
.HasForeignKey("SourceId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_book_files_import_sources_source_id");
b.Navigation("Book");
b.Navigation("Edition");
b.Navigation("Source");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("Editions")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_editions_books_book_id");
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("BookFiles");
b.Navigation("Editions");
b.Navigation("SeriesEntries");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b =>
{
b.Navigation("BookFiles");
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Navigation("Entries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace PageManager.Api.Migrations
{
/// <inheritdoc />
public partial class AddBookFiles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "book_files",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
book_id = table.Column<int>(type: "integer", nullable: true),
edition_id = table.Column<int>(type: "integer", nullable: true),
source_id = table.Column<string>(type: "text", nullable: true),
path = table.Column<string>(type: "text", nullable: false),
filename = table.Column<string>(type: "text", nullable: false),
size_bytes = table.Column<long>(type: "bigint", nullable: false),
format = table.Column<string>(type: "text", nullable: false),
hash = table.Column<string>(type: "text", nullable: true),
added_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_book_files", x => x.id);
table.ForeignKey(
name: "fk_book_files_books_book_id",
column: x => x.book_id,
principalTable: "books",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_book_files_editions_edition_id",
column: x => x.edition_id,
principalTable: "editions",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_book_files_import_sources_source_id",
column: x => x.source_id,
principalTable: "import_sources",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "ix_book_files_book_id",
table: "book_files",
column: "book_id");
migrationBuilder.CreateIndex(
name: "ix_book_files_edition_id",
table: "book_files",
column: "edition_id");
migrationBuilder.CreateIndex(
name: "ix_book_files_hash",
table: "book_files",
column: "hash");
migrationBuilder.CreateIndex(
name: "ix_book_files_source_id",
table: "book_files",
column: "source_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "book_files");
}
}
}
@@ -1,4 +1,5 @@
// <auto-generated />
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -30,11 +31,31 @@ namespace PageManager.Api.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<int?>("BornYear")
.HasColumnType("integer")
.HasColumnName("born_year");
b.Property<int?>("HardcoverId")
.HasColumnType("integer")
.HasColumnName("hardcover_id");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.HasColumnType("text")
.HasColumnName("slug");
b.HasKey("Id")
.HasName("pk_authors");
@@ -134,6 +155,144 @@ namespace PageManager.Api.Migrations
b.ToTable("book_authors", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("added_at");
b.Property<int?>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<int?>("EditionId")
.HasColumnType("integer")
.HasColumnName("edition_id");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<string>("Format")
.IsRequired()
.HasColumnType("text")
.HasColumnName("format")
.HasConversion<string>();
b.Property<string>("Hash")
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text")
.HasColumnName("path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size_bytes");
b.Property<string>("SourceId")
.HasColumnType("text")
.HasColumnName("source_id");
b.HasKey("Id")
.HasName("pk_book_files");
b.HasIndex("BookId")
.HasDatabaseName("ix_book_files_book_id");
b.HasIndex("EditionId")
.HasDatabaseName("ix_book_files_edition_id");
b.HasIndex("Hash")
.HasDatabaseName("ix_book_files_hash");
b.HasIndex("SourceId")
.HasDatabaseName("ix_book_files_source_id");
b.ToTable("book_files", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<string>("Asin")
.HasColumnType("text")
.HasColumnName("asin");
b.Property<int?>("AudioSeconds")
.HasColumnType("integer")
.HasColumnName("audio_seconds");
b.Property<string>("CoverColor")
.HasColumnType("text")
.HasColumnName("cover_color");
b.Property<string>("CoverUrl")
.HasColumnType("text")
.HasColumnName("cover_url");
b.Property<string>("EditionFormat")
.HasColumnType("text")
.HasColumnName("edition_format");
b.Property<string>("Isbn")
.HasColumnType("text")
.HasColumnName("isbn");
b.Property<string>("Language")
.HasColumnType("text")
.HasColumnName("language");
b.Property<string>("LanguageCode")
.HasColumnType("text")
.HasColumnName("language_code");
b.Property<int?>("Pages")
.HasColumnType("integer")
.HasColumnName("pages");
b.Property<string>("Publisher")
.HasColumnType("text")
.HasColumnName("publisher");
b.Property<string>("ReadingFormat")
.HasColumnType("text")
.HasColumnName("reading_format")
.HasConversion<string>();
b.Property<int?>("ReleaseYear")
.HasColumnType("integer")
.HasColumnName("release_year");
b.HasKey("Id")
.HasName("pk_editions");
b.HasIndex("BookId")
.HasDatabaseName("ix_editions_book_id");
b.ToTable("editions", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b =>
{
b.Property<string>("Id")
@@ -277,6 +436,45 @@ namespace PageManager.Api.Migrations
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.BookFile", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("BookFiles")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_book_files_books_book_id");
b.HasOne("PageManager.Api.Data.Models.Edition", "Edition")
.WithMany("BookFiles")
.HasForeignKey("EditionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_book_files_editions_edition_id");
b.HasOne("PageManager.Api.Data.Models.ImportSource", "Source")
.WithMany()
.HasForeignKey("SourceId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_book_files_import_sources_source_id");
b.Navigation("Book");
b.Navigation("Edition");
b.Navigation("Source");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("Editions")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_editions_books_book_id");
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
@@ -307,9 +505,20 @@ namespace PageManager.Api.Migrations
{
b.Navigation("BookAuthors");
b.Navigation("BookFiles");
b.Navigation("Editions");
b.Navigation("SeriesEntries");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b =>
{
b.Navigation("BookFiles");
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Series", b =>
{
b.Navigation("Entries");
@@ -7,6 +7,12 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>PageManager.Api.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
@@ -33,8 +33,14 @@ builder.Host.UseSerilog((ctx, services, cfg) => cfg
builder.Services.AddControllers();
builder.Services.AddScoped<IBooksRepository, BooksRepository>();
builder.Services.AddScoped<IBooksService, BooksService>();
builder.Services.AddScoped<IAuthorsRepository, AuthorsRepository>();
builder.Services.AddScoped<IAuthorsService, AuthorsService>();
builder.Services.AddHttpClient<IHardcoverService, HardcoverService>();
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IFilesRepository, FilesRepository>();
builder.Services.AddScoped<IFileScannerService, FileScannerService>();
builder.Services.AddSingleton<IFileSystem, PhysicalFileSystem>();
builder.Services.AddHostedService<FileScannerBackgroundService>();
builder.Services.AddOpenApi();
builder.Services.AddDbContext<AppDbContext>(options =>
@@ -0,0 +1,40 @@
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Repositories;
namespace PageManager.Api.Services;
public class AuthorsService(IAuthorsRepository repo) : IAuthorsService
{
public async Task<IEnumerable<AuthorSummaryDto>> GetAllAsync()
{
var authors = await repo.GetAllAsync();
return authors.Select(a => new AuthorSummaryDto
{
Id = a.Id,
Name = a.Name,
Bio = a.Bio,
BornYear = a.BornYear,
ImageUrl = a.ImageUrl,
BookCount = a.BookAuthors.Count,
});
}
public async Task<AuthorDetailDto?> GetByIdAsync(int id)
{
var author = await repo.GetByIdAsync(id);
if (author is null) return null;
return new AuthorDetailDto
{
Id = author.Id,
Name = author.Name,
Bio = author.Bio,
BornYear = author.BornYear,
ImageUrl = author.ImageUrl,
Slug = author.Slug,
Books = author.BookAuthors
.Select(ba => BooksService.ToDto(ba.Book))
.ToArray(),
};
}
}
@@ -63,6 +63,21 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
CoverUrl = details.CoverUrl,
Isbn = details.Isbn,
HardcoverId = details.Id,
Editions = details.Editions.Select(e => new Edition
{
Isbn = e.Isbn,
Asin = e.Asin,
Publisher = e.Publisher,
ReleaseYear = e.ReleaseYear,
ReadingFormat = e.ReadingFormat,
EditionFormat = e.EditionFormat,
Pages = e.Pages,
AudioSeconds = e.AudioSeconds,
Language = e.Language,
LanguageCode = e.LanguageCode,
CoverUrl = e.CoverUrl,
CoverColor = e.CoverColor,
}).ToList(),
};
(string name, double position, string? arc)? seriesArg = details.Series is { } s
@@ -72,7 +87,47 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
return ToDto(created);
}
private static BookDto ToDto(Book book)
public async Task<BookDto?> RefreshFromHardcoverAsync(int id)
{
var book = await repo.GetByIdAsync(id);
if (book is null || book.HardcoverId is null) return null;
var details = await hardcover.GetBookDetailsAsync(book.HardcoverId.Value);
if (details is null) return null;
var color = details.CoverColor is { Length: > 0 } c && c.StartsWith('#') ? c : book.Color;
book.Title = details.Title;
book.Year = details.Year;
book.Publisher = details.Publisher;
book.Pages = details.Pages;
book.Description = details.Description;
book.Color = color;
book.Genres = details.Genres;
book.CoverUrl = details.CoverUrl;
book.Isbn = details.Isbn;
var editions = details.Editions.Select(e => new Edition
{
Isbn = e.Isbn,
Asin = e.Asin,
Publisher = e.Publisher,
ReleaseYear = e.ReleaseYear,
ReadingFormat = e.ReadingFormat,
EditionFormat = e.EditionFormat,
Pages = e.Pages,
AudioSeconds = e.AudioSeconds,
Language = e.Language,
LanguageCode = e.LanguageCode,
CoverUrl = e.CoverUrl,
CoverColor = e.CoverColor,
}).ToList();
var updated = await repo.SyncHardcoverDataAsync(book, details.Authors, editions);
return ToDto(updated);
}
internal static BookDto ToDto(Book book)
{
var entry = book.SeriesEntries.FirstOrDefault();
return new BookDto
@@ -87,7 +142,16 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
Color = book.Color,
Genres = book.Genres,
Authors = book.BookAuthors
.Select(ba => new AuthorDto { Id = ba.Author.Id, Name = ba.Author.Name })
.Select(ba => new AuthorDto
{
Id = ba.Author.Id,
Name = ba.Author.Name,
Bio = ba.Author.Bio,
BornYear = ba.Author.BornYear,
ImageUrl = ba.Author.ImageUrl,
Slug = ba.Author.Slug,
Role = ba.Role,
})
.ToArray(),
Series = entry is null ? null : new BookSeriesDto
{
@@ -98,6 +162,21 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
CoverUrl = book.CoverUrl,
Isbn = book.Isbn,
HardcoverId = book.HardcoverId,
Editions = book.Editions.Select(e => new EditionDto
{
Id = e.Id,
Isbn = e.Isbn,
Asin = e.Asin,
Publisher = e.Publisher,
ReleaseYear = e.ReleaseYear,
ReadingFormat = e.ReadingFormat,
EditionFormat = e.EditionFormat,
Pages = e.Pages,
AudioSeconds = e.AudioSeconds,
Language = e.Language,
LanguageCode = e.LanguageCode,
CoverUrl = e.CoverUrl,
}).ToArray(),
};
}
}
@@ -0,0 +1,34 @@
namespace PageManager.Api.Services;
public class FileScannerBackgroundService(
IServiceScopeFactory scopeFactory,
IHostApplicationLifetime lifetime,
ILogger<FileScannerBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Wait until the app is fully started before scanning
var ready = new TaskCompletionSource();
lifetime.ApplicationStarted.Register(() => ready.TrySetResult());
await ready.Task.WaitAsync(stoppingToken);
if (stoppingToken.IsCancellationRequested) return;
logger.LogInformation("Starting initial library scan");
try
{
using var scope = scopeFactory.CreateScope();
var scanner = scope.ServiceProvider.GetRequiredService<IFileScannerService>();
await scanner.ScanAsync(stoppingToken);
logger.LogInformation("Initial library scan complete");
}
catch (OperationCanceledException)
{
// Normal shutdown
}
catch (Exception ex)
{
logger.LogError(ex, "Library scan failed");
}
}
}
@@ -0,0 +1,144 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PageManager.Api.Data.Repositories;
namespace PageManager.Api.Services;
public class FileScannerService(
AppDbContext db,
IFilesRepository filesRepo,
IBooksRepository booksRepo,
IFileSystem fileSystem,
ILogger<FileScannerService> logger) : IFileScannerService
{
private static readonly HashSet<string> SupportedExtensions =
[".epub", ".mobi", ".pdf", ".m4b", ".mp3", ".aac", ".flac"];
public async Task ScanAsync(CancellationToken cancellationToken = default)
{
var sources = await db.ImportSources
.Where(s => s.Enabled && s.Type == ImportSourceType.Folder)
.ToListAsync(cancellationToken);
if (sources.Count == 0)
{
logger.LogDebug("No enabled folder sources configured — skipping scan");
return;
}
foreach (var source in sources)
{
if (!fileSystem.DirectoryExists(source.Path))
{
logger.LogWarning("Source directory not found: {Path}", source.Path);
continue;
}
logger.LogInformation("Scanning source '{Name}' at {Path}", source.Name, source.Path);
await ScanSourceAsync(source, cancellationToken);
}
await AutoMatchAsync(cancellationToken);
}
private async Task ScanSourceAsync(ImportSource source, CancellationToken ct)
{
IEnumerable<string> allFiles;
try
{
allFiles = fileSystem.EnumerateFiles(source.Path, SearchOption.AllDirectories);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to enumerate files in {Path}", source.Path);
return;
}
foreach (var fullPath in allFiles)
{
if (ct.IsCancellationRequested) break;
var ext = System.IO.Path.GetExtension(fullPath);
var format = GetFormatFromExtension(ext);
if (format is null) continue;
try
{
var hash = await fileSystem.ComputeSha256Async(fullPath, ct);
var existing = await filesRepo.FindByHashAsync(hash);
if (existing is not null)
{
logger.LogDebug("Skipping duplicate: {Path}", fullPath);
continue;
}
var relativePath = System.IO.Path.GetRelativePath(source.Path, fullPath);
var filename = System.IO.Path.GetFileName(fullPath);
var size = fileSystem.GetFileSize(fullPath);
await filesRepo.AddAsync(new BookFile
{
SourceId = source.Id,
Path = relativePath,
Filename = filename,
SizeBytes = size,
Format = format.Value,
Hash = hash,
AddedAt = DateTime.UtcNow,
});
logger.LogInformation("Discovered: {Filename}", filename);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing file: {Path}", fullPath);
}
}
}
private async Task AutoMatchAsync(CancellationToken ct)
{
var unmatched = (await filesRepo.GetUnmatchedAsync()).ToList();
if (unmatched.Count == 0) return;
var books = (await booksRepo.GetAllAsync()).ToList();
foreach (var file in unmatched)
{
if (ct.IsCancellationRequested) break;
var matched = FindMatch(file, books);
if (matched is not null)
{
await filesRepo.AssignAsync(file.Id, matched.Id, null);
logger.LogInformation("Auto-matched '{Filename}' → '{Title}'", file.Filename, matched.Title);
}
}
}
// ── Helpers (internal for unit testing) ──────────────────────────────────
internal static FileFormat? GetFormatFromExtension(string ext) =>
ext.ToLowerInvariant() switch
{
".epub" => FileFormat.Epub,
".mobi" => FileFormat.Mobi,
".pdf" => FileFormat.Pdf,
".m4b" => FileFormat.M4b,
".mp3" => FileFormat.Mp3,
".aac" => FileFormat.Aac,
".flac" => FileFormat.Flac,
_ => null,
};
internal static Book? FindMatch(BookFile file, IEnumerable<Book> books)
{
var stem = System.IO.Path.GetFileNameWithoutExtension(file.Filename).ToLowerInvariant();
return books.FirstOrDefault(b =>
stem.Contains(b.Title.ToLowerInvariant()) ||
(b.Isbn is { Length: > 0 } isbn && stem.Contains(isbn)));
}
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Services;
@@ -24,14 +25,31 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
description
pages
release_year
cached_contributors
cached_tags
contributions(where: {contributable_type: {_eq: "Book"}}, limit: 8) {
contribution
author {
id
name
bio
born_year
slug
image { url }
}
}
book_series(order_by: {position: asc}, limit: 1) {
position
series { id name }
}
editions(order_by: {users_count: desc}, limit: 3) {
editions(order_by: {users_count: desc}, limit: 10) {
isbn_13
asin
pages
release_year
edition_format
audio_seconds
reading_format_id
language { language code2 }
publisher { name }
image { url color }
}
@@ -86,9 +104,9 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
var details = ParseBookDetails(books[0]);
if (details.Authors.Length == 0)
logger.LogWarning("No authors parsed for book id={Id}. cached_contributors: {Raw}",
logger.LogWarning("No authors parsed for book id={Id}. contributions: {Raw}",
hardcoverId,
books[0].TryGetProperty("cached_contributors", out var cc) ? cc.GetRawText() : "missing");
books[0].TryGetProperty("contributions", out var cc) ? cc.GetRawText() : "missing");
return details;
}
@@ -144,10 +162,15 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
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);
var authors = ParseContributions(book);
var genres = ParseCachedTags(book);
var series = ParseBookSeries(book);
var editions = ParseEditions(book);
var isbn = editions.Select(e => e.Isbn).FirstOrDefault(x => x is not null);
var publisher = editions.Select(e => e.Publisher).FirstOrDefault(x => x is not null);
var coverUrl = editions.Select(e => e.CoverUrl).FirstOrDefault(x => x is not null);
var coverColor = editions.Select(e => e.CoverColor).FirstOrDefault(x => x is not null);
return new HardcoverBookDetails
{
@@ -163,40 +186,53 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
CoverUrl = coverUrl,
CoverColor = coverColor,
Series = series,
Editions = [.. editions],
};
}
private static List<string> ParseCachedContributors(JsonElement book)
private static List<HardcoverAuthor> ParseContributions(JsonElement book)
{
var authors = new List<string>();
if (!book.TryGetProperty("cached_contributors", out var el)) return authors;
var result = new List<HardcoverAuthor>();
if (!book.TryGetProperty("contributions", out var arr) || arr.ValueKind != JsonValueKind.Array)
return result;
if (el.ValueKind == JsonValueKind.String)
foreach (var c in arr.EnumerateArray())
{
if (el.GetString() is not string s) return authors;
using var inner = JsonDocument.Parse(s);
ExtractContributorNames(inner.RootElement, authors);
return authors;
string role = "Author";
if (c.TryGetProperty("contribution", out var roleEl) && roleEl.ValueKind == JsonValueKind.String)
role = roleEl.GetString() ?? "Author";
if (!c.TryGetProperty("author", out var a) || a.ValueKind == JsonValueKind.Null)
continue;
int? hardcoverId = a.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number
? idEl.GetInt32() : null;
string name = a.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name)) continue;
string? bio = a.TryGetProperty("bio", out var bioEl) && bioEl.ValueKind == JsonValueKind.String
? bioEl.GetString() : null;
int? bornYear = a.TryGetProperty("born_year", out var byEl) && byEl.ValueKind == JsonValueKind.Number
? byEl.GetInt32() : null;
string? slug = a.TryGetProperty("slug", out var slEl) && slEl.ValueKind == JsonValueKind.String
? slEl.GetString() : null;
string? imageUrl = null;
if (a.TryGetProperty("image", out var imgEl) && imgEl.ValueKind != JsonValueKind.Null)
if (imgEl.TryGetProperty("url", out var urlEl)) imageUrl = urlEl.GetString();
result.Add(new HardcoverAuthor
{
HardcoverId = hardcoverId,
Name = name,
Bio = bio,
BornYear = bornYear,
Slug = slug,
ImageUrl = imageUrl,
Role = role,
});
}
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);
}
return result;
}
private static List<string> ParseCachedTags(JsonElement book)
@@ -239,30 +275,76 @@ public class HardcoverService(HttpClient http, IConfiguration config, ILogger<Ha
return seriesName is null ? null : new HardcoverSeriesInfo { Name = seriesName, Position = position };
}
private static (string? isbn, string? publisher, string? coverUrl, string? coverColor) ParseEditions(JsonElement book)
private static List<HardcoverEdition> ParseEditions(JsonElement book)
{
string? isbn = null, publisher = null, coverUrl = null, coverColor = null;
var result = new List<HardcoverEdition>();
if (!book.TryGetProperty("editions", out var editions)) return (isbn, publisher, coverUrl, coverColor);
if (!book.TryGetProperty("editions", out var editions)) return result;
foreach (var ed in editions.EnumerateArray())
{
if (isbn is null && ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null)
isbn = isbnEl.GetString();
string? isbn = ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null
? isbnEl.GetString() : null;
if (publisher is null && ed.TryGetProperty("publisher", out var pubEl) && pubEl.ValueKind != JsonValueKind.Null)
string? asin = ed.TryGetProperty("asin", out var asinEl) && asinEl.ValueKind != JsonValueKind.Null
? asinEl.GetString() : null;
int? pages = ed.TryGetProperty("pages", out var pagesEl) && pagesEl.ValueKind == JsonValueKind.Number
? pagesEl.GetInt32() : null;
int? releaseYear = ed.TryGetProperty("release_year", out var ryEl) && ryEl.ValueKind == JsonValueKind.Number
? ryEl.GetInt32() : null;
string? editionFormat = ed.TryGetProperty("edition_format", out var efEl) && efEl.ValueKind != JsonValueKind.Null
? efEl.GetString() : null;
int? audioSeconds = ed.TryGetProperty("audio_seconds", out var asEl) && asEl.ValueKind == JsonValueKind.Number
? asEl.GetInt32() : null;
ReadingFormat? readingFormat = null;
if (ed.TryGetProperty("reading_format_id", out var rfIdEl) && rfIdEl.ValueKind == JsonValueKind.Number)
{
var id = rfIdEl.GetInt32();
if (Enum.IsDefined(typeof(ReadingFormat), id))
readingFormat = (ReadingFormat)id;
}
string? language = null, languageCode = null;
if (ed.TryGetProperty("language", out var langEl) && langEl.ValueKind != JsonValueKind.Null)
{
if (langEl.TryGetProperty("language", out var lnEl)) language = lnEl.GetString();
if (langEl.TryGetProperty("code2", out var lcEl)) languageCode = lcEl.GetString();
}
string? publisher = null;
if (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)
string? coverUrl = null, coverColor = null;
if (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;
result.Add(new HardcoverEdition
{
Isbn = isbn,
Asin = asin,
Publisher = publisher,
ReleaseYear = releaseYear,
ReadingFormat = readingFormat,
EditionFormat = editionFormat,
Pages = pages,
AudioSeconds = audioSeconds,
Language = language,
LanguageCode = languageCode,
CoverUrl = coverUrl,
CoverColor = coverColor,
});
}
return (isbn, publisher, coverUrl, coverColor);
return result;
}
// ── HTTP ──────────────────────────────────────────────────────────────────
@@ -0,0 +1,9 @@
using PageManager.Api.Api.Dtos;
namespace PageManager.Api.Services;
public interface IAuthorsService
{
Task<IEnumerable<AuthorSummaryDto>> GetAllAsync();
Task<AuthorDetailDto?> GetByIdAsync(int id);
}
@@ -13,4 +13,10 @@ public interface IBooksService
/// Returns null if the Hardcover API does not recognise the id.
/// </summary>
Task<BookDto?> CreateFromHardcoverAsync(int hardcoverId);
/// <summary>
/// Re-fetches metadata from Hardcover for an existing book and updates all fields,
/// authors, and editions in place. Returns null if the book has no hardcoverId
/// or the Hardcover API does not recognise it.
/// </summary>
Task<BookDto?> RefreshFromHardcoverAsync(int id);
}
@@ -0,0 +1,6 @@
namespace PageManager.Api.Services;
public interface IFileScannerService
{
Task ScanAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
namespace PageManager.Api.Services;
public interface IFileSystem
{
bool DirectoryExists(string path);
IEnumerable<string> EnumerateFiles(string path, SearchOption searchOption);
long GetFileSize(string path);
Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,21 @@
using System.Security.Cryptography;
namespace PageManager.Api.Services;
public class PhysicalFileSystem : IFileSystem
{
public bool DirectoryExists(string path) => Directory.Exists(path);
public IEnumerable<string> EnumerateFiles(string path, SearchOption searchOption) =>
Directory.EnumerateFiles(path, "*", searchOption);
public long GetFileSize(string path) => new FileInfo(path).Length;
public async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken = default)
{
using var sha256 = SHA256.Create();
await using var stream = File.OpenRead(path);
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
@@ -8,5 +8,6 @@
"AllowedHosts": "*",
"Hardcover": {
"ApiKey": ""
}
},
"LibraryPaths": []
}