Added support for bittorrent

This commit is contained in:
2026-03-28 17:36:25 +02:00
parent 5acde17a53
commit 4f7036ca27
45 changed files with 3383 additions and 225 deletions
@@ -0,0 +1,133 @@
using FluentAssertions;
using PageManager.Api.Services;
using PageManager.Api.Tests.Helpers;
namespace PageManager.Api.Tests.Unit.Services;
public class FileOrganizerServiceTests
{
// ── SanitizePathComponent ─────────────────────────────────────────────────
[Theory]
[InlineData("Andy Weir", "Andy Weir")]
[InlineData("Brandon Sanderson", "Brandon Sanderson")]
[InlineData("Hello: World", "Hello_ World")] // colon is invalid
[InlineData("Title/Sub", "Title_Sub")] // slash is invalid
[InlineData("Name.", "Name")] // trailing dot stripped
[InlineData(" Name ", "Name")] // whitespace trimmed
public void SanitizePathComponent_ReturnsExpected(string input, string expected)
{
FileOrganizerService.SanitizePathComponent(input).Should().Be(expected);
}
// ── ComputeCanonicalRelativePath — ebooks ─────────────────────────────────
[Fact]
public void Ebook_WithYear_ProducesAuthorTitleYearFolder()
{
var book = BookFactory.Create(title: "The Martian", year: 2011)
.WithAuthors((1, "Andy Weir"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().Be(Path.Combine("Andy Weir", "The Martian (2011)", "The Martian.epub"));
}
[Fact]
public void Ebook_WithoutYear_OmitsYearFromFolder()
{
var book = BookFactory.Create(title: "Dune", year: null)
.WithAuthors((1, "Frank Herbert"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().Be(Path.Combine("Frank Herbert", "Dune", "Dune.epub"));
}
[Fact]
public void Ebook_SeriesIgnored_SeriesNotInEbookPath()
{
var book = BookFactory.Create(title: "The Way of Kings", year: 2010)
.WithAuthors((1, "Brandon Sanderson"))
.WithSeries(seriesName: "The Stormlight Archive");
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
// Series folder should NOT appear for ebooks
result.Should().Be(Path.Combine("Brandon Sanderson", "The Way of Kings (2010)", "The Way of Kings.epub"));
}
// ── ComputeCanonicalRelativePath — audiobooks ─────────────────────────────
[Fact]
public void Audiobook_WithoutSeries_ProducesAuthorTitleTitle()
{
var book = BookFactory.Create(title: "Project Hail Mary", year: 2021)
.WithAuthors((1, "Andy Weir"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".m4b", isAudio: true);
result.Should().Be(Path.Combine("Andy Weir", "Project Hail Mary", "Project Hail Mary.m4b"));
}
[Fact]
public void Audiobook_WithSeries_InsertSeriesFolderBetweenAuthorAndTitle()
{
var book = BookFactory.Create(title: "The Way of Kings", year: 2010)
.WithAuthors((1, "Brandon Sanderson"))
.WithSeries(seriesName: "The Stormlight Archive");
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".m4b", isAudio: true);
result.Should().Be(Path.Combine("Brandon Sanderson", "The Stormlight Archive", "The Way of Kings", "The Way of Kings.m4b"));
}
[Fact]
public void Audiobook_Mp3Extension_PreservedInFilename()
{
var book = BookFactory.Create(title: "Dune", year: 1965)
.WithAuthors((1, "Frank Herbert"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".mp3", isAudio: true);
result.Should().Be(Path.Combine("Frank Herbert", "Dune", "Dune.mp3"));
}
// ── Multiple authors ──────────────────────────────────────────────────────
[Fact]
public void MultipleAuthors_UsesPrimaryAuthorForPath()
{
var book = BookFactory.Create(title: "Good Omens", year: 1990)
.WithAuthors((1, "Terry Pratchett"), (2, "Neil Gaiman"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().StartWith("Terry Pratchett");
result.Should().NotContain("Neil Gaiman");
}
[Fact]
public void NoAuthors_UsesUnknownFolder()
{
var book = BookFactory.Create(title: "Anonymous Work", year: 2000);
// No authors added
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().StartWith("Unknown");
}
// ── Sanitization edge cases ───────────────────────────────────────────────
[Fact]
public void TitleWithInvalidChars_Sanitized()
{
var book = BookFactory.Create(title: "Title: A Story", year: 2020)
.WithAuthors((1, "Author Name"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().Be(Path.Combine("Author Name", "Title_ A Story (2020)", "Title_ A Story.epub"));
}
}
@@ -87,4 +87,39 @@ public class FileScannerServiceTests
var act = () => FileScannerService.FindMatch(file, books);
act.Should().NotThrow();
}
[Fact]
public void FindMatch_YearPrefixedFilename_StillMatchesTitle()
{
var books = new[] { BookFactory.Create(id: 1, title: "Project Hail Mary") };
var file = new BookFile { Filename = "2021 - Project Hail Mary.epub", Path = "2021 - Project Hail Mary.epub" };
FileScannerService.FindMatch(file, books)!.Id.Should().Be(1);
}
[Fact]
public void FindMatch_AudiobookInSubfolder_MatchesByParentFolderName()
{
var books = new[] { BookFactory.Create(id: 2, title: "Project Hail Mary") };
// m4b lives at "Andy Weir/2021 - Project Hail Mary/Project Hail Mary.m4b"
var file = new BookFile
{
Filename = "Project Hail Mary.m4b",
Path = "Andy Weir/2021 - Project Hail Mary/Project Hail Mary.m4b",
};
FileScannerService.FindMatch(file, books)!.Id.Should().Be(2);
}
// ── StripYearPrefix ───────────────────────────────────────────────────────
[Theory]
[InlineData("2021 - project hail mary", "project hail mary")]
[InlineData("2015 - artemis", "artemis")]
[InlineData("project hail mary", "project hail mary")] // no prefix → unchanged
[InlineData("diary of an asscan", "diary of an asscan")]
public void StripYearPrefix_ReturnsExpected(string input, string expected)
{
FileScannerService.StripYearPrefix(input).Should().Be(expected);
}
}
@@ -0,0 +1,136 @@
using FluentAssertions;
using PageManager.Api.Services;
using PageManager.Api.Tests.Helpers;
namespace PageManager.Api.Tests.Unit.Services;
public class MetadataWriterServiceTests
{
// ── ParseOpfPath ──────────────────────────────────────────────────────────
[Fact]
public void ParseOpfPath_ReturnsFullPath()
{
var xml = """
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
""";
MetadataWriterService.ParseOpfPath(xml).Should().Be("OEBPS/content.opf");
}
// ── ModifyOpfXml ──────────────────────────────────────────────────────────
private const string MinimalOpf = """
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Old Title</dc:title>
<dc:creator>Old Author</dc:creator>
<dc:language>en</dc:language>
</metadata>
<manifest/>
<spine/>
</package>
""";
[Fact]
public void ModifyOpfXml_UpdatesTitle()
{
var book = BookFactory.Create(title: "Dune", year: 1965).WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("<dc:title>Dune</dc:title>");
}
[Fact]
public void ModifyOpfXml_UpdatesCreator()
{
var book = BookFactory.Create(title: "Dune").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("Frank Herbert");
}
[Fact]
public void ModifyOpfXml_AddsDescription_WhenPresent()
{
var book = BookFactory.Create(title: "Dune", description: "A sci-fi epic.").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("A sci-fi epic.");
}
[Fact]
public void ModifyOpfXml_SkipsDescription_WhenNull()
{
var book = BookFactory.Create(title: "Dune", description: null).WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().NotContain("dc:description");
}
[Fact]
public void ModifyOpfXml_AddsIsbnIdentifier_WhenPresent()
{
var book = BookFactory.Create(title: "Dune", isbn: "9780441013593").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("9780441013593");
result.Should().Contain("ISBN");
}
[Fact]
public void ModifyOpfXml_AddsDate_WhenYearPresent()
{
var book = BookFactory.Create(title: "Dune", year: 1965).WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("1965");
}
[Fact]
public void ModifyOpfXml_PreservesExistingXmlStructure()
{
var book = BookFactory.Create(title: "Dune").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
// Manifest and spine should still be present
result.Should().Contain("manifest");
result.Should().Contain("spine");
result.Should().Contain("dc:language");
}
[Fact]
public void ModifyOpfXml_WithMultipleAuthors_UsesPrimaryAuthorForCreator()
{
var book = BookFactory.Create(title: "Good Omens")
.WithAuthors((1, "Terry Pratchett"), (2, "Neil Gaiman"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("Terry Pratchett");
}
[Fact]
public void ModifyOpfXml_WithNoAuthors_DoesNotAddCreator()
{
var book = BookFactory.Create(title: "Anonymous");
// No authors added
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
// Should not crash and should not add empty creator
result.Should().NotContain("<dc:creator />");
}
[Fact]
public void ModifyOpfXml_UpdatesExistingIsbnIdentifier_RatherThanAdding()
{
var opfWithIsbn = MinimalOpf.Replace(
"<dc:language>en</dc:language>",
"<dc:language>en</dc:language>\n <dc:identifier opf:scheme=\"ISBN\">OLD-ISBN</dc:identifier>");
var book = BookFactory.Create(title: "Dune", isbn: "9780441013593").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(opfWithIsbn, book);
result.Should().Contain("9780441013593");
result.Should().NotContain("OLD-ISBN");
// Should not have two ISBN identifiers
result.Split("ISBN").Length.Should().BeLessThanOrEqualTo(3); // "ISBN" appears in scheme + value
}
}
@@ -40,4 +40,6 @@ public class BookDto
public string? Isbn { get; set; }
public int? HardcoverId { get; set; }
public EditionDto[] Editions { get; set; } = [];
/// <summary>Distinct file formats of BookFile records actually in the library.</summary>
public string[] LocalFileFormats { get; set; } = [];
}
@@ -0,0 +1,34 @@
namespace PageManager.Api.Api.Dtos;
public class DownloadDto
{
public string Id { get; set; } = string.Empty;
public string Filename { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public long DownloadedBytes { get; set; }
public string Status { get; set; } = string.Empty;
public string SourceType { get; set; } = string.Empty;
public string? TorrentHash { get; set; }
public int? BookId { get; set; }
public string? BookTitle { get; set; }
public string? Error { get; set; }
public DateTime AddedAt { get; set; }
}
public class AddDownloadRequest
{
public string Magnet { get; set; } = string.Empty;
public int? BookId { get; set; }
}
public class TorrentSearchResultDto
{
public string Title { get; set; } = string.Empty;
public string? Magnet { get; set; }
public string? DownloadUrl { get; set; }
public long? SizeBytes { get; set; }
public int Seeders { get; set; }
public int Leechers { get; set; }
public string Indexer { get; set; } = string.Empty;
public DateTime? PublishDate { get; set; }
}
@@ -0,0 +1,21 @@
namespace PageManager.Api.Api.Dtos;
public class WantedBookDto
{
public int Id { get; set; }
public int BookId { get; set; }
public string BookTitle { get; set; } = string.Empty;
public string[] BookAuthors { get; set; } = [];
public string? BookCoverUrl { get; set; }
public DateTime AddedAt { get; set; }
public string Status { get; set; } = string.Empty;
public string? FormatPreference { get; set; }
public int MinSeeders { get; set; }
}
public class AddWantedRequest
{
public int BookId { get; set; }
public string? FormatPreference { get; set; }
public int MinSeeders { get; set; } = 1;
}
@@ -0,0 +1,100 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PageManager.Api.Services;
namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class DownloadsController(
AppDbContext db,
IQBittorrentClient torrent,
IConfiguration config,
ILogger<DownloadsController> logger) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<DownloadDto>> GetDownloads()
{
var items = await db.ImportQueueItems
.Include(i => i.Book)
.OrderByDescending(i => i.AddedAt)
.ToListAsync();
return items.Select(ToDto);
}
[HttpPost]
public async Task<ActionResult<DownloadDto>> AddDownload(AddDownloadRequest req)
{
if (string.IsNullOrWhiteSpace(req.Magnet))
return BadRequest("Magnet link is required.");
var savePath = config["Torrent:SavePath"] ?? "/data/books/incoming";
var error = await torrent.AddMagnetAsync(req.Magnet, savePath, "books");
if (error is not null)
{
logger.LogWarning("qBittorrent rejected magnet: {Error}", error);
return BadRequest($"qBittorrent rejected the magnet: {error}");
}
var hash = ExtractInfoHash(req.Magnet);
var item = new ImportQueueItem
{
Filename = hash ?? req.Magnet[..Math.Min(60, req.Magnet.Length)],
Status = QueueItemStatus.Downloading,
SourceType = DownloadSourceType.Torrent,
TorrentHash = hash,
TorrentMagnet = req.Magnet,
BookId = req.BookId,
};
db.ImportQueueItems.Add(item);
await db.SaveChangesAsync();
await db.Entry(item).Reference(i => i.Book).LoadAsync();
return CreatedAtAction(nameof(GetDownloads), ToDto(item));
}
[HttpDelete("{id}")]
public async Task<IActionResult> CancelDownload(string id)
{
var item = await db.ImportQueueItems.FindAsync(id);
if (item is null) return NotFound();
if (item.TorrentHash is not null)
{
try { await torrent.RemoveTorrentAsync(item.TorrentHash, deleteFiles: false); }
catch (Exception ex) { logger.LogWarning(ex, "Could not remove torrent {Hash}", item.TorrentHash); }
}
db.ImportQueueItems.Remove(item);
await db.SaveChangesAsync();
return NoContent();
}
private static DownloadDto ToDto(ImportQueueItem i) => new()
{
Id = i.Id,
Filename = i.Filename,
SizeBytes = i.SizeBytes,
DownloadedBytes = i.DownloadedBytes,
Status = i.Status.ToString().ToLowerInvariant(),
SourceType = i.SourceType.ToString().ToLowerInvariant(),
TorrentHash = i.TorrentHash,
BookId = i.BookId,
BookTitle = i.Book?.Title,
Error = i.Error,
AddedAt = i.AddedAt,
};
private static string? ExtractInfoHash(string magnet)
{
var m = System.Text.RegularExpressions.Regex.Match(magnet,
@"urn:btih:([a-fA-F0-9]{40}|[A-Z2-7]{32})",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return m.Success ? m.Groups[1].Value.ToLowerInvariant() : null;
}
}
@@ -8,7 +8,12 @@ namespace PageManager.Api.Controllers;
[ApiController]
[Route("api")]
public class FilesController(IFilesRepository filesRepo, IFileScannerService scanner) : ControllerBase
public class FilesController(
IFilesRepository filesRepo,
IFileScannerService scanner,
IFileOrganizerService organizer,
IMetadataWriterService metadataWriter,
ILogger<FilesController> logger) : ControllerBase
{
// GET /api/books/{id}/files
[HttpGet("books/{id:int}/files")]
@@ -35,9 +40,50 @@ public class FilesController(IFilesRepository filesRepo, IFileScannerService sca
{
var file = await filesRepo.AssignAsync(id, req.BookId, req.EditionId);
if (file is null) return NotFound();
// Auto-organize when assigning to a book (not when unlinking)
if (req.BookId is not null)
{
try
{
var result = await organizer.OrganizeAsync(id);
if (result.Moved)
logger.LogInformation("Auto-organized file {Id} to {Path}", id, result.NewRelativePath);
else if (result.SkipReason is not null)
logger.LogDebug("Organize skipped for file {Id}: {Reason}", id, result.SkipReason);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Auto-organize failed for file {Id}, assignment still saved.", id);
}
}
// Re-fetch so path/filename reflect any rename
file = await filesRepo.GetByIdAsync(id) ?? file;
return Ok(ToDto(file));
}
// POST /api/files/{id}/organize
[HttpPost("files/{id:int}/organize")]
public async Task<IActionResult> Organize(int id, CancellationToken ct)
{
try
{
var result = await organizer.OrganizeAsync(id, ct);
return Ok(new
{
moved = result.Moved,
newRelativePath = result.NewRelativePath,
newFilename = result.NewFilename,
skipReason = result.SkipReason,
});
}
catch (KeyNotFoundException)
{
return NotFound();
}
}
// DELETE /api/files/{id}
[HttpDelete("files/{id:int}")]
public async Task<IActionResult> Delete(int id)
@@ -47,6 +93,37 @@ public class FilesController(IFilesRepository filesRepo, IFileScannerService sca
return NoContent();
}
// POST /api/files/{id}/write-metadata
[HttpPost("files/{id:int}/write-metadata")]
public async Task<IActionResult> WriteMetadata(int id, CancellationToken ct)
{
try
{
var result = await metadataWriter.WriteAsync(id, ct);
return Ok(new { success = result.Success, message = result.Message });
}
catch (KeyNotFoundException)
{
return NotFound();
}
}
// POST /api/books/{id}/write-metadata
[HttpPost("books/{id:int}/write-metadata")]
public async Task<IActionResult> WriteMetadataForBook(int id, CancellationToken ct)
{
var files = (await filesRepo.GetByBookIdAsync(id)).ToList();
if (files.Count == 0) return Ok(new { results = Array.Empty<object>() });
var results = new List<object>();
foreach (var file in files)
{
var r = await metadataWriter.WriteAsync(file.Id, ct);
results.Add(new { fileId = file.Id, filename = file.Filename, success = r.Success, message = r.Message });
}
return Ok(new { results });
}
// POST /api/scan
[HttpPost("scan")]
public async Task<IActionResult> TriggerScan(CancellationToken ct)
@@ -6,7 +6,7 @@ namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SearchController(IHardcoverService hardcover) : ControllerBase
public class SearchController(IHardcoverService hardcover, IIndexerService indexer) : ControllerBase
{
[HttpGet("books")]
public async Task<ActionResult<IEnumerable<HardcoverBookResult>>> SearchBooks([FromQuery] string q)
@@ -17,4 +17,25 @@ public class SearchController(IHardcoverService hardcover) : ControllerBase
var results = await hardcover.SearchBooksAsync(q);
return Ok(results);
}
[HttpGet("torrents")]
public async Task<ActionResult<IEnumerable<TorrentSearchResultDto>>> SearchTorrents(
[FromQuery] string q, [FromQuery] string type = "ebook")
{
if (string.IsNullOrWhiteSpace(q))
return BadRequest("Query parameter 'q' is required.");
var results = await indexer.SearchAsync(q, type);
return Ok(results.Select(r => new TorrentSearchResultDto
{
Title = r.Title,
Magnet = r.Magnet,
DownloadUrl = r.DownloadUrl,
SizeBytes = r.SizeBytes,
Seeders = r.Seeders,
Leechers = r.Leechers,
Indexer = r.Indexer,
PublishDate = r.PublishDate,
}));
}
}
@@ -0,0 +1,118 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PageManager.Api.Services;
namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class WantedController(
AppDbContext db,
IIndexerService indexer) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<WantedBookDto>> GetWanted()
{
var items = await db.WantedBooks
.Include(w => w.Book).ThenInclude(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.OrderByDescending(w => w.AddedAt)
.ToListAsync();
return items.Select(ToDto);
}
[HttpPost]
public async Task<ActionResult<WantedBookDto>> AddWanted(AddWantedRequest req)
{
var book = await db.Books.FindAsync(req.BookId);
if (book is null) return NotFound($"Book {req.BookId} not found.");
// Upsert — one wanted entry per book
var existing = await db.WantedBooks.FirstOrDefaultAsync(w => w.BookId == req.BookId);
if (existing is not null)
{
existing.FormatPreference = ParseFormat(req.FormatPreference);
existing.MinSeeders = req.MinSeeders;
existing.Status = WantedStatus.Wanted;
}
else
{
existing = new WantedBook
{
BookId = req.BookId,
FormatPreference = ParseFormat(req.FormatPreference),
MinSeeders = req.MinSeeders,
};
db.WantedBooks.Add(existing);
}
await db.SaveChangesAsync();
await db.Entry(existing).Reference(w => w.Book).LoadAsync();
await db.Entry(existing.Book).Collection(b => b.BookAuthors).Query()
.Include(ba => ba.Author).LoadAsync();
return Ok(ToDto(existing));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> RemoveWanted(int id)
{
var item = await db.WantedBooks.FindAsync(id);
if (item is null) return NotFound();
db.WantedBooks.Remove(item);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("{id:int}/search-now")]
public async Task<ActionResult<IEnumerable<TorrentSearchResultDto>>> SearchNow(int id)
{
var w = await db.WantedBooks
.Include(w => w.Book).ThenInclude(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.FirstOrDefaultAsync(w => w.Id == id);
if (w is null) return NotFound();
var primaryAuthor = w.Book.BookAuthors.FirstOrDefault()?.Author.Name ?? "";
var query = string.IsNullOrEmpty(primaryAuthor)
? w.Book.Title
: $"{w.Book.Title} {primaryAuthor}";
var type = w.FormatPreference.HasValue && IsAudioFormat(w.FormatPreference.Value)
? "audiobook" : "ebook";
var results = await indexer.SearchAsync(query, type);
return Ok(results.Select(r => new TorrentSearchResultDto
{
Title = r.Title,
Magnet = r.Magnet,
DownloadUrl = r.DownloadUrl,
SizeBytes = r.SizeBytes,
Seeders = r.Seeders,
Leechers = r.Leechers,
Indexer = r.Indexer,
PublishDate = r.PublishDate,
}));
}
private static WantedBookDto ToDto(WantedBook w) => new()
{
Id = w.Id,
BookId = w.BookId,
BookTitle = w.Book?.Title ?? "",
BookAuthors = w.Book?.BookAuthors.Select(ba => ba.Author.Name).ToArray() ?? [],
BookCoverUrl = w.Book?.CoverUrl,
AddedAt = w.AddedAt,
Status = w.Status.ToString().ToLowerInvariant(),
FormatPreference = w.FormatPreference?.ToString()?.ToLowerInvariant(),
MinSeeders = w.MinSeeders,
};
private static FileFormat? ParseFormat(string? s) =>
Enum.TryParse<FileFormat>(s, ignoreCase: true, out var f) ? f : null;
private static bool IsAudioFormat(FileFormat f) =>
f is FileFormat.M4b or FileFormat.Mp3 or FileFormat.Aac or FileFormat.Flac;
}
@@ -14,6 +14,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<ImportSource> ImportSources => Set<ImportSource>();
public DbSet<ImportQueueItem> ImportQueueItems => Set<ImportQueueItem>();
public DbSet<BookFile> BookFiles => Set<BookFile>();
public DbSet<WantedBook> WantedBooks => Set<WantedBook>();
protected override void OnModelCreating(ModelBuilder model)
{
@@ -96,6 +97,26 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
{
e.Property(i => i.Id).ValueGeneratedNever();
e.Property(i => i.Status).HasConversion<string>();
e.Property(i => i.SourceType).HasConversion<string>();
e.HasOne(i => i.Book)
.WithMany()
.HasForeignKey(i => i.BookId)
.OnDelete(DeleteBehavior.SetNull);
});
// ── WantedBook ────────────────────────────────────────────────────────
model.Entity<WantedBook>(e =>
{
e.Property(w => w.Status).HasConversion<string>();
e.Property(w => w.FormatPreference).HasConversion<string>();
e.HasOne(w => w.Book)
.WithMany(b => b.WantedBooks)
.HasForeignKey(w => w.BookId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(w => w.BookId).IsUnique(); // one entry per book
});
// ── BookFile ──────────────────────────────────────────────────────────
@@ -29,4 +29,5 @@ public class Book
public ICollection<SeriesEntry> SeriesEntries { get; set; } = [];
public ICollection<Edition> Editions { get; set; } = [];
public ICollection<BookFile> BookFiles { get; set; } = [];
public ICollection<WantedBook> WantedBooks { get; set; } = [];
}
@@ -1,6 +1,7 @@
namespace PageManager.Api.Data.Models;
public enum QueueItemStatus { Queued, Downloading, Completed, Failed }
public enum DownloadSourceType { Manual, Torrent, LocalScan }
public class ImportQueueItem
{
@@ -9,6 +10,12 @@ public class ImportQueueItem
public long SizeBytes { get; set; }
public long DownloadedBytes { get; set; }
public QueueItemStatus Status { get; set; } = QueueItemStatus.Queued;
public string Source { get; set; } = string.Empty;
public DownloadSourceType SourceType { get; set; } = DownloadSourceType.Manual;
public string? TorrentHash { get; set; }
public string? TorrentMagnet { get; set; }
public int? BookId { get; set; }
public Book? Book { get; set; }
public string? Source { get; set; }
public string? Error { get; set; }
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,15 @@
namespace PageManager.Api.Data.Models;
public enum WantedStatus { Wanted, Downloading, Found }
public class WantedBook
{
public int Id { get; set; }
public int BookId { get; set; }
public Book Book { get; set; } = null!;
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
public WantedStatus Status { get; set; } = WantedStatus.Wanted;
/// <summary>Preferred file format — null means any.</summary>
public FileFormat? FormatPreference { get; set; }
public int MinSeeders { get; set; } = 1;
}
@@ -11,6 +11,7 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.Include(b => b.BookFiles)
.ToListAsync()
.ContinueWith(t => (IEnumerable<Book>)t.Result);
@@ -19,6 +20,7 @@ public class BooksRepository(AppDbContext db) : IBooksRepository
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.Include(b => b.Editions)
.Include(b => b.BookFiles)
.FirstOrDefaultAsync(b => b.Id == id);
public Task<Book?> FindByHardcoverIdAsync(int hardcoverId) =>
@@ -0,0 +1,134 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using PageManager.Api.Data;
#nullable disable
namespace PageManager.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260330000000_AddTorrentSupport")]
public partial class AddTorrentSupport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ── Extend import_queue_items ────────────────────────────────────────
migrationBuilder.AddColumn<string>(
name: "source_type",
table: "import_queue_items",
type: "text",
nullable: false,
defaultValue: "Manual");
migrationBuilder.AddColumn<string>(
name: "torrent_hash",
table: "import_queue_items",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "torrent_magnet",
table: "import_queue_items",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "book_id",
table: "import_queue_items",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "added_at",
table: "import_queue_items",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now()");
// Make source nullable (was required)
migrationBuilder.AlterColumn<string>(
name: "source",
table: "import_queue_items",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.CreateIndex(
name: "ix_import_queue_items_book_id",
table: "import_queue_items",
column: "book_id");
migrationBuilder.AddForeignKey(
name: "fk_import_queue_items_books_book_id",
table: "import_queue_items",
column: "book_id",
principalTable: "books",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
// ── Create wanted_books ──────────────────────────────────────────────
migrationBuilder.CreateTable(
name: "wanted_books",
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),
added_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
format_preference = table.Column<string>(type: "text", nullable: true),
min_seeders = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_wanted_books", x => x.id);
table.ForeignKey(
name: "fk_wanted_books_books_book_id",
column: x => x.book_id,
principalTable: "books",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_wanted_books_book_id",
table: "wanted_books",
column: "book_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "wanted_books");
migrationBuilder.DropForeignKey(
name: "fk_import_queue_items_books_book_id",
table: "import_queue_items");
migrationBuilder.DropIndex(
name: "ix_import_queue_items_book_id",
table: "import_queue_items");
migrationBuilder.DropColumn(name: "source_type", table: "import_queue_items");
migrationBuilder.DropColumn(name: "torrent_hash", table: "import_queue_items");
migrationBuilder.DropColumn(name: "torrent_magnet", table: "import_queue_items");
migrationBuilder.DropColumn(name: "book_id", table: "import_queue_items");
migrationBuilder.DropColumn(name: "added_at", table: "import_queue_items");
migrationBuilder.AlterColumn<string>(
name: "source",
table: "import_queue_items",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
}
}
@@ -299,6 +299,14 @@ namespace PageManager.Api.Migrations
.HasColumnType("text")
.HasColumnName("id");
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("added_at");
b.Property<int?>("BookId")
.HasColumnType("integer")
.HasColumnName("book_id");
b.Property<long>("DownloadedBytes")
.HasColumnType("bigint")
.HasColumnName("downloaded_bytes");
@@ -317,21 +325,79 @@ namespace PageManager.Api.Migrations
.HasColumnName("size_bytes");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.Property<string>("SourceType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source_type")
.HasConversion<string>();
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("TorrentHash")
.HasColumnType("text")
.HasColumnName("torrent_hash");
b.Property<string>("TorrentMagnet")
.HasColumnType("text")
.HasColumnName("torrent_magnet");
b.HasKey("Id")
.HasName("pk_import_queue_items");
b.HasIndex("BookId")
.HasDatabaseName("ix_import_queue_items_book_id");
b.ToTable("import_queue_items", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.WantedBook", 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<string>("FormatPreference")
.HasColumnType("text")
.HasColumnName("format_preference")
.HasConversion<string>();
b.Property<int>("MinSeeders")
.HasColumnType("integer")
.HasColumnName("min_seeders");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status")
.HasConversion<string>();
b.HasKey("Id")
.HasName("pk_wanted_books");
b.HasIndex("BookId")
.IsUnique()
.HasDatabaseName("ix_wanted_books_book_id");
b.ToTable("wanted_books", (string)null);
});
modelBuilder.Entity("PageManager.Api.Data.Models.ImportSource", b =>
{
b.Property<string>("Id")
@@ -501,6 +567,29 @@ namespace PageManager.Api.Migrations
b.Navigation("BookAuthors");
});
modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany()
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_import_queue_items_books_book_id");
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.WantedBook", b =>
{
b.HasOne("PageManager.Api.Data.Models.Book", "Book")
.WithMany("WantedBooks")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wanted_books_books_book_id");
b.Navigation("Book");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Book", b =>
{
b.Navigation("BookAuthors");
@@ -510,6 +599,8 @@ namespace PageManager.Api.Migrations
b.Navigation("Editions");
b.Navigation("SeriesEntries");
b.Navigation("WantedBooks");
});
modelBuilder.Entity("PageManager.Api.Data.Models.Edition", b =>
@@ -21,9 +21,11 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="Scalar.AspNetCore" Version="2.13.8" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
@@ -28,6 +28,9 @@ builder.Host.UseSerilog((ctx, services, cfg) => cfg
.MinimumLevel.Override("Microsoft.AspNetCore.StaticFiles", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", Serilog.Events.LogEventLevel.Warning)
// Show Debug logs for file scanning so every step is visible during development
.MinimumLevel.Override("PageManager.Api.Services.FileScannerService", Serilog.Events.LogEventLevel.Debug)
.MinimumLevel.Override("PageManager.Api.Services.FileScannerBackgroundService", Serilog.Events.LogEventLevel.Debug)
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"));
builder.Services.AddControllers();
@@ -39,8 +42,19 @@ builder.Services.AddHttpClient<IHardcoverService, HardcoverService>();
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IFilesRepository, FilesRepository>();
builder.Services.AddScoped<IFileScannerService, FileScannerService>();
builder.Services.AddScoped<IFileOrganizerService, FileOrganizerService>();
builder.Services.AddHttpClient<IMetadataWriterService, MetadataWriterService>();
builder.Services.AddSingleton<IFileSystem, PhysicalFileSystem>();
builder.Services.AddHostedService<FileScannerBackgroundService>();
// Phase 4: torrent + indexer
builder.Services.AddSingleton<IIndexerService, IndexerService>();
builder.Services.AddSingleton<IQBittorrentClient, QBittorrentClient>();
builder.Services.AddHostedService<DownloadWorkerService>();
builder.Services.AddHostedService<MonitoringWorkerService>();
builder.Services.AddHttpClient("qbittorrent");
builder.Services.AddHttpClient("indexer");
builder.Services.AddOpenApi();
builder.Services.AddDbContext<AppDbContext>(options =>
@@ -162,6 +162,10 @@ public class BooksService(IBooksRepository repo, IHardcoverService hardcover) :
CoverUrl = book.CoverUrl,
Isbn = book.Isbn,
HardcoverId = book.HardcoverId,
LocalFileFormats = book.BookFiles
.Select(f => f.Format.ToString().ToLowerInvariant())
.Distinct()
.ToArray(),
Editions = book.Editions.Select(e => new EditionDto
{
Id = e.Id,
@@ -0,0 +1,113 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Services;
/// <summary>
/// Background service that polls qBittorrent every 30 seconds and syncs
/// torrent progress/state back into the ImportQueueItems table.
/// </summary>
public class DownloadWorkerService(
IServiceScopeFactory scopeFactory,
IQBittorrentClient torrent,
ILogger<DownloadWorkerService> logger) : BackgroundService
{
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Download worker started.");
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(PollInterval, stoppingToken);
try { await SyncAsync(stoppingToken); }
catch (OperationCanceledException) { break; }
catch (Exception ex) { logger.LogError(ex, "Download worker sync failed."); }
}
logger.LogInformation("Download worker stopped.");
}
private async Task SyncAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var scanner = scope.ServiceProvider.GetRequiredService<IFileScannerService>();
var active = await db.ImportQueueItems
.Where(i => i.Status == QueueItemStatus.Downloading && i.TorrentHash != null)
.ToListAsync(ct);
if (active.Count == 0) return;
IReadOnlyList<TorrentStatus> statuses;
try { statuses = await torrent.GetTorrentsAsync(ct); }
catch (Exception ex)
{
logger.LogWarning(ex, "Could not reach qBittorrent, skipping sync.");
return;
}
var byHash = statuses.ToDictionary(s => s.Hash.ToLowerInvariant());
var triggerScan = false;
foreach (var item in active)
{
if (!byHash.TryGetValue(item.TorrentHash!.ToLowerInvariant(), out var ts))
{
logger.LogDebug("No status from qBittorrent for hash {Hash}", item.TorrentHash);
continue;
}
item.SizeBytes = ts.SizeBytes;
item.DownloadedBytes = ts.Downloaded;
switch (ts.State)
{
case TorrentState.Seeding:
case TorrentState.Completed:
item.Status = QueueItemStatus.Completed;
logger.LogInformation("Download completed: {Name}", item.Filename);
triggerScan = true;
break;
case TorrentState.Error:
item.Status = QueueItemStatus.Failed;
item.Error = "qBittorrent reported an error.";
logger.LogWarning("Download failed: {Name}", item.Filename);
break;
}
}
await db.SaveChangesAsync(ct);
if (triggerScan)
{
logger.LogInformation("Download(s) completed — triggering library scan.");
try { await scanner.ScanAsync(ct); }
catch (Exception ex) { logger.LogWarning(ex, "Post-download scan failed."); }
}
// Update WantedBook status for completed downloads
var completedBookIds = active
.Where(i => i.Status == QueueItemStatus.Completed && i.BookId.HasValue)
.Select(i => i.BookId!.Value)
.ToHashSet();
if (completedBookIds.Count > 0)
{
var wantedItems = await db.WantedBooks
.Where(w => completedBookIds.Contains(w.BookId) && w.Status == WantedStatus.Downloading)
.ToListAsync(ct);
foreach (var w in wantedItems)
w.Status = WantedStatus.Found;
if (wantedItems.Count > 0)
await db.SaveChangesAsync(ct);
}
}
}
@@ -0,0 +1,142 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Services;
public class FileOrganizerService(
AppDbContext db,
IFileSystem fileSystem,
IConfiguration config,
ILogger<FileOrganizerService> logger) : IFileOrganizerService
{
private static readonly FileFormat[] AudioFormats = [FileFormat.M4b, FileFormat.Mp3, FileFormat.Aac, FileFormat.Flac];
public async Task<OrganizeResult> OrganizeAsync(int fileId, CancellationToken ct = default)
{
var file = await db.BookFiles.FindAsync([fileId], ct);
if (file is null)
throw new KeyNotFoundException($"BookFile {fileId} not found.");
if (file.BookId is null)
return Skip(file, "File is not assigned to a book.");
var book = await db.Books
.Include(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(b => b.SeriesEntries).ThenInclude(se => se.Series)
.FirstOrDefaultAsync(b => b.Id == file.BookId, ct);
if (book is null)
return Skip(file, "Book not found.");
var isAudio = AudioFormats.Contains(file.Format);
var destRoot = isAudio ? config["LibraryPaths:Audiobooks"] : config["LibraryPaths:Books"];
if (string.IsNullOrWhiteSpace(destRoot))
return Skip(file, "Destination root not configured.");
var sourceRoot = ResolveSourceRoot(file);
if (string.IsNullOrWhiteSpace(sourceRoot))
return Skip(file, "Source root not found.");
var currentAbsPath = Path.Combine(sourceRoot, file.Path);
if (!fileSystem.FileExists(currentAbsPath))
return Skip(file, $"File not found on disk: {currentAbsPath}");
var ext = Path.GetExtension(file.Filename);
var canonicalRelPath = ComputeCanonicalRelativePath(book, ext, isAudio);
var canonicalAbsPath = Path.Combine(destRoot, canonicalRelPath);
if (string.Equals(
Path.GetFullPath(currentAbsPath),
Path.GetFullPath(canonicalAbsPath),
StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation("File {Id} already at canonical path: {Path}", fileId, canonicalRelPath);
return new OrganizeResult(false, currentAbsPath, canonicalAbsPath, canonicalRelPath, Path.GetFileName(canonicalRelPath), "Already organized.");
}
canonicalAbsPath = ResolveCollision(canonicalAbsPath);
canonicalRelPath = Path.GetRelativePath(destRoot, canonicalAbsPath);
logger.LogInformation("Organizing file {Id}: {From} → {To}", fileId, currentAbsPath, canonicalAbsPath);
fileSystem.EnsureDirectoryExists(Path.GetDirectoryName(canonicalAbsPath)!);
await fileSystem.CopyFileAsync(currentAbsPath, canonicalAbsPath, ct);
fileSystem.DeleteFile(currentAbsPath);
file.Path = canonicalRelPath;
file.Filename = Path.GetFileName(canonicalRelPath);
file.SourceId = null;
await db.SaveChangesAsync(ct);
logger.LogInformation("Organized file {Id} → {Path}", fileId, canonicalRelPath);
return new OrganizeResult(true, currentAbsPath, canonicalAbsPath, canonicalRelPath, file.Filename);
}
// ── Helpers (internal for testing) ───────────────────────────────────────
internal static string ComputeCanonicalRelativePath(Book book, string ext, bool isAudio)
{
var authorName = book.BookAuthors.FirstOrDefault()?.Author.Name ?? "Unknown";
var titleSeg = SanitizePathComponent(book.Title);
var authorSeg = SanitizePathComponent(authorName);
var filename = titleSeg + ext;
if (isAudio)
{
// AudioBookShelf: Author/[Series/]Title/Title.ext
var series = book.SeriesEntries.FirstOrDefault()?.Series.Name;
return series is not null
? Path.Combine(authorSeg, SanitizePathComponent(series), titleSeg, filename)
: Path.Combine(authorSeg, titleSeg, filename);
}
else
{
// Ebook: Author/Title (Year)/Title.ext
var folderSeg = book.Year.HasValue
? $"{titleSeg} ({book.Year})"
: titleSeg;
return Path.Combine(authorSeg, folderSeg, filename);
}
}
internal static string SanitizePathComponent(string s)
{
var invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(s.Length);
foreach (var c in s)
sb.Append(invalid.Contains(c) ? '_' : c);
return sb.ToString().Trim().TrimEnd('.');
}
// ── Private helpers ───────────────────────────────────────────────────────
private string? ResolveSourceRoot(BookFile file)
{
if (string.IsNullOrEmpty(file.SourceId))
{
var isAudio = AudioFormats.Contains(file.Format);
return isAudio ? config["LibraryPaths:Audiobooks"] : config["LibraryPaths:Books"];
}
return db.ImportSources.Find(file.SourceId)?.Path;
}
private string ResolveCollision(string path)
{
if (!fileSystem.FileExists(path)) return path;
var dir = Path.GetDirectoryName(path)!;
var stem = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (var i = 2; i < 100; i++)
{
var candidate = Path.Combine(dir, $"{stem} ({i}){ext}");
if (!fileSystem.FileExists(candidate)) return candidate;
}
return path;
}
private static OrganizeResult Skip(BookFile file, string reason) =>
new(false, null, null, file.Path, file.Filename, reason);
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PageManager.Api.Data.Repositories;
@@ -10,77 +11,126 @@ public class FileScannerService(
IFilesRepository filesRepo,
IBooksRepository booksRepo,
IFileSystem fileSystem,
IConfiguration config,
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
logger.LogInformation("=== Library scan started ===");
// ── 1. Collect scan targets ───────────────────────────────────────────
// DB-configured sources
var dbSources = await db.ImportSources
.Where(s => s.Enabled && s.Type == ImportSourceType.Folder)
.ToListAsync(cancellationToken);
if (sources.Count == 0)
logger.LogInformation("DB import sources (enabled, type=Folder): {Count}", dbSources.Count);
foreach (var s in dbSources)
logger.LogInformation(" DB source '{Name}': {Path}", s.Name, s.Path);
// appsettings LibraryPaths — used when no DB source covers the same path
var configSources = BuildConfigSources();
logger.LogInformation("Config LibraryPaths sources: {Count}", configSources.Count);
foreach (var s in configSources)
logger.LogInformation(" Config source '{Name}': {Path}", s.Name, s.Path);
// Merge: config paths that aren't already covered by a DB source
var dbPaths = dbSources.Select(s => NormalizePath(s.Path)).ToHashSet(StringComparer.OrdinalIgnoreCase);
var extraFromConfig = configSources
.Where(s => !dbPaths.Contains(NormalizePath(s.Path)))
.ToList();
var allSources = dbSources.Concat(extraFromConfig).ToList();
if (allSources.Count == 0)
{
logger.LogDebug("No enabled folder sources configured — skipping scan");
logger.LogWarning(
"No scan sources found. Add rows to ImportSources in the DB, " +
"or set LibraryPaths:Books / LibraryPaths:Audiobooks in appsettings.json.");
return;
}
foreach (var source in sources)
logger.LogInformation("Total sources to scan: {Count}", allSources.Count);
// ── 2. Scan each source ───────────────────────────────────────────────
var totalDiscovered = 0;
foreach (var source in allSources)
{
if (cancellationToken.IsCancellationRequested) break;
if (!fileSystem.DirectoryExists(source.Path))
{
logger.LogWarning("Source directory not found: {Path}", source.Path);
logger.LogWarning("Source directory not found, skipping: {Path}", source.Path);
continue;
}
logger.LogInformation("Scanning source '{Name}' at {Path}", source.Name, source.Path);
await ScanSourceAsync(source, cancellationToken);
logger.LogInformation("Scanning '{Name}' {Path}", source.Name, source.Path);
var discovered = await ScanSourceAsync(source, cancellationToken);
totalDiscovered += discovered;
logger.LogInformation("Finished '{Name}': {Discovered} new file(s)", source.Name, discovered);
}
logger.LogInformation("Scan complete. Total new files discovered: {Total}", totalDiscovered);
// ── 3. Auto-match unmatched files against books in DB ─────────────────
await AutoMatchAsync(cancellationToken);
logger.LogInformation("=== Library scan finished ===");
}
private async Task ScanSourceAsync(ImportSource source, CancellationToken ct)
private async Task<int> ScanSourceAsync(ImportSource source, CancellationToken ct)
{
IEnumerable<string> allFiles;
try
{
allFiles = fileSystem.EnumerateFiles(source.Path, SearchOption.AllDirectories);
allFiles = fileSystem.EnumerateFiles(source.Path, SearchOption.AllDirectories).ToList();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to enumerate files in {Path}", source.Path);
return;
return 0;
}
foreach (var fullPath in allFiles)
var allFilesList = allFiles.ToList();
logger.LogDebug("Found {Total} total files (all types) under {Path}", allFilesList.Count, source.Path);
var discovered = 0;
foreach (var fullPath in allFilesList)
{
if (ct.IsCancellationRequested) break;
var ext = System.IO.Path.GetExtension(fullPath);
var ext = Path.GetExtension(fullPath);
var format = GetFormatFromExtension(ext);
if (format is null) continue;
if (format is null)
{
logger.LogDebug("Skipping non-book file: {Path}", fullPath);
continue;
}
try
{
logger.LogDebug("Processing book file: {Path}", fullPath);
var hash = await fileSystem.ComputeSha256Async(fullPath, ct);
logger.LogDebug("SHA-256: {Hash} for {Path}", hash, fullPath);
var existing = await filesRepo.FindByHashAsync(hash);
if (existing is not null)
{
logger.LogDebug("Skipping duplicate: {Path}", fullPath);
logger.LogInformation("Duplicate (already in DB, id={Id}): {Path}", existing.Id, fullPath);
continue;
}
var relativePath = System.IO.Path.GetRelativePath(source.Path, fullPath);
var filename = System.IO.Path.GetFileName(fullPath);
var relativePath = Path.GetRelativePath(source.Path, fullPath);
var filename = Path.GetFileName(fullPath);
var size = fileSystem.GetFileSize(fullPath);
await filesRepo.AddAsync(new BookFile
var bookFile = await filesRepo.AddAsync(new BookFile
{
SourceId = source.Id,
SourceId = string.IsNullOrEmpty(source.Id) ? null : source.Id,
Path = relativePath,
Filename = filename,
SizeBytes = size,
@@ -89,35 +139,73 @@ public class FileScannerService(
AddedAt = DateTime.UtcNow,
});
logger.LogInformation("Discovered: {Filename}", filename);
logger.LogInformation("Discovered [{Format}] {Filename} ({Size} bytes, id={Id})",
format, filename, size, bookFile.Id);
discovered++;
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing file: {Path}", fullPath);
}
}
return discovered;
}
private async Task AutoMatchAsync(CancellationToken ct)
{
var unmatched = (await filesRepo.GetUnmatchedAsync()).ToList();
logger.LogInformation("Auto-match: {Count} unmatched file(s) in DB", unmatched.Count);
if (unmatched.Count == 0) return;
var books = (await booksRepo.GetAllAsync()).ToList();
logger.LogInformation("Auto-match: comparing against {Count} book(s) in DB", books.Count);
var matched = 0;
foreach (var file in unmatched)
{
if (ct.IsCancellationRequested) break;
var matched = FindMatch(file, books);
if (matched is not null)
var book = FindMatch(file, books);
if (book is not null)
{
await filesRepo.AssignAsync(file.Id, matched.Id, null);
logger.LogInformation("Auto-matched '{Filename}' → '{Title}'", file.Filename, matched.Title);
await filesRepo.AssignAsync(file.Id, book.Id, null);
logger.LogInformation("Auto-matched '{Filename}' → '{Title}' (bookId={Id})",
file.Filename, book.Title, book.Id);
matched++;
}
else
{
logger.LogDebug("No match found for '{Filename}' (path={Path})", file.Filename, file.Path);
}
}
logger.LogInformation("Auto-match complete: {Matched}/{Total} file(s) matched", matched, unmatched.Count);
}
// ── Config source helpers ─────────────────────────────────────────────────
private List<ImportSource> BuildConfigSources()
{
var sources = new List<ImportSource>();
var booksPath = config["LibraryPaths:Books"];
var audiobooksPath = config["LibraryPaths:Audiobooks"];
// Id is left empty — config sources have no DB row, so SourceId on BookFile will be null
if (!string.IsNullOrWhiteSpace(booksPath))
sources.Add(new ImportSource { Id = string.Empty, Name = "Books (config)", Path = booksPath, Enabled = true, Type = ImportSourceType.Folder });
if (!string.IsNullOrWhiteSpace(audiobooksPath))
sources.Add(new ImportSource { Id = string.Empty, Name = "Audiobooks (config)", Path = audiobooksPath, Enabled = true, Type = ImportSourceType.Folder });
return sources;
}
private static string NormalizePath(string path) =>
Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
// ── Helpers (internal for unit testing) ──────────────────────────────────
internal static FileFormat? GetFormatFromExtension(string ext) =>
@@ -135,10 +223,27 @@ public class FileScannerService(
internal static Book? FindMatch(BookFile file, IEnumerable<Book> books)
{
var stem = System.IO.Path.GetFileNameWithoutExtension(file.Filename).ToLowerInvariant();
var stem = Path.GetFileNameWithoutExtension(file.Filename).ToLowerInvariant();
var strippedStem = StripYearPrefix(stem);
// Audiobooks stored as Author/YYYY - Title/Title.m4b — check parent folder too
var parent = Path.GetFileName(Path.GetDirectoryName(file.Path) ?? string.Empty) ?? string.Empty;
var strippedParent = StripYearPrefix(parent.ToLowerInvariant());
return books.FirstOrDefault(b =>
stem.Contains(b.Title.ToLowerInvariant()) ||
(b.Isbn is { Length: > 0 } isbn && stem.Contains(isbn)));
{
var t = b.Title.ToLowerInvariant();
return strippedStem.Contains(t) ||
t.Contains(strippedStem) ||
(strippedParent.Length > 0 && (strippedParent.Contains(t) || t.Contains(strippedParent))) ||
(b.Isbn is { Length: > 0 } isbn && (strippedStem.Contains(isbn) || strippedParent.Contains(isbn)));
});
}
// Strips a leading "YYYY - " prefix: "2021 - Project Hail Mary" → "project hail mary"
internal static string StripYearPrefix(string s)
{
var m = System.Text.RegularExpressions.Regex.Match(s, @"^\d{4}\s*-\s*(.+)$");
return m.Success ? m.Groups[1].Value.Trim() : s;
}
}
@@ -0,0 +1,18 @@
namespace PageManager.Api.Services;
public interface IFileOrganizerService
{
/// <summary>
/// Moves the file on disk to its canonical library path, then updates Path/Filename in DB.
/// No-ops if the file is already at the canonical path or has no book assigned.
/// </summary>
Task<OrganizeResult> OrganizeAsync(int fileId, CancellationToken ct = default);
}
public record OrganizeResult(
bool Moved,
string? OldAbsolutePath,
string? NewAbsolutePath,
string NewRelativePath,
string NewFilename,
string? SkipReason = null);
@@ -3,7 +3,11 @@ namespace PageManager.Api.Services;
public interface IFileSystem
{
bool DirectoryExists(string path);
bool FileExists(string path);
IEnumerable<string> EnumerateFiles(string path, SearchOption searchOption);
long GetFileSize(string path);
Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken = default);
void EnsureDirectoryExists(string path);
Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken ct = default);
void DeleteFile(string path);
}
@@ -0,0 +1,17 @@
namespace PageManager.Api.Services;
public record TorrentResult(
string Title,
string? Magnet,
string? DownloadUrl,
long? SizeBytes,
int Seeders,
int Leechers,
string Indexer,
DateTime? PublishDate);
public interface IIndexerService
{
/// <param name="type">"ebook" or "audiobook"</param>
Task<IReadOnlyList<TorrentResult>> SearchAsync(string query, string type, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
namespace PageManager.Api.Services;
public interface IMetadataWriterService
{
/// <summary>Writes book metadata into the file at <paramref name="fileId"/>.</summary>
Task<WriteMetadataResult> WriteAsync(int fileId, CancellationToken ct = default);
}
public record WriteMetadataResult(bool Success, string Message);
@@ -0,0 +1,19 @@
namespace PageManager.Api.Services;
public enum TorrentState { Downloading, Seeding, Completed, Error, Paused, Queued, Unknown }
public record TorrentStatus(
string Hash,
string Name,
long SizeBytes,
long Downloaded,
TorrentState State,
float Progress);
public interface IQBittorrentClient
{
Task<string?> AddMagnetAsync(string magnet, string savePath, string? category = null, CancellationToken ct = default);
Task<TorrentStatus?> GetTorrentAsync(string hash, CancellationToken ct = default);
Task<IReadOnlyList<TorrentStatus>> GetTorrentsAsync(CancellationToken ct = default);
Task RemoveTorrentAsync(string hash, bool deleteFiles = false, CancellationToken ct = default);
}
@@ -0,0 +1,19 @@
namespace PageManager.Api.Services;
public class IndexerConfig
{
public string Name { get; set; } = string.Empty;
/// <summary>"prowlarr" or "jackett"</summary>
public string Type { get; set; } = string.Empty;
public string BaseUrl { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public string BuildSearchUrl(string query, string categories) =>
Type.ToLowerInvariant() switch
{
"prowlarr" => $"{BaseUrl.TrimEnd('/')}/api/v1/indexer/all/newznab?t=search&q={Uri.EscapeDataString(query)}&cat={categories}&apikey={ApiKey}&limit=50",
"jackett" => $"{BaseUrl.TrimEnd('/')}/api/v2.0/indexers/all/results/torznab?t=search&q={Uri.EscapeDataString(query)}&cat={categories}&apikey={ApiKey}&limit=50",
_ => $"{BaseUrl.TrimEnd('/')}/api?t=search&q={Uri.EscapeDataString(query)}&cat={categories}&apikey={ApiKey}&limit=50",
};
}
@@ -0,0 +1,104 @@
using System.Xml.Linq;
namespace PageManager.Api.Services;
public class IndexerService(
IHttpClientFactory httpFactory,
IConfiguration config,
ILogger<IndexerService> logger) : IIndexerService
{
// Torznab categories
private const string EbookCats = "7000,7020";
private const string AudiobookCats = "3000,3030";
private static readonly XNamespace TorznabNs =
XNamespace.Get("http://torznab.com/schemas/2015/feed");
public async Task<IReadOnlyList<TorrentResult>> SearchAsync(
string query, string type, CancellationToken ct = default)
{
var indexers = config.GetSection("Indexers").Get<IndexerConfig[]>() ?? [];
var enabled = indexers.Where(i => i.Enabled).ToList();
if (enabled.Count == 0)
{
logger.LogWarning("No indexers configured. Add entries to Indexers[] in appsettings.");
return [];
}
var categories = type.Equals("audiobook", StringComparison.OrdinalIgnoreCase)
? AudiobookCats
: EbookCats;
var tasks = enabled.Select(idx => SearchOneAsync(idx, query, categories, ct));
var results = await Task.WhenAll(tasks);
return results
.SelectMany(r => r)
.OrderByDescending(r => r.Seeders)
.ToList();
}
private async Task<IEnumerable<TorrentResult>> SearchOneAsync(
IndexerConfig idx, string query, string cats, CancellationToken ct)
{
var url = idx.BuildSearchUrl(query, cats);
logger.LogDebug("Searching indexer '{Name}': {Url}", idx.Name, url);
try
{
var http = httpFactory.CreateClient("indexer");
var xml = await http.GetStringAsync(url, ct);
var parsed = ParseTorznabResponse(xml, idx.Name);
logger.LogInformation("Indexer '{Name}' returned {Count} results", idx.Name, parsed.Count);
return parsed;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Indexer '{Name}' search failed", idx.Name);
return [];
}
}
internal static List<TorrentResult> ParseTorznabResponse(string xml, string indexerName)
{
var doc = XDocument.Parse(xml);
var channel = doc.Root?.Element("channel");
if (channel is null) return [];
var results = new List<TorrentResult>();
foreach (var item in channel.Elements("item"))
{
var title = item.Element("title")?.Value ?? string.Empty;
var link = item.Element("link")?.Value;
var pubDate = TryParseDate(item.Element("pubDate")?.Value);
// torznab:attr elements
var attrs = item.Elements(TorznabNs + "attr")
.ToDictionary(
e => e.Attribute("name")?.Value ?? "",
e => e.Attribute("value")?.Value ?? "",
StringComparer.OrdinalIgnoreCase);
var magnet = attrs.GetValueOrDefault("magneturl");
var downloadUrl = attrs.GetValueOrDefault("downloadurl") ?? link;
var seeders = int.TryParse(attrs.GetValueOrDefault("seeders"), out var s) ? s : 0;
var leechers = int.TryParse(attrs.GetValueOrDefault("leechers"), out var l) ? l : 0;
var sizeAttr = attrs.GetValueOrDefault("size") ?? item.Element("size")?.Value;
long? sizeBytes = long.TryParse(sizeAttr, out var sz) ? sz : null;
if (string.IsNullOrEmpty(title)) continue;
results.Add(new TorrentResult(title, magnet, downloadUrl, sizeBytes,
seeders, leechers, indexerName, pubDate));
}
return results;
}
private static DateTime? TryParseDate(string? s)
{
if (string.IsNullOrEmpty(s)) return null;
return DateTime.TryParse(s, out var d) ? d : null;
}
}
@@ -0,0 +1,253 @@
using System.IO.Compression;
using System.Xml.Linq;
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PdfSharp.Pdf.IO;
namespace PageManager.Api.Services;
public class MetadataWriterService(
AppDbContext db,
HttpClient http,
IConfiguration config,
ILogger<MetadataWriterService> logger) : IMetadataWriterService
{
private static readonly FileFormat[] AudioFormats = [FileFormat.M4b, FileFormat.Mp3, FileFormat.Aac, FileFormat.Flac];
public async Task<WriteMetadataResult> WriteAsync(int fileId, CancellationToken ct = default)
{
var file = await db.BookFiles
.Include(f => f.Book!).ThenInclude(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Include(f => f.Book!).ThenInclude(b => b.SeriesEntries).ThenInclude(se => se.Series)
.FirstOrDefaultAsync(f => f.Id == fileId, ct);
if (file is null) return Fail("File record not found.");
if (file.BookId is null) return Fail("File is not assigned to a book.");
if (file.Book is null) return Fail("Book not found.");
var absPath = ResolveAbsolutePath(file);
if (absPath is null) return Fail("Library root path is not configured.");
if (!File.Exists(absPath)) return Fail($"File not found on disk: {absPath}");
logger.LogInformation("Writing metadata to file {Id} ({Format}): {Path}", fileId, file.Format, absPath);
try
{
return file.Format switch
{
FileFormat.Epub => await WriteEpubAsync(absPath, file.Book, ct),
FileFormat.Pdf => WritePdf(absPath, file.Book),
FileFormat.Mobi => Skip("Mobi metadata writing is not supported."),
_ when AudioFormats.Contains(file.Format) => await WriteAudioAsync(absPath, file.Book, ct),
_ => Fail($"Unsupported format: {file.Format}"),
};
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to write metadata to file {Id}", fileId);
return Fail(ex.Message);
}
}
// ── EPUB ──────────────────────────────────────────────────────────────────
private async Task<WriteMetadataResult> WriteEpubAsync(string path, Book book, CancellationToken ct)
{
string opfRelPath, opfContent;
using (var readArchive = ZipFile.OpenRead(path))
{
var containerEntry = readArchive.GetEntry("META-INF/container.xml")
?? throw new InvalidOperationException("META-INF/container.xml not found in EPUB.");
using var cs = containerEntry.Open();
using var cr = new StreamReader(cs);
opfRelPath = ParseOpfPath(await cr.ReadToEndAsync(ct));
var opfEntry = readArchive.GetEntry(opfRelPath)
?? throw new InvalidOperationException($"OPF file not found in EPUB: {opfRelPath}");
using var os = opfEntry.Open();
using var or = new StreamReader(os);
opfContent = await or.ReadToEndAsync(ct);
}
var modifiedOpf = ModifyOpfXml(opfContent, book);
// Modify in a temp copy, then replace original
var tmpPath = path + ".metadata.tmp";
try
{
File.Copy(path, tmpPath, overwrite: true);
using (var archive = ZipFile.Open(tmpPath, ZipArchiveMode.Update))
{
archive.GetEntry(opfRelPath)!.Delete();
var newEntry = archive.CreateEntry(opfRelPath, CompressionLevel.Optimal);
await using var writer = new StreamWriter(newEntry.Open());
await writer.WriteAsync(modifiedOpf.AsMemory(), ct);
}
File.Move(tmpPath, path, overwrite: true);
}
finally
{
if (File.Exists(tmpPath)) File.Delete(tmpPath);
}
logger.LogInformation("EPUB metadata written to {Path}", path);
return Ok("EPUB metadata written.");
}
// ── PDF ───────────────────────────────────────────────────────────────────
private WriteMetadataResult WritePdf(string path, Book book)
{
using var doc = PdfSharp.Pdf.IO.PdfReader.Open(path, PdfDocumentOpenMode.Modify);
doc.Info.Title = book.Title;
doc.Info.Author = string.Join(", ", book.BookAuthors.Select(ba => ba.Author.Name));
if (book.Description is not null)
doc.Info.Subject = book.Description;
doc.Save(path);
logger.LogInformation("PDF metadata written to {Path}", path);
return Ok("PDF metadata written.");
}
// ── Audio ─────────────────────────────────────────────────────────────────
private async Task<WriteMetadataResult> WriteAudioAsync(string path, Book book, CancellationToken ct)
{
byte[]? coverBytes = null;
if (book.CoverUrl is not null)
{
try
{
coverBytes = await http.GetByteArrayAsync(book.CoverUrl, ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to download cover for book {Id}, skipping cover embed.", book.Id);
}
}
var authors = book.BookAuthors.Select(ba => ba.Author.Name).ToArray();
using var tfile = TagLib.File.Create(path);
tfile.Tag.Title = book.Title;
tfile.Tag.Album = book.Title;
tfile.Tag.Performers = authors; // → "artist" tag
tfile.Tag.AlbumArtists = authors; // → "album_artist" tag — ABS writes same value to both
if (book.Year.HasValue)
tfile.Tag.Year = (uint)book.Year.Value;
if (book.Description is not null)
tfile.Tag.Comment = book.Description;
if (book.Genres is { Length: > 0 })
tfile.Tag.Genres = book.Genres;
// Series → "grouping" tag (ABS format: "Series Name #1")
var series = book.SeriesEntries.FirstOrDefault();
if (series is not null)
tfile.Tag.Grouping = $"{series.Series.Name} #{series.Position}";
if (coverBytes is { Length: > 0 })
{
tfile.Tag.Pictures =
[
new TagLib.Picture(new TagLib.ByteVector(coverBytes))
{
Type = TagLib.PictureType.FrontCover,
MimeType = DetectImageMimeType(coverBytes),
}
];
}
tfile.Save();
logger.LogInformation("Audio metadata written to {Path}", path);
return Ok("Audio metadata written.");
}
// ── Helpers (internal for testing) ───────────────────────────────────────
internal static string ParseOpfPath(string containerXml)
{
var doc = XDocument.Parse(containerXml);
var ns = XNamespace.Get("urn:oasis:names:tc:opendocument:xmlns:container");
return doc.Root!
.Element(ns + "rootfiles")!
.Element(ns + "rootfile")!
.Attribute("full-path")!.Value;
}
internal static string ModifyOpfXml(string opfXml, Book book)
{
var doc = XDocument.Parse(opfXml);
var opfNs = XNamespace.Get("http://www.idpf.org/2007/opf");
var dcNs = XNamespace.Get("http://purl.org/dc/elements/1.1/");
// metadata element may use the opf namespace or no namespace in EPUB 3
var metadata = doc.Root!.Element(opfNs + "metadata")
?? doc.Root.Element("metadata")
?? throw new InvalidOperationException("OPF <metadata> element not found.");
SetOrCreate(metadata, dcNs + "title", book.Title);
var primaryAuthor = book.BookAuthors.FirstOrDefault()?.Author.Name;
if (primaryAuthor is not null)
SetOrCreate(metadata, dcNs + "creator", primaryAuthor);
if (book.Description is not null)
SetOrCreate(metadata, dcNs + "description", book.Description);
if (book.Year.HasValue)
SetOrCreate(metadata, dcNs + "date", book.Year.Value.ToString());
if (book.Isbn is not null)
{
// Find existing ISBN identifier or create one
var isbnEl = metadata.Elements(dcNs + "identifier")
.FirstOrDefault(e =>
(e.Attribute(opfNs + "scheme")?.Value ?? e.Attribute("scheme")?.Value ?? "")
.Equals("ISBN", StringComparison.OrdinalIgnoreCase));
if (isbnEl is not null)
isbnEl.Value = book.Isbn;
else
metadata.Add(new XElement(dcNs + "identifier",
new XAttribute(opfNs + "scheme", "ISBN"), book.Isbn));
}
using var sw = new StringWriter();
doc.Save(sw);
return sw.ToString();
}
private static void SetOrCreate(XElement parent, XName name, string value)
{
var el = parent.Element(name);
if (el is not null)
el.Value = value;
else
parent.Add(new XElement(name, value));
}
private static string DetectImageMimeType(byte[] bytes)
{
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8) return "image/jpeg";
if (bytes.Length >= 4 && bytes[0] == 0x89 && bytes[1] == 0x50) return "image/png";
return "image/jpeg";
}
private string? ResolveAbsolutePath(BookFile file)
{
if (string.IsNullOrEmpty(file.SourceId))
{
var isAudio = AudioFormats.Contains(file.Format);
var root = isAudio ? config["LibraryPaths:Audiobooks"] : config["LibraryPaths:Books"];
return root is null ? null : Path.Combine(root, file.Path);
}
var source = db.ImportSources.Find(file.SourceId);
return source is null ? null : Path.Combine(source.Path, file.Path);
}
private static WriteMetadataResult Ok(string msg) => new(true, msg);
private static WriteMetadataResult Fail(string msg) => new(false, msg);
private static WriteMetadataResult Skip(string msg) => new(true, msg);
}
@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
namespace PageManager.Api.Services;
/// <summary>
/// Background service that periodically searches indexers for Wanted books
/// and auto-queues the best torrent found.
/// </summary>
public class MonitoringWorkerService(
IServiceScopeFactory scopeFactory,
IIndexerService indexer,
IQBittorrentClient torrent,
ILogger<MonitoringWorkerService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Monitoring worker started.");
// Initial delay so the app is fully started
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try { await RunCycleAsync(stoppingToken); }
catch (OperationCanceledException) { break; }
catch (Exception ex) { logger.LogError(ex, "Monitoring cycle failed."); }
using var scope = scopeFactory.CreateScope();
var cfg = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var intervalHours = cfg.GetValue("Monitoring:IntervalHours", 6);
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
}
logger.LogInformation("Monitoring worker stopped.");
}
private async Task RunCycleAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cfg = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var wanted = await db.WantedBooks
.Include(w => w.Book).ThenInclude(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.Where(w => w.Status == WantedStatus.Wanted)
.ToListAsync(ct);
logger.LogInformation("Monitoring: checking {Count} wanted book(s).", wanted.Count);
foreach (var w in wanted)
{
if (ct.IsCancellationRequested) break;
try { await ProcessWantedAsync(w, db, cfg, ct); }
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to process wanted book '{Title}'.", w.Book.Title);
}
}
}
private async Task ProcessWantedAsync(
WantedBook w, AppDbContext db, IConfiguration cfg, CancellationToken ct)
{
var primaryAuthor = w.Book.BookAuthors.FirstOrDefault()?.Author.Name ?? "";
var query = string.IsNullOrEmpty(primaryAuthor)
? w.Book.Title
: $"{w.Book.Title} {primaryAuthor}";
var type = w.FormatPreference.HasValue && IsAudioFormat(w.FormatPreference.Value)
? "audiobook"
: "ebook";
logger.LogInformation("Monitoring: searching for '{Title}' ({Type})", w.Book.Title, type);
var results = await indexer.SearchAsync(query, type, ct);
var best = results.FirstOrDefault(r => r.Seeders >= w.MinSeeders && r.Magnet is not null);
if (best is null)
{
logger.LogDebug("No result with >= {MinSeeders} seeders for '{Title}'", w.MinSeeders, w.Book.Title);
return;
}
var savePath = cfg["Torrent:SavePath"] ?? "/data/books/incoming";
var error = await torrent.AddMagnetAsync(best.Magnet!, savePath, "books", ct);
if (error is not null)
{
logger.LogWarning("qBittorrent rejected magnet for '{Title}': {Error}", w.Book.Title, error);
return;
}
// Extract info hash from magnet for tracking
var hash = ExtractInfoHash(best.Magnet!);
var queueItem = new ImportQueueItem
{
Filename = best.Title,
SizeBytes = best.SizeBytes ?? 0,
Status = QueueItemStatus.Downloading,
SourceType = DownloadSourceType.Torrent,
TorrentHash = hash,
TorrentMagnet = best.Magnet,
BookId = w.BookId,
};
db.ImportQueueItems.Add(queueItem);
w.Status = WantedStatus.Downloading;
await db.SaveChangesAsync(ct);
logger.LogInformation("Auto-queued '{Title}' from {Indexer} ({Seeders} seeders)",
w.Book.Title, best.Indexer, best.Seeders);
}
private static bool IsAudioFormat(Data.Models.FileFormat f) =>
f is Data.Models.FileFormat.M4b or Data.Models.FileFormat.Mp3
or Data.Models.FileFormat.Aac or Data.Models.FileFormat.Flac;
private static string? ExtractInfoHash(string magnet)
{
var m = System.Text.RegularExpressions.Regex.Match(magnet,
@"urn:btih:([a-fA-F0-9]{40}|[A-Z2-7]{32})", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return m.Success ? m.Groups[1].Value.ToLowerInvariant() : null;
}
}
@@ -6,6 +6,8 @@ public class PhysicalFileSystem : IFileSystem
{
public bool DirectoryExists(string path) => Directory.Exists(path);
public bool FileExists(string path) => File.Exists(path);
public IEnumerable<string> EnumerateFiles(string path, SearchOption searchOption) =>
Directory.EnumerateFiles(path, "*", searchOption);
@@ -18,4 +20,15 @@ public class PhysicalFileSystem : IFileSystem
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public void EnsureDirectoryExists(string path) => Directory.CreateDirectory(path);
public async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken ct = default)
{
await using var src = File.OpenRead(sourcePath);
await using var dest = File.Create(destinationPath);
await src.CopyToAsync(dest, ct);
}
public void DeleteFile(string path) => File.Delete(path);
}
@@ -0,0 +1,178 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace PageManager.Api.Services;
/// <summary>Client for the qBittorrent Web API v2.</summary>
public class QBittorrentClient(
IHttpClientFactory httpFactory,
IConfiguration config,
ILogger<QBittorrentClient> logger) : IQBittorrentClient
{
private string? _sid;
private readonly SemaphoreSlim _loginLock = new(1, 1);
private string BaseUrl =>
(config["Torrent:QBittorrentUrl"] ?? "http://localhost:8080").TrimEnd('/');
// ── Public API ─────────────────────────────────────────────────────────────
public async Task<string?> AddMagnetAsync(
string magnet, string savePath, string? category = null, CancellationToken ct = default)
{
var fields = new Dictionary<string, string>
{
["urls"] = magnet,
["savepath"] = savePath,
};
if (!string.IsNullOrEmpty(category))
fields["category"] = category;
var response = await ExecuteAsync(
client => client.PostAsync($"{BaseUrl}/api/v2/torrents/add",
new FormUrlEncodedContent(fields), ct), ct);
var body = await response.Content.ReadAsStringAsync(ct);
logger.LogInformation("qBittorrent AddMagnet response: {Body}", body);
return body == "Ok." ? null : body;
}
public async Task<TorrentStatus?> GetTorrentAsync(string hash, CancellationToken ct = default)
{
var response = await ExecuteAsync(
client => client.GetAsync($"{BaseUrl}/api/v2/torrents/info?hashes={hash}", ct), ct);
var json = await response.Content.ReadAsStringAsync(ct);
var array = JsonNode.Parse(json)?.AsArray();
if (array is null || array.Count == 0) return null;
return ParseTorrentStatus(array[0]!.AsObject());
}
public async Task<IReadOnlyList<TorrentStatus>> GetTorrentsAsync(CancellationToken ct = default)
{
var response = await ExecuteAsync(
client => client.GetAsync($"{BaseUrl}/api/v2/torrents/info", ct), ct);
var json = await response.Content.ReadAsStringAsync(ct);
var array = JsonNode.Parse(json)?.AsArray();
if (array is null) return [];
return array
.Select(n => ParseTorrentStatus(n!.AsObject()))
.ToList();
}
public async Task RemoveTorrentAsync(string hash, bool deleteFiles = false, CancellationToken ct = default)
{
var fields = new Dictionary<string, string>
{
["hashes"] = hash,
["deleteFiles"] = deleteFiles ? "true" : "false",
};
await ExecuteAsync(
client => client.PostAsync($"{BaseUrl}/api/v2/torrents/delete",
new FormUrlEncodedContent(fields), ct), ct);
}
// ── Auth ───────────────────────────────────────────────────────────────────
private async Task EnsureLoggedInAsync(CancellationToken ct)
{
if (_sid is not null) return;
await _loginLock.WaitAsync(ct);
try
{
if (_sid is not null) return;
var username = config["Torrent:Username"] ?? "admin";
var password = config["Torrent:Password"] ?? "";
var client = httpFactory.CreateClient("qbittorrent");
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["username"] = username,
["password"] = password,
});
var resp = await client.PostAsync($"{BaseUrl}/api/v2/auth/login", content, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
if (body.Trim() != "Ok.")
{
logger.LogWarning("qBittorrent login failed: {Body}", body);
return;
}
if (resp.Headers.TryGetValues("Set-Cookie", out var cookies))
{
foreach (var cookie in cookies)
{
var m = Regex.Match(cookie, @"SID=([^;]+)");
if (m.Success) { _sid = m.Groups[1].Value; break; }
}
}
logger.LogInformation("qBittorrent session established.");
}
finally
{
_loginLock.Release();
}
}
private async Task<HttpResponseMessage> ExecuteAsync(
Func<HttpClient, Task<HttpResponseMessage>> action, CancellationToken ct)
{
await EnsureLoggedInAsync(ct);
var client = CreateAuthedClient();
var resp = await action(client);
if (resp.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
// Session expired — re-login once
_sid = null;
await EnsureLoggedInAsync(ct);
client = CreateAuthedClient();
resp = await action(client);
}
resp.EnsureSuccessStatusCode();
return resp;
}
private HttpClient CreateAuthedClient()
{
var client = httpFactory.CreateClient("qbittorrent");
if (_sid is not null)
client.DefaultRequestHeaders.Add("Cookie", $"SID={_sid}");
return client;
}
// ── Parsing ────────────────────────────────────────────────────────────────
private static TorrentStatus ParseTorrentStatus(JsonObject obj)
{
var hash = obj["hash"]?.GetValue<string>() ?? "";
var name = obj["name"]?.GetValue<string>() ?? "";
var size = obj["size"]?.GetValue<long>() ?? 0;
var completed = obj["completed"]?.GetValue<long>() ?? 0;
var progress = obj["progress"]?.GetValue<float>() ?? 0f;
var stateStr = obj["state"]?.GetValue<string>() ?? "";
var state = stateStr.ToLowerInvariant() switch
{
"downloading" or "stalleddl" or "checkingdl" or "metadl" => TorrentState.Downloading,
"uploading" or "stalledup" or "checkingup" or "forcedup" => TorrentState.Seeding,
"pauseddl" => TorrentState.Paused,
"pausedup" or "completed" => TorrentState.Completed,
"queuddl" or "queuedup" => TorrentState.Queued,
"error" or "missingfiles" => TorrentState.Error,
_ => TorrentState.Unknown,
};
return new TorrentStatus(hash, name, size, completed, state, progress);
}
}
@@ -9,5 +9,33 @@
"Hardcover": {
"ApiKey": ""
},
"LibraryPaths": []
"LibraryPaths": {
"Books": "C:\\Books\\books",
"Audiobooks": "C:\\Books\\audiobooks"
},
"Torrent": {
"QBittorrentUrl": "http://localhost:8080",
"Username": "admin",
"Password": "",
"SavePath": "/data/books/incoming"
},
"Monitoring": {
"IntervalHours": 6
},
"Indexers": [
{
"Name": "Prowlarr",
"Type": "prowlarr",
"BaseUrl": "http://localhost:9696",
"ApiKey": "",
"Enabled": false
},
{
"Name": "Jackett",
"Type": "jackett",
"BaseUrl": "http://localhost:9117",
"ApiKey": "",
"Enabled": false
}
]
}
+28
View File
@@ -0,0 +1,28 @@
import type { Download, TorrentSearchResult } from '../types'
export async function fetchDownloads(): Promise<Download[]> {
const res = await fetch('/api/downloads')
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export async function addDownload(magnet: string, bookId?: number): Promise<Download> {
const res = await fetch('/api/downloads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ magnet, bookId: bookId ?? null }),
})
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export async function cancelDownload(id: string): Promise<void> {
const res = await fetch(`/api/downloads/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error(await res.text())
}
export async function searchTorrents(q: string, type: 'ebook' | 'audiobook' = 'ebook'): Promise<TorrentSearchResult[]> {
const res = await fetch(`/api/search/torrents?q=${encodeURIComponent(q)}&type=${type}`)
if (!res.ok) throw new Error(await res.text())
return res.json()
}
+12
View File
@@ -23,3 +23,15 @@ export function deleteFile(id: number): Promise<void> {
export function triggerScan(): Promise<void> {
return fetch('/api/scan', { method: 'POST' }).then(() => undefined)
}
export function organizeFile(id: number): Promise<{ moved: boolean; newRelativePath: string; newFilename: string; skipReason: string | null }> {
return fetch(`/api/files/${id}/organize`, { method: 'POST' }).then(r => r.json())
}
export function writeFileMetadata(id: number): Promise<{ success: boolean; message: string }> {
return fetch(`/api/files/${id}/write-metadata`, { method: 'POST' }).then(r => r.json())
}
export function writeBookMetadata(bookId: number): Promise<{ results: { fileId: number; filename: string; success: boolean; message: string }[] }> {
return fetch(`/api/books/${bookId}/write-metadata`, { method: 'POST' }).then(r => r.json())
}
+28
View File
@@ -0,0 +1,28 @@
import type { TorrentSearchResult, WantedBook } from '../types'
export async function fetchWanted(): Promise<WantedBook[]> {
const res = await fetch('/api/wanted')
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export async function addWanted(bookId: number, formatPreference?: string, minSeeders = 1): Promise<WantedBook> {
const res = await fetch('/api/wanted', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookId, formatPreference: formatPreference ?? null, minSeeders }),
})
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export async function removeWanted(id: number): Promise<void> {
const res = await fetch(`/api/wanted/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error(await res.text())
}
export async function searchNow(id: number): Promise<TorrentSearchResult[]> {
const res = await fetch(`/api/wanted/${id}/search-now`, { method: 'POST' })
if (!res.ok) throw new Error(await res.text())
return res.json()
}
@@ -72,6 +72,26 @@
border-radius: 4px;
}
.fileBadges {
position: absolute;
bottom: 8px;
left: 8px;
display: flex;
gap: 4px;
}
.fileBadge {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: rgba(0,0,0,.55);
border-radius: 4px;
font-size: 12px;
line-height: 1;
}
.body {
padding: 8px 10px 10px;
display: flex;
@@ -1,7 +1,9 @@
import { Tag } from 'antd'
import type { Book } from '../../types'
import type { Book, FileFormat } from '../../types'
import s from './BookCard.module.css'
const EBOOK_FORMATS: FileFormat[] = ['epub', 'mobi', 'pdf']
const AUDIOBOOK_FORMATS: FileFormat[] = ['m4b', 'mp3', 'aac', 'flac']
interface Props {
book: Book
onClick: (book: Book) => void
@@ -16,6 +18,9 @@ export default function BookCard({ book, onClick, selected }: Props) {
.join('')
.toUpperCase()
const hasEbook = book.localFileFormats.some(f => EBOOK_FORMATS.includes(f))
const hasAudiobook = book.localFileFormats.some(f => AUDIOBOOK_FORMATS.includes(f))
return (
<article
className={`${s.card} ${selected ? s.selected : ''}`}
@@ -29,21 +34,22 @@ export default function BookCard({ book, onClick, selected }: Props) {
{book.series && (
<span className={s.seriesPill}>#{book.series.position}</span>
)}
{(hasEbook || hasAudiobook) && (
<span className={s.fileBadges}>
{hasEbook && <span className={s.fileBadge} title="Ebook in library">📖</span>}
{hasAudiobook && <span className={s.fileBadge} title="Audiobook in library">🎧</span>}
</span>
)}
</div>
<div className={s.body}>
<p className={s.title}>{book.title}</p>
<p className={s.author}>{book.authors.map(a => a.name).join(', ')}</p>
{book.year && (
<div className={s.meta}>
{book.year && <span className={s.year}>{book.year}</span>}
<div className={s.chips}>
{book.formats.map(f => (
<Tag key={f} style={{ fontSize: 11, lineHeight: '20px', padding: '0 5px', margin: 0 }}>
{f.toUpperCase()}
</Tag>
))}
</div>
<span className={s.year}>{book.year}</span>
</div>
)}
</div>
<div className={s.stateLayer} />
@@ -15,10 +15,12 @@ function makeBook(overrides: Partial<Book> = {}): Book {
formats: ['epub'],
color: '#6366f1',
genres: ['Science Fiction'],
authors: [{ id: 1, name: 'Frank Herbert' }],
authors: [{ id: 1, name: 'Frank Herbert', bio: null, bornYear: null, imageUrl: null, slug: null, role: 'Author' }],
coverUrl: null,
isbn: null,
hardcoverId: null,
editions: [],
localFileFormats: [],
...overrides,
}
}
@@ -34,10 +36,20 @@ describe('BookCard', () => {
expect(screen.getByText('Frank Herbert')).toBeInTheDocument()
})
it('renders format chips', () => {
render(<BookCard book={makeBook({ formats: ['epub', 'mobi'] })} onClick={vi.fn()} />)
expect(screen.getByText('EPUB')).toBeInTheDocument()
expect(screen.getByText('MOBI')).toBeInTheDocument()
it('renders ebook badge when ebook file is present', () => {
render(<BookCard book={makeBook({ localFileFormats: ['epub'] })} onClick={vi.fn()} />)
expect(screen.getByTitle('Ebook in library')).toBeInTheDocument()
})
it('renders audiobook badge when audiobook file is present', () => {
render(<BookCard book={makeBook({ localFileFormats: ['m4b'] })} onClick={vi.fn()} />)
expect(screen.getByTitle('Audiobook in library')).toBeInTheDocument()
})
it('renders no file badges when no local files', () => {
render(<BookCard book={makeBook({ localFileFormats: [] })} onClick={vi.fn()} />)
expect(screen.queryByTitle('Ebook in library')).not.toBeInTheDocument()
expect(screen.queryByTitle('Audiobook in library')).not.toBeInTheDocument()
})
it('renders series position pill when series exists', () => {
@@ -1,21 +1,31 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button, Divider, Flex, Popconfirm, Skeleton, Tag, Typography } from 'antd'
import { Button, Divider, Flex, Popconfirm, Select, Skeleton, Tag, Tooltip, Typography, message } from 'antd'
import {
ArrowLeftOutlined,
AudioOutlined,
BankOutlined,
CalendarOutlined,
DeleteOutlined,
DownOutlined,
EditOutlined,
FileTextOutlined,
FolderOutlined,
LinkOutlined,
ReadOutlined,
SearchOutlined,
StarFilled,
StarOutlined,
TabletOutlined,
TagsOutlined,
} from '@ant-design/icons'
import type { Book, BookFile, Edition, ReadingFormat } from '../../types'
import type { Book, BookFile, Edition, FileFormat, ReadingFormat, WantedBook } from '../../types'
const EBOOK_FORMATS: FileFormat[] = ['epub', 'mobi', 'pdf']
const AUDIOBOOK_FORMATS: FileFormat[] = ['m4b', 'mp3', 'aac', 'flac']
import { fetchBook } from '../../api/books'
import { assignFile, deleteFile, fetchBookFiles } from '../../api/files'
import { assignFile, deleteFile, fetchBookFiles, organizeFile, writeFileMetadata, writeBookMetadata } from '../../api/files'
import { addWanted, fetchWanted, removeWanted } from '../../api/wanted'
import s from './BookDetail.module.css'
export default function BookDetail() {
@@ -24,6 +34,10 @@ export default function BookDetail() {
const [book, setBook] = useState<Book | null>(null)
const [loading, setLoading] = useState(true)
const [files, setFiles] = useState<BookFile[]>([])
const [editionsOpen, setEditionsOpen] = useState(false)
const [writingAll, setWritingAll] = useState(false)
const [wanted, setWanted] = useState<WantedBook | null>(null)
const [toggling, setToggling] = useState(false)
useEffect(() => {
if (!id) return
@@ -31,13 +45,36 @@ export default function BookDetail() {
fetchBook(Number(id))
.then(b => {
setBook(b)
return fetchBookFiles(b.id)
return Promise.all([
fetchBookFiles(b.id),
fetchWanted().then(ws => ws.find(w => w.bookId === b.id) ?? null),
])
})
.then(setFiles)
.then(([fs, w]) => { setFiles(fs); setWanted(w) })
.catch(() => setBook(null))
.finally(() => setLoading(false))
}, [id])
async function handleToggleMonitor() {
if (!book) return
setToggling(true)
try {
if (wanted) {
await removeWanted(wanted.id)
setWanted(null)
message.success('Removed from monitoring.')
} else {
const w = await addWanted(book.id)
setWanted(w)
message.success('Book is now monitored.')
}
} catch {
message.error('Failed to update monitoring.')
} finally {
setToggling(false)
}
}
function handleUnlink(fileId: number) {
assignFile(fileId, null, null).then(updated =>
setFiles(fs => fs.map(f => f.id === fileId ? updated : f))
@@ -48,6 +85,43 @@ export default function BookDetail() {
deleteFile(fileId).then(() => setFiles(fs => fs.filter(f => f.id !== fileId)))
}
function handleAssignEdition(fileId: number, bookId: number | null, editionId: number | null) {
assignFile(fileId, bookId, editionId).then(updated =>
setFiles(fs => fs.map(f => f.id === fileId ? updated : f))
)
}
function handleOrganize(fileId: number) {
organizeFile(fileId).then(result => {
if (result.moved)
setFiles(fs => fs.map(f => f.id === fileId
? { ...f, path: result.newRelativePath, filename: result.newFilename }
: f
))
})
}
function handleWriteMetadata(fileId: number) {
return writeFileMetadata(fileId).then(r => {
if (r.success) message.success(r.message)
else message.error(r.message)
})
}
function handleWriteAllMetadata() {
if (!book) return
setWritingAll(true)
writeBookMetadata(book.id)
.then(r => {
const failed = r.results.filter(x => !x.success)
if (failed.length === 0)
message.success(`Metadata written to ${r.results.length} file(s).`)
else
message.warning(`${r.results.length - failed.length} succeeded, ${failed.length} failed.`)
})
.finally(() => setWritingAll(false))
}
return (
<div className={s.page}>
<div className={s.content}>
@@ -62,6 +136,20 @@ export default function BookDetail() {
</Button>
<div style={{ flex: 1 }} />
{book && (
<>
<Tooltip title={wanted ? 'Monitored — click to unmonitor' : 'Monitor for downloads'}>
<Button
icon={wanted ? <StarFilled style={{ color: '#6750A4' }} /> : <StarOutlined />}
loading={toggling}
onClick={handleToggleMonitor}
/>
</Tooltip>
<Button
icon={<SearchOutlined />}
onClick={() => navigate(`/import?tab=search&q=${encodeURIComponent(book.title)}`)}
>
Find Downloads
</Button>
<Button
type="primary"
icon={<EditOutlined />}
@@ -69,6 +157,7 @@ export default function BookDetail() {
>
Edit Metadata
</Button>
</>
)}
</Flex>
@@ -132,6 +221,31 @@ export default function BookDetail() {
))}
</Flex>
)}
{book.localFileFormats.length > 0 && (
<Flex gap={8} align="center">
{book.localFileFormats.some(f => EBOOK_FORMATS.includes(f)) && (
<Flex gap={6} align="center" style={{
padding: '3px 10px', borderRadius: 20,
background: '#f0f5ff', border: '1px solid #adc6ff',
fontSize: 13, color: '#2f54eb',
}}>
<TabletOutlined />
<span>Ebook</span>
</Flex>
)}
{book.localFileFormats.some(f => AUDIOBOOK_FORMATS.includes(f)) && (
<Flex gap={6} align="center" style={{
padding: '3px 10px', borderRadius: 20,
background: '#f9f0ff', border: '1px solid #d3adf7',
fontSize: 13, color: '#722ed1',
}}>
<AudioOutlined />
<span>Audiobook</span>
</Flex>
)}
</Flex>
)}
</div>
</Flex>
@@ -152,23 +266,55 @@ export default function BookDetail() {
)
return filtered.length > 0 ? (
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>
<button
onClick={() => setEditionsOpen(o => !o)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
marginBottom: editionsOpen ? 10 : 0,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
Editions from Hardcover ({filtered.length})
</Typography.Title>
<DownOutlined
style={{
fontSize: 11, color: 'rgba(0,0,0,.45)',
transition: 'transform .2s',
transform: editionsOpen ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</button>
{editionsOpen && (
<div className={s.editionList}>
{filtered.map(ed => (
<EditionRow key={ed.id} edition={ed} />
))}
</div>
)}
</section>
) : null
})()}
{/* Files */}
<section className={s.section}>
<Typography.Title level={5} style={{ marginBottom: 10 }}>
<Flex align="center" gap={8} style={{ marginBottom: 10 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
Files {files.length > 0 && `(${files.length})`}
</Typography.Title>
{files.length > 0 && (
<Tooltip title="Write metadata to all files">
<Button
size="small"
icon={<TagsOutlined />}
loading={writingAll}
onClick={handleWriteAllMetadata}
>
Write Metadata
</Button>
</Tooltip>
)}
</Flex>
{files.length === 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
No files linked to this book.
@@ -179,8 +325,14 @@ export default function BookDetail() {
<FileRow
key={f.id}
file={f}
editions={book.editions.filter(ed =>
!ed.language || ed.language === 'English' || ed.language === 'Latvian'
)}
onUnlink={handleUnlink}
onDelete={handleDeleteFile}
onAssignEdition={handleAssignEdition}
onOrganize={handleOrganize}
onWriteMetadata={handleWriteMetadata}
/>
))}
</div>
@@ -271,18 +423,42 @@ function formatBytes(bytes: number): string {
return `${bytes} B`
}
function editionLabel(ed: Edition): string {
const parts: string[] = []
if (ed.editionFormat) parts.push(ed.editionFormat)
else if (ed.readingFormat) parts.push(ed.readingFormat)
if (ed.releaseYear) parts.push(String(ed.releaseYear))
if (ed.isbn) parts.push(`ISBN ${ed.isbn}`)
else if (ed.asin) parts.push(`ASIN ${ed.asin}`)
return parts.join(' · ') || `Edition #${ed.id}`
}
function FileRow({
file,
editions,
onUnlink,
onDelete,
onAssignEdition,
onOrganize,
onWriteMetadata,
}: {
file: BookFile
editions: Edition[]
onUnlink: (id: number) => void
onDelete: (id: number) => void
onAssignEdition: (fileId: number, bookId: number | null, editionId: number | null) => void
onOrganize: (id: number) => void
onWriteMetadata: (id: number) => void
}) {
const [writing, setWriting] = useState(false)
function handleWrite() {
setWriting(true)
onWriteMetadata(file.id).finally(() => setWriting(false))
}
return (
<Flex gap={12} align="center" className={s.editionRow}>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0 }}>
<Flex gap={12} align="center" className={s.editionRow} style={{ flexWrap: 'wrap' }}>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0, flexShrink: 0 }}>
{file.format}
</Tag>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -296,13 +472,42 @@ function FileRow({
{formatBytes(file.sizeBytes)}
</Typography.Text>
</div>
{editions.length > 0 && (
<Select
size="small"
allowClear
placeholder="Edition"
value={file.editionId ?? undefined}
onChange={(val: number | undefined) =>
onAssignEdition(file.id, file.bookId ?? null, val ?? null)
}
style={{ width: 200, fontSize: 12 }}
options={editions.map(ed => ({ value: ed.id, label: editionLabel(ed) }))}
/>
)}
<Flex gap={4}>
<Tooltip title="Write metadata to file">
<Button
size="small"
icon={<TagsOutlined />}
loading={writing}
onClick={handleWrite}
/>
</Tooltip>
<Tooltip title="Move to canonical library path">
<Button
size="small"
icon={<FolderOutlined />}
onClick={() => onOrganize(file.id)}
/>
</Tooltip>
<Tooltip title="Unlink from book">
<Button
size="small"
icon={<LinkOutlined />}
onClick={() => onUnlink(file.id)}
title="Unlink from book"
/>
</Tooltip>
<Popconfirm
title="Remove this file record?"
description="The file on disk is not deleted."
+788 -125
View File
@@ -1,18 +1,34 @@
import { useEffect, useState } from 'react'
import { Badge, Button, Flex, Switch, Tag, Typography, Upload } from 'antd'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
Badge, Button, Flex, Input, Modal, Progress, Segmented, Select, Spin, Switch, Tag, Tabs,
Typography, message,
} from 'antd'
import {
BookOutlined,
CheckCircleOutlined,
CloseOutlined,
DeleteOutlined,
DownloadOutlined,
FolderOutlined,
GlobalOutlined,
LinkOutlined,
LoadingOutlined,
PlusOutlined,
ScanOutlined,
UploadOutlined,
SearchOutlined,
StarOutlined,
WifiOutlined,
} from '@ant-design/icons'
import type { BookFile, QueueItem as IQueueItem, ImportSource } from '../../types'
import { fetchQueue, fetchSources, retryQueueItem, removeQueueItem, updateSource } from '../../api/importQueue'
import { fetchUnmatchedFiles, triggerScan } from '../../api/files'
import QueueItem from '../../components/QueueItem/QueueItem'
import type {
Book, BookFile, Download, HardcoverSearchResult, ImportSource, TorrentSearchResult, WantedBook,
} from '../../types'
import { fetchSources, updateSource } from '../../api/importQueue'
import { assignFile, fetchUnmatchedFiles, triggerScan } from '../../api/files'
import { fetchBooks } from '../../api/books'
import { searchHardcover, addBookFromHardcover } from '../../api/search'
import { addDownload, cancelDownload, fetchDownloads, searchTorrents } from '../../api/downloads'
import { addWanted, fetchWanted, removeWanted, searchNow } from '../../api/wanted'
const SOURCE_ICONS: Record<string, React.ReactNode> = {
folder: <FolderOutlined />,
@@ -21,16 +37,481 @@ const SOURCE_ICONS: Record<string, React.ReactNode> = {
url: <GlobalOutlined />,
}
export default function Import() {
const [queue, setQueue] = useState<IQueueItem[]>([])
const [sources, setSources] = useState<ImportSource[]>([])
const [unmatched, setUnmatched] = useState<BookFile[]>([])
const [scanning, setScanning] = useState(false)
function formatBytes(b: number) {
if (b === 0) return '—'
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`
}
// ── Downloads tab ────────────────────────────────────────────────────────────
function DownloadsTab() {
const [downloads, setDownloads] = useState<Download[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchDownloads().then(setDownloads).finally(() => setLoading(false))
const id = setInterval(() => fetchDownloads().then(setDownloads), 10_000)
return () => clearInterval(id)
}, [])
async function handleCancel(id: string) {
try {
await cancelDownload(id)
setDownloads(ds => ds.filter(d => d.id !== id))
} catch {
message.error('Failed to cancel download.')
}
}
const active = downloads.filter(d => d.status === 'downloading' || d.status === 'queued')
const finished = downloads.filter(d => d.status === 'completed' || d.status === 'failed')
if (loading) {
return (
<Flex justify="center" style={{ padding: 40 }}>
<Spin indicator={<LoadingOutlined spin />} />
</Flex>
)
}
if (downloads.length === 0) {
return (
<Flex align="center" justify="center" style={{ minHeight: 160 }}>
<Typography.Text type="secondary">No downloads.</Typography.Text>
</Flex>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{active.length > 0 && (
<section>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>Active</Typography.Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{active.map(d => (
<DownloadRow key={d.id} download={d} onCancel={handleCancel} />
))}
</div>
</section>
)}
{finished.length > 0 && (
<section>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>History</Typography.Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{finished.map(d => (
<DownloadRow key={d.id} download={d} onCancel={handleCancel} />
))}
</div>
</section>
)}
</div>
)
}
function DownloadRow({ download: d, onCancel }: { download: Download; onCancel: (id: string) => void }) {
const pct = d.sizeBytes > 0 ? Math.round((d.downloadedBytes / d.sizeBytes) * 100) : 0
const statusColor: Record<string, string> = {
downloading: '#6750A4',
queued: '#fa8c16',
completed: '#52c41a',
failed: '#ff4d4f',
}
return (
<div style={{
padding: '10px 12px',
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
}}>
<Flex align="center" gap={8} style={{ marginBottom: d.status === 'downloading' ? 6 : 0 }}>
<Tag color={statusColor[d.status]} style={{ margin: 0, flexShrink: 0 }}>{d.status}</Tag>
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>
{d.bookTitle ?? d.filename}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>
{formatBytes(d.sizeBytes)}
</Typography.Text>
{(d.status === 'downloading' || d.status === 'queued') && (
<Button
type="text"
size="small"
icon={<CloseOutlined />}
danger
onClick={() => onCancel(d.id)}
/>
)}
</Flex>
{d.status === 'downloading' && (
<Progress percent={pct} size="small" showInfo={false} strokeColor="#6750A4" />
)}
{d.status === 'failed' && d.error && (
<Typography.Text type="danger" style={{ fontSize: 12 }}>{d.error}</Typography.Text>
)}
</div>
)
}
// ── Search tab ───────────────────────────────────────────────────────────────
function SearchTab({ initialQuery }: { initialQuery?: string }) {
const [query, setQuery] = useState(initialQuery ?? '')
const [type, setType] = useState<'ebook' | 'audiobook'>('ebook')
const [results, setResults] = useState<TorrentSearchResult[]>([])
const [loading, setLoading] = useState(false)
const [adding, setAdding] = useState<string | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (initialQuery) doSearch(initialQuery, type)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function doSearch(q: string, t: 'ebook' | 'audiobook') {
if (!q.trim()) { setResults([]); return }
setLoading(true)
searchTorrents(q, t)
.then(setResults)
.catch(() => { message.error('Search failed.'); setResults([]) })
.finally(() => setLoading(false))
}
function handleQueryChange(q: string) {
setQuery(q)
if (timerRef.current) clearTimeout(timerRef.current)
if (!q.trim()) { setResults([]); return }
timerRef.current = setTimeout(() => doSearch(q, type), 500)
}
function handleTypeChange(t: 'ebook' | 'audiobook') {
setType(t)
if (query.trim()) doSearch(query, t)
}
async function handleDownload(r: TorrentSearchResult) {
if (!r.magnet || adding) return
setAdding(r.magnet)
try {
await addDownload(r.magnet)
message.success(`Added "${r.title}" to downloads.`)
} catch {
message.error('Failed to add download.')
} finally {
setAdding(null)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Flex gap={8}>
<Input
prefix={<SearchOutlined />}
placeholder="Search for ebooks or audiobooks…"
value={query}
onChange={e => handleQueryChange(e.target.value)}
allowClear
style={{ flex: 1 }}
autoFocus
/>
<Select
value={type}
onChange={handleTypeChange}
style={{ width: 120 }}
options={[
{ label: 'Ebook', value: 'ebook' },
{ label: 'Audiobook', value: 'audiobook' },
]}
/>
</Flex>
{loading && (
<Flex justify="center" style={{ padding: 32 }}>
<Spin indicator={<LoadingOutlined spin />} />
</Flex>
)}
{!loading && query.trim() && results.length === 0 && (
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
No results for "{query}"
</Typography.Text>
)}
{!loading && !query.trim() && (
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
Start typing to search indexers
</Typography.Text>
)}
{!loading && results.map((r, i) => (
<div key={i} style={{
padding: '10px 12px',
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
}}>
<Flex align="flex-start" justify="space-between" gap={12}>
<div style={{ minWidth: 0, flex: 1 }}>
<Typography.Text ellipsis style={{ display: 'block', fontSize: 13, fontWeight: 500 }}>
{r.title}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{r.seeders} seeders · {r.leechers} leechers · {formatBytes(r.sizeBytes ?? 0)} · {r.indexer}
</Typography.Text>
</div>
<Button
size="small"
icon={<DownloadOutlined />}
type="primary"
ghost
disabled={!r.magnet || adding === r.magnet}
loading={adding === r.magnet}
onClick={() => handleDownload(r)}
>
Download
</Button>
</Flex>
</div>
))}
</div>
)
}
// ── Wanted tab ───────────────────────────────────────────────────────────────
function WantedTab() {
const [wanted, setWanted] = useState<WantedBook[]>([])
const [loading, setLoading] = useState(true)
const [searching, setSearching] = useState<number | null>(null)
const [removing, setRemoving] = useState<number | null>(null)
const [searchResults, setSearchResults] = useState<{ id: number; results: TorrentSearchResult[] } | null>(null)
const [addModalOpen, setAddModalOpen] = useState(false)
const [books, setBooks] = useState<Book[]>([])
const [addBookId, setAddBookId] = useState<number | null>(null)
const [addFormat, setAddFormat] = useState<string | undefined>(undefined)
const [addMinSeeders, setAddMinSeeders] = useState(1)
const [adding, setAdding] = useState(false)
useEffect(() => {
fetchWanted().then(setWanted).finally(() => setLoading(false))
fetchBooks().then(setBooks)
}, [])
async function handleRemove(id: number) {
setRemoving(id)
try {
await removeWanted(id)
setWanted(ws => ws.filter(w => w.id !== id))
} catch {
message.error('Failed to remove.')
} finally {
setRemoving(null)
}
}
async function handleSearchNow(id: number) {
setSearching(id)
try {
const results = await searchNow(id)
setSearchResults({ id, results })
} catch {
message.error('Search failed.')
} finally {
setSearching(null)
}
}
async function handleAdd() {
if (!addBookId) return
setAdding(true)
try {
const entry = await addWanted(addBookId, addFormat, addMinSeeders)
setWanted(ws => {
const idx = ws.findIndex(w => w.bookId === addBookId)
if (idx >= 0) { const copy = [...ws]; copy[idx] = entry; return copy }
return [...ws, entry]
})
setAddModalOpen(false)
setAddBookId(null)
setAddFormat(undefined)
setAddMinSeeders(1)
} catch {
message.error('Failed to add.')
} finally {
setAdding(false)
}
}
const statusColor: Record<string, string> = {
wanted: '#6750A4',
downloading: '#fa8c16',
found: '#52c41a',
}
if (loading) {
return (
<Flex justify="center" style={{ padding: 40 }}>
<Spin indicator={<LoadingOutlined spin />} />
</Flex>
)
}
return (
<>
<Flex justify="flex-end" style={{ marginBottom: 12 }}>
<Button icon={<PlusOutlined />} onClick={() => setAddModalOpen(true)}>
Monitor book
</Button>
</Flex>
{wanted.length === 0 && (
<Flex align="center" justify="center" style={{ minHeight: 120 }}>
<Typography.Text type="secondary">No monitored books.</Typography.Text>
</Flex>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{wanted.map(w => (
<div key={w.id} style={{
padding: '10px 12px',
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
}}>
<Flex align="center" gap={10}>
<Tag color={statusColor[w.status]} style={{ margin: 0, flexShrink: 0 }}>{w.status}</Tag>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text ellipsis style={{ display: 'block', fontSize: 13, fontWeight: 500 }}>
{w.bookTitle}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{w.bookAuthors.join(', ')}
{w.formatPreference ? ` · ${w.formatPreference}` : ''}
{` · ≥${w.minSeeders} seeders`}
</Typography.Text>
</div>
<Button
size="small"
icon={searching === w.id ? <LoadingOutlined spin /> : <SearchOutlined />}
disabled={searching !== null}
onClick={() => handleSearchNow(w.id)}
>
Search now
</Button>
<Button
size="small"
type="text"
danger
icon={removing === w.id ? <LoadingOutlined spin /> : <DeleteOutlined />}
disabled={removing !== null}
onClick={() => handleRemove(w.id)}
/>
</Flex>
{searchResults?.id === w.id && searchResults.results.length > 0 && (
<div style={{ marginTop: 8, borderTop: '1px solid #f0f0f0', paddingTop: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
{searchResults.results.length} result(s) found:
</Typography.Text>
{searchResults.results.slice(0, 3).map((r, i) => (
<Typography.Text key={i} type="secondary" style={{ fontSize: 12, display: 'block' }}>
· {r.title} ({r.seeders} seeders, {formatBytes(r.sizeBytes ?? 0)})
</Typography.Text>
))}
</div>
)}
{searchResults?.id === w.id && searchResults.results.length === 0 && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
No results found.
</Typography.Text>
)}
</div>
))}
</div>
<Modal
title="Monitor book"
open={addModalOpen}
onCancel={() => { setAddModalOpen(false); setAddBookId(null); setAddFormat(undefined); setAddMinSeeders(1) }}
onOk={handleAdd}
okText="Monitor"
okButtonProps={{ disabled: !addBookId, loading: adding }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
<div>
<Typography.Text style={{ display: 'block', marginBottom: 4 }}>Book</Typography.Text>
<Select
showSearch
style={{ width: '100%' }}
placeholder="Select a book…"
value={addBookId ?? undefined}
onChange={setAddBookId}
filterOption={(input, option) =>
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
}
options={books.map(b => ({
value: b.id,
label: `${b.title}${b.authors.length ? `${b.authors[0].name}` : ''}`,
}))}
/>
</div>
<div>
<Typography.Text style={{ display: 'block', marginBottom: 4 }}>Format preference (optional)</Typography.Text>
<Select
allowClear
style={{ width: '100%' }}
placeholder="Any format"
value={addFormat}
onChange={v => setAddFormat(v)}
options={[
{ label: 'EPUB', value: 'epub' },
{ label: 'MOBI', value: 'mobi' },
{ label: 'M4B', value: 'm4b' },
{ label: 'MP3', value: 'mp3' },
]}
/>
</div>
<div>
<Typography.Text style={{ display: 'block', marginBottom: 4 }}>Minimum seeders</Typography.Text>
<Input
type="number"
min={1}
value={addMinSeeders}
onChange={e => setAddMinSeeders(Number(e.target.value) || 1)}
style={{ width: 120 }}
/>
</div>
</div>
</Modal>
</>
)
}
// ── Sources tab ──────────────────────────────────────────────────────────────
function SourcesTab() {
const [sources, setSources] = useState<ImportSource[]>([])
const [scanning, setScanning] = useState(false)
const [unmatched, setUnmatched] = useState<BookFile[]>([])
// Match modal state
const [matchingFile, setMatchingFile] = useState<BookFile | null>(null)
const [matchTab, setMatchTab] = useState<'library' | 'hardcover'>('library')
const [books, setBooks] = useState<Book[]>([])
const [bookSearch, setBookSearch] = useState('')
const [assigning, setAssigning] = useState(false)
const [hcQuery, setHcQuery] = useState('')
const [hcResults, setHcResults] = useState<HardcoverSearchResult[]>([])
const [hcLoading, setHcLoading] = useState(false)
const [hcAdding, setHcAdding] = useState<number | null>(null)
const [hcAdded, setHcAdded] = useState<Set<number>>(new Set())
const hcTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetchQueue().then(setQueue)
fetchSources().then(setSources)
fetchUnmatchedFiles().then(setUnmatched)
fetchBooks().then(setBooks)
}, [])
function handleScan() {
@@ -40,16 +521,6 @@ export default function Import() {
.finally(() => setScanning(false))
}
function handleRetry(id: string) {
retryQueueItem(id).then(() =>
setQueue(q => q.map(i => i.id === id ? { ...i, status: 'queued' as const } : i))
)
}
function handleRemove(id: string) {
removeQueueItem(id).then(() => setQueue(q => q.filter(i => i.id !== id)))
}
function toggleSource(id: string) {
const current = sources.find(s => s.id === id)
if (!current) return
@@ -60,55 +531,84 @@ export default function Import() {
)
}
const active = queue.filter(i => i.status === 'downloading' || i.status === 'queued')
const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed')
function openMatchModal(file: BookFile) {
setMatchingFile(file)
setMatchTab('library')
setBookSearch('')
setHcQuery('')
setHcResults([])
setHcAdded(new Set())
}
function closeMatchModal() {
setMatchingFile(null)
setBookSearch('')
setHcQuery('')
setHcResults([])
}
function handleAssign(book: Book) {
if (!matchingFile) return
setAssigning(true)
assignFile(matchingFile.id, book.id, null)
.then(() => {
setUnmatched(fs => fs.filter(f => f.id !== matchingFile.id))
closeMatchModal()
})
.finally(() => setAssigning(false))
}
function handleHcQueryChange(q: string) {
setHcQuery(q)
if (hcTimerRef.current) clearTimeout(hcTimerRef.current)
if (!q.trim()) { setHcResults([]); return }
hcTimerRef.current = setTimeout(() => {
setHcLoading(true)
searchHardcover(q)
.then(setHcResults)
.catch(() => setHcResults([]))
.finally(() => setHcLoading(false))
}, 400)
}
async function handleHcAddAndMatch(result: HardcoverSearchResult) {
if (!matchingFile || hcAdding !== null || hcAdded.has(result.id)) return
setHcAdding(result.id)
try {
const book = await addBookFromHardcover(result.id)
setHcAdded(prev => new Set(prev).add(result.id))
await assignFile(matchingFile.id, book.id, null)
setUnmatched(fs => fs.filter(f => f.id !== matchingFile.id))
closeMatchModal()
} finally {
setHcAdding(null)
}
}
const filteredBooks = useMemo(() => {
const q = bookSearch.toLowerCase()
if (!q) return books
return books.filter(b =>
b.title.toLowerCase().includes(q) ||
b.authors.some(a => a.name.toLowerCase().includes(q))
)
}, [books, bookSearch])
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', height: '100%', overflow: 'hidden' }}>
{/* Left column */}
<div style={{ display: 'flex', flexDirection: 'column', overflowY: 'auto', padding: 24, gap: 24 }}>
<section>
<Typography.Title level={5} style={{ marginBottom: 12 }}>Drop files</Typography.Title>
<Upload.Dragger
multiple
accept=".epub,.mobi,.pdf,.cbz,.cbr"
showUploadList={false}
beforeUpload={file => {
console.log('file:', file.name)
return false
}}
style={{ padding: '8px 0' }}
>
<div style={{ padding: '16px 0' }}>
<UploadOutlined style={{ fontSize: 40, color: 'rgba(0,0,0,.25)', display: 'block', marginBottom: 12 }} />
<Typography.Text>Drop EPUB, MOBI, PDF files here</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 13 }}>or click to browse</Typography.Text>
</div>
</Upload.Dragger>
</section>
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<section>
<Typography.Title level={5} style={{ marginBottom: 8 }}>Sources</Typography.Title>
<div style={{ borderTop: '1px solid #f0f0f0' }}>
{sources.map(src => (
<div key={src.id} style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 0',
borderBottom: '1px solid #f0f0f0',
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 0', borderBottom: '1px solid #f0f0f0',
}}>
<div style={{
width: 36,
height: 36,
borderRadius: '50%',
background: '#EDE7F6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#6750A4',
flexShrink: 0,
width: 36, height: 36, borderRadius: '50%', background: '#EDE7F6',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#6750A4', flexShrink: 0,
}}>
{SOURCE_ICONS[src.type] ?? <GlobalOutlined />}
</div>
@@ -121,16 +621,13 @@ export default function Import() {
<Switch
checked={src.enabled}
onChange={() => toggleSource(src.id)}
aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`}
size="small"
/>
</div>
))}
</div>
<Button
type="dashed"
icon={<PlusOutlined />}
style={{ marginTop: 12 }}
type="dashed" icon={<PlusOutlined />} style={{ marginTop: 12 }}
onClick={() => {}}
>
Add source
@@ -141,55 +638,10 @@ export default function Import() {
<Flex align="center" gap={8} style={{ marginBottom: 8 }}>
<Typography.Title level={5} style={{ margin: 0 }}>Scan</Typography.Title>
</Flex>
<Button
icon={<ScanOutlined />}
loading={scanning}
onClick={handleScan}
>
<Button icon={<ScanOutlined />} loading={scanning} onClick={handleScan}>
Scan sources now
</Button>
</section>
</div>
{/* Right column */}
<div style={{
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
padding: 24,
gap: 24,
borderLeft: '1px solid #f0f0f0',
}}>
{active.length > 0 && (
<section>
<Flex align="center" gap={8} style={{ marginBottom: 12 }}>
<Typography.Title level={5} style={{ margin: 0 }}>Downloading</Typography.Title>
<Badge count={active.length} color="#6750A4" />
</Flex>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{active.map(item => (
<QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
))}
</div>
</section>
)}
{finished.length > 0 && (
<section>
<Typography.Title level={5} style={{ marginBottom: 12 }}>History</Typography.Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{finished.map(item => (
<QueueItem key={item.id} item={item} onRetry={handleRetry} onRemove={handleRemove} />
))}
</div>
</section>
)}
{queue.length === 0 && unmatched.length === 0 && (
<Flex align="center" justify="center" style={{ flex: 1, minHeight: 120 }}>
<Typography.Text type="secondary">No recent activity.</Typography.Text>
</Flex>
)}
{unmatched.length > 0 && (
<section>
@@ -200,28 +652,239 @@ export default function Import() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{unmatched.map(f => (
<Flex
key={f.id}
align="center"
gap={10}
style={{
padding: '8px 12px',
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
}}
key={f.id} align="center" gap={10}
style={{ padding: '8px 12px', border: '1px solid #f0f0f0', borderRadius: 6, background: '#fafafa' }}
>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0 }}>
<Tag style={{ fontFamily: 'monospace', textTransform: 'uppercase', margin: 0, flexShrink: 0 }}>
{f.format}
</Tag>
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>
{f.filename}
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text ellipsis style={{ display: 'block', fontSize: 13 }}>{f.filename}</Typography.Text>
{f.path !== f.filename && (
<Typography.Text type="secondary" ellipsis style={{ display: 'block', fontSize: 11 }}>
{f.path}
</Typography.Text>
)}
</div>
<Button size="small" icon={<LinkOutlined />} onClick={() => openMatchModal(f)}>
Match
</Button>
</Flex>
))}
</div>
</section>
)}
</div>
{/* Manual match modal */}
<Modal
title={
<div>
<div>Match file to book</div>
{matchingFile && (
<Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>
{matchingFile.filename}
</Typography.Text>
)}
</div>
}
open={matchingFile !== null}
onCancel={closeMatchModal}
footer={null}
width={520}
>
<Segmented
block
value={matchTab}
onChange={v => setMatchTab(v as 'library' | 'hardcover')}
options={[
{ label: 'Library', value: 'library' },
{ label: 'Search Hardcover', value: 'hardcover' },
]}
style={{ marginBottom: 12 }}
/>
{matchTab === 'library' && (
<>
<Input
prefix={<SearchOutlined />}
placeholder="Search by title or author…"
value={bookSearch}
onChange={e => setBookSearch(e.target.value)}
style={{ marginBottom: 12 }}
autoFocus
/>
<div style={{ maxHeight: 320, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
{filteredBooks.length === 0 && (
<Typography.Text type="secondary" style={{ padding: '16px 0', display: 'block', textAlign: 'center' }}>
No books found.
</Typography.Text>
)}
{filteredBooks.map(b => (
<button
key={b.id}
disabled={assigning}
onClick={() => handleAssign(b)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
border: 'none', borderRadius: 6, background: 'transparent',
cursor: assigning ? 'not-allowed' : 'pointer', textAlign: 'left', width: '100%',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#f5f0ff')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<div style={{
width: 32, height: 48, borderRadius: 4,
background: b.coverUrl ? 'transparent' : b.color,
flexShrink: 0, overflow: 'hidden',
}}>
{b.coverUrl && (
<img src={b.coverUrl} alt={b.title} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)}
</div>
<div style={{ minWidth: 0 }}>
<Typography.Text style={{ display: 'block', fontSize: 13, fontWeight: 500 }}>{b.title}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{b.authors.map(a => a.name).join(', ')}
{b.year ? ` · ${b.year}` : ''}
</Typography.Text>
</div>
</button>
))}
</div>
</>
)}
{matchTab === 'hardcover' && (
<>
<Input
prefix={<SearchOutlined />}
placeholder="Search Hardcover…"
value={hcQuery}
onChange={e => handleHcQueryChange(e.target.value)}
allowClear
style={{ marginBottom: 12 }}
autoFocus
/>
<div style={{ minHeight: 100, maxHeight: 320, overflowY: 'auto' }}>
{hcLoading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
<Spin indicator={<LoadingOutlined spin />} />
</div>
)}
{!hcLoading && !hcQuery.trim() && (
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
Start typing to search Hardcover
</Typography.Text>
)}
{!hcLoading && hcQuery.trim() && hcResults.length === 0 && (
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '32px 0' }}>
No results for "{hcQuery}"
</Typography.Text>
)}
{!hcLoading && hcResults.map(r => {
const isAdded = hcAdded.has(r.id)
const isAdding = hcAdding === r.id
return (
<div
key={r.id}
onClick={() => handleHcAddAndMatch(r)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') handleHcAddAndMatch(r) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 8px', borderRadius: 6,
cursor: isAdded ? 'default' : 'pointer',
background: isAdded ? '#f6f0ff' : 'transparent',
opacity: isAdding ? 0.6 : 1, transition: 'background 150ms',
}}
onMouseEnter={e => { if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'rgba(0,0,0,.03)' }}
onMouseLeave={e => { if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'transparent' }}
>
<div style={{ minWidth: 0 }}>
<Typography.Text strong ellipsis style={{ display: 'block' }}>{r.title}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ fontSize: 13, display: 'block' }}>
{r.authors.join(', ')}{r.year ? ` · ${r.year}` : ''}
</Typography.Text>
</div>
<span style={{ marginLeft: 12, color: isAdded ? '#6750A4' : 'rgba(0,0,0,.45)', fontSize: 18, flexShrink: 0, display: 'flex' }}>
{isAdding ? <LoadingOutlined spin /> : isAdded ? <CheckCircleOutlined /> : <PlusOutlined />}
</span>
</div>
)
})}
</div>
</>
)}
</Modal>
</>
)
}
// ── Main page ────────────────────────────────────────────────────────────────
export default function Import() {
const [searchParams] = useSearchParams()
const initialTab = searchParams.get('tab') ?? 'downloads'
const initialSearch = searchParams.get('q') ?? undefined
const [activeTab, setActiveTab] = useState(initialTab)
const items = [
{
key: 'downloads',
label: (
<span><DownloadOutlined style={{ marginRight: 6 }} />Downloads</span>
),
children: (
<div style={{ padding: '16px 0' }}>
<DownloadsTab />
</div>
),
},
{
key: 'search',
label: (
<span><SearchOutlined style={{ marginRight: 6 }} />Search</span>
),
children: (
<div style={{ padding: '16px 0' }}>
<SearchTab initialQuery={initialSearch} />
</div>
),
},
{
key: 'wanted',
label: (
<span><StarOutlined style={{ marginRight: 6 }} />Wanted</span>
),
children: (
<div style={{ padding: '16px 0' }}>
<WantedTab />
</div>
),
},
{
key: 'sources',
label: (
<span><FolderOutlined style={{ marginRight: 6 }} />Sources</span>
),
children: (
<div style={{ padding: '16px 0' }}>
<SourcesTab />
</div>
),
},
]
return (
<div style={{ padding: 24, overflowY: 'auto', height: '100%' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={items}
style={{ height: '100%' }}
/>
</div>
)
}
+45
View File
@@ -61,6 +61,8 @@ export interface Book {
isbn: string | null
hardcoverId: number | null
editions: Edition[]
/** Distinct formats of BookFile records actually present in the library. */
localFileFormats: FileFormat[]
}
export interface QueueItem {
@@ -108,6 +110,49 @@ export interface ImportSource {
enabled: boolean
}
export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'failed'
export type DownloadSourceType = 'manual' | 'torrent' | 'localscan'
export interface Download {
id: string
filename: string
sizeBytes: number
downloadedBytes: number
status: DownloadStatus
sourceType: DownloadSourceType
torrentHash: string | null
bookId: number | null
bookTitle: string | null
error: string | null
addedAt: string
}
export type WantedStatus = 'wanted' | 'downloading' | 'found'
export type FileFormatPreference = 'epub' | 'mobi' | 'm4b' | 'mp3' | 'aac' | 'flac'
export interface WantedBook {
id: number
bookId: number
bookTitle: string
bookAuthors: string[]
bookCoverUrl: string | null
addedAt: string
status: WantedStatus
formatPreference: FileFormatPreference | null
minSeeders: number
}
export interface TorrentSearchResult {
title: string
magnet: string | null
downloadUrl: string | null
sizeBytes: number | null
seeders: number
leechers: number
indexer: string
publishDate: string | null
}
export type FileFormat = 'epub' | 'mobi' | 'pdf' | 'm4b' | 'mp3' | 'aac' | 'flac'
export interface BookFile {
+35 -14
View File
@@ -1,15 +1,4 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: pagemanager
POSTGRES_USER: pm
POSTGRES_PASSWORD: pm
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
pagemanager.api:
image: pagemanager.api
build:
@@ -19,11 +8,14 @@ services:
- "5278:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__Postgres=Host=postgres;Database=pagemanager;Username=pm;Password=pm
- ConnectionStrings__Postgres=${POSTGRES_CONNECTION_STRING}
- Torrent__QBittorrentUrl=http://qbittorrent:8080
- Torrent__SavePath=/data/books/incoming
volumes:
- books:/data/books
- audiobooks:/data/audiobooks
depends_on:
- postgres
- qbittorrent
pagemanager.web:
image: pagemanager.web
@@ -35,6 +27,35 @@ services:
depends_on:
- pagemanager.api
# Optional: self-hosted qBittorrent (comment out if using an external instance)
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
- WEBUI_PORT=8080
volumes:
- qbt-config:/config
- books:/data/books
ports:
- "6881:6881"
- "6881:6881/udp"
- "8090:8080" # WebUI on host port 8090 to avoid conflict with pagemanager.web
# Optional: Prowlarr indexer aggregator (comment out if not needed)
# prowlarr:
# image: lscr.io/linuxserver/prowlarr:latest
# environment:
# - PUID=1000
# - PGID=1000
# - TZ=Etc/UTC
# volumes:
# - prowlarr-config:/config
# ports:
# - "9696:9696"
volumes:
pgdata:
books:
audiobooks:
qbt-config: