Changed design language. Added editions, better support for authors. Base for file handling
This commit is contained in:
@@ -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);
|
||||
}
|
||||
+375
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+532
@@ -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": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user