Added support for bittorrent
This commit is contained in:
@@ -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 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 size = fileSystem.GetFileSize(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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
))}
|
||||
{book.year && (
|
||||
<div className={s.meta}>
|
||||
<span className={s.year}>{book.year}</span>
|
||||
</div>
|
||||
</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,29 +1,43 @@
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [book, setBook] = useState<Book | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [files, setFiles] = useState<BookFile[]>([])
|
||||
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,13 +136,28 @@ export default function BookDetail() {
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
{book && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/metadata?bookId=${book.id}`)}
|
||||
>
|
||||
Edit Metadata
|
||||
</Button>
|
||||
<>
|
||||
<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 />}
|
||||
onClick={() => navigate(`/metadata?bookId=${book.id}`)}
|
||||
>
|
||||
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 }}>
|
||||
Editions from Hardcover ({filtered.length})
|
||||
</Typography.Title>
|
||||
<div className={s.editionList}>
|
||||
{filtered.map(ed => (
|
||||
<EditionRow key={ed.id} edition={ed} />
|
||||
))}
|
||||
</div>
|
||||
<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 }}>
|
||||
Files {files.length > 0 && `(${files.length})`}
|
||||
</Typography.Title>
|
||||
<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>
|
||||
<Flex gap={4}>
|
||||
<Button
|
||||
{editions.length > 0 && (
|
||||
<Select
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => onUnlink(file.id)}
|
||||
title="Unlink from book"
|
||||
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)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Remove this file record?"
|
||||
description="The file on disk is not deleted."
|
||||
|
||||
@@ -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}
|
||||
</Typography.Text>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user