Added support for bittorrent

This commit is contained in:
2026-03-28 17:36:25 +02:00
parent 5acde17a53
commit 4f7036ca27
45 changed files with 3383 additions and 225 deletions
@@ -0,0 +1,133 @@
using FluentAssertions;
using PageManager.Api.Services;
using PageManager.Api.Tests.Helpers;
namespace PageManager.Api.Tests.Unit.Services;
public class FileOrganizerServiceTests
{
// ── SanitizePathComponent ─────────────────────────────────────────────────
[Theory]
[InlineData("Andy Weir", "Andy Weir")]
[InlineData("Brandon Sanderson", "Brandon Sanderson")]
[InlineData("Hello: World", "Hello_ World")] // colon is invalid
[InlineData("Title/Sub", "Title_Sub")] // slash is invalid
[InlineData("Name.", "Name")] // trailing dot stripped
[InlineData(" Name ", "Name")] // whitespace trimmed
public void SanitizePathComponent_ReturnsExpected(string input, string expected)
{
FileOrganizerService.SanitizePathComponent(input).Should().Be(expected);
}
// ── ComputeCanonicalRelativePath — ebooks ─────────────────────────────────
[Fact]
public void Ebook_WithYear_ProducesAuthorTitleYearFolder()
{
var book = BookFactory.Create(title: "The Martian", year: 2011)
.WithAuthors((1, "Andy Weir"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().Be(Path.Combine("Andy Weir", "The Martian (2011)", "The Martian.epub"));
}
[Fact]
public void Ebook_WithoutYear_OmitsYearFromFolder()
{
var book = BookFactory.Create(title: "Dune", year: null)
.WithAuthors((1, "Frank Herbert"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().Be(Path.Combine("Frank Herbert", "Dune", "Dune.epub"));
}
[Fact]
public void Ebook_SeriesIgnored_SeriesNotInEbookPath()
{
var book = BookFactory.Create(title: "The Way of Kings", year: 2010)
.WithAuthors((1, "Brandon Sanderson"))
.WithSeries(seriesName: "The Stormlight Archive");
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
// Series folder should NOT appear for ebooks
result.Should().Be(Path.Combine("Brandon Sanderson", "The Way of Kings (2010)", "The Way of Kings.epub"));
}
// ── ComputeCanonicalRelativePath — audiobooks ─────────────────────────────
[Fact]
public void Audiobook_WithoutSeries_ProducesAuthorTitleTitle()
{
var book = BookFactory.Create(title: "Project Hail Mary", year: 2021)
.WithAuthors((1, "Andy Weir"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".m4b", isAudio: true);
result.Should().Be(Path.Combine("Andy Weir", "Project Hail Mary", "Project Hail Mary.m4b"));
}
[Fact]
public void Audiobook_WithSeries_InsertSeriesFolderBetweenAuthorAndTitle()
{
var book = BookFactory.Create(title: "The Way of Kings", year: 2010)
.WithAuthors((1, "Brandon Sanderson"))
.WithSeries(seriesName: "The Stormlight Archive");
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".m4b", isAudio: true);
result.Should().Be(Path.Combine("Brandon Sanderson", "The Stormlight Archive", "The Way of Kings", "The Way of Kings.m4b"));
}
[Fact]
public void Audiobook_Mp3Extension_PreservedInFilename()
{
var book = BookFactory.Create(title: "Dune", year: 1965)
.WithAuthors((1, "Frank Herbert"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".mp3", isAudio: true);
result.Should().Be(Path.Combine("Frank Herbert", "Dune", "Dune.mp3"));
}
// ── Multiple authors ──────────────────────────────────────────────────────
[Fact]
public void MultipleAuthors_UsesPrimaryAuthorForPath()
{
var book = BookFactory.Create(title: "Good Omens", year: 1990)
.WithAuthors((1, "Terry Pratchett"), (2, "Neil Gaiman"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().StartWith("Terry Pratchett");
result.Should().NotContain("Neil Gaiman");
}
[Fact]
public void NoAuthors_UsesUnknownFolder()
{
var book = BookFactory.Create(title: "Anonymous Work", year: 2000);
// No authors added
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().StartWith("Unknown");
}
// ── Sanitization edge cases ───────────────────────────────────────────────
[Fact]
public void TitleWithInvalidChars_Sanitized()
{
var book = BookFactory.Create(title: "Title: A Story", year: 2020)
.WithAuthors((1, "Author Name"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().Be(Path.Combine("Author Name", "Title_ A Story (2020)", "Title_ A Story.epub"));
}
}
@@ -87,4 +87,39 @@ public class FileScannerServiceTests
var act = () => FileScannerService.FindMatch(file, books);
act.Should().NotThrow();
}
[Fact]
public void FindMatch_YearPrefixedFilename_StillMatchesTitle()
{
var books = new[] { BookFactory.Create(id: 1, title: "Project Hail Mary") };
var file = new BookFile { Filename = "2021 - Project Hail Mary.epub", Path = "2021 - Project Hail Mary.epub" };
FileScannerService.FindMatch(file, books)!.Id.Should().Be(1);
}
[Fact]
public void FindMatch_AudiobookInSubfolder_MatchesByParentFolderName()
{
var books = new[] { BookFactory.Create(id: 2, title: "Project Hail Mary") };
// m4b lives at "Andy Weir/2021 - Project Hail Mary/Project Hail Mary.m4b"
var file = new BookFile
{
Filename = "Project Hail Mary.m4b",
Path = "Andy Weir/2021 - Project Hail Mary/Project Hail Mary.m4b",
};
FileScannerService.FindMatch(file, books)!.Id.Should().Be(2);
}
// ── StripYearPrefix ───────────────────────────────────────────────────────
[Theory]
[InlineData("2021 - project hail mary", "project hail mary")]
[InlineData("2015 - artemis", "artemis")]
[InlineData("project hail mary", "project hail mary")] // no prefix → unchanged
[InlineData("diary of an asscan", "diary of an asscan")]
public void StripYearPrefix_ReturnsExpected(string input, string expected)
{
FileScannerService.StripYearPrefix(input).Should().Be(expected);
}
}
@@ -0,0 +1,136 @@
using FluentAssertions;
using PageManager.Api.Services;
using PageManager.Api.Tests.Helpers;
namespace PageManager.Api.Tests.Unit.Services;
public class MetadataWriterServiceTests
{
// ── ParseOpfPath ──────────────────────────────────────────────────────────
[Fact]
public void ParseOpfPath_ReturnsFullPath()
{
var xml = """
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
""";
MetadataWriterService.ParseOpfPath(xml).Should().Be("OEBPS/content.opf");
}
// ── ModifyOpfXml ──────────────────────────────────────────────────────────
private const string MinimalOpf = """
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Old Title</dc:title>
<dc:creator>Old Author</dc:creator>
<dc:language>en</dc:language>
</metadata>
<manifest/>
<spine/>
</package>
""";
[Fact]
public void ModifyOpfXml_UpdatesTitle()
{
var book = BookFactory.Create(title: "Dune", year: 1965).WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("<dc:title>Dune</dc:title>");
}
[Fact]
public void ModifyOpfXml_UpdatesCreator()
{
var book = BookFactory.Create(title: "Dune").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("Frank Herbert");
}
[Fact]
public void ModifyOpfXml_AddsDescription_WhenPresent()
{
var book = BookFactory.Create(title: "Dune", description: "A sci-fi epic.").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("A sci-fi epic.");
}
[Fact]
public void ModifyOpfXml_SkipsDescription_WhenNull()
{
var book = BookFactory.Create(title: "Dune", description: null).WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().NotContain("dc:description");
}
[Fact]
public void ModifyOpfXml_AddsIsbnIdentifier_WhenPresent()
{
var book = BookFactory.Create(title: "Dune", isbn: "9780441013593").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("9780441013593");
result.Should().Contain("ISBN");
}
[Fact]
public void ModifyOpfXml_AddsDate_WhenYearPresent()
{
var book = BookFactory.Create(title: "Dune", year: 1965).WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("1965");
}
[Fact]
public void ModifyOpfXml_PreservesExistingXmlStructure()
{
var book = BookFactory.Create(title: "Dune").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
// Manifest and spine should still be present
result.Should().Contain("manifest");
result.Should().Contain("spine");
result.Should().Contain("dc:language");
}
[Fact]
public void ModifyOpfXml_WithMultipleAuthors_UsesPrimaryAuthorForCreator()
{
var book = BookFactory.Create(title: "Good Omens")
.WithAuthors((1, "Terry Pratchett"), (2, "Neil Gaiman"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("Terry Pratchett");
}
[Fact]
public void ModifyOpfXml_WithNoAuthors_DoesNotAddCreator()
{
var book = BookFactory.Create(title: "Anonymous");
// No authors added
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
// Should not crash and should not add empty creator
result.Should().NotContain("<dc:creator />");
}
[Fact]
public void ModifyOpfXml_UpdatesExistingIsbnIdentifier_RatherThanAdding()
{
var opfWithIsbn = MinimalOpf.Replace(
"<dc:language>en</dc:language>",
"<dc:language>en</dc:language>\n <dc:identifier opf:scheme=\"ISBN\">OLD-ISBN</dc:identifier>");
var book = BookFactory.Create(title: "Dune", isbn: "9780441013593").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(opfWithIsbn, book);
result.Should().Contain("9780441013593");
result.Should().NotContain("OLD-ISBN");
// Should not have two ISBN identifiers
result.Split("ISBN").Length.Should().BeLessThanOrEqualTo(3); // "ISBN" appears in scheme + value
}
}
@@ -40,4 +40,6 @@ public class BookDto
public string? Isbn { get; set; }
public int? HardcoverId { get; set; }
public EditionDto[] Editions { get; set; } = [];
/// <summary>Distinct file formats of BookFile records actually in the library.</summary>
public string[] LocalFileFormats { get; set; } = [];
}
@@ -0,0 +1,34 @@
namespace PageManager.Api.Api.Dtos;
public class DownloadDto
{
public string Id { get; set; } = string.Empty;
public string Filename { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public long DownloadedBytes { get; set; }
public string Status { get; set; } = string.Empty;
public string SourceType { get; set; } = string.Empty;
public string? TorrentHash { get; set; }
public int? BookId { get; set; }
public string? BookTitle { get; set; }
public string? Error { get; set; }
public DateTime AddedAt { get; set; }
}
public class AddDownloadRequest
{
public string Magnet { get; set; } = string.Empty;
public int? BookId { get; set; }
}
public class TorrentSearchResultDto
{
public string Title { get; set; } = string.Empty;
public string? Magnet { get; set; }
public string? DownloadUrl { get; set; }
public long? SizeBytes { get; set; }
public int Seeders { get; set; }
public int Leechers { get; set; }
public string Indexer { get; set; } = string.Empty;
public DateTime? PublishDate { get; set; }
}
@@ -0,0 +1,21 @@
namespace PageManager.Api.Api.Dtos;
public class WantedBookDto
{
public int Id { get; set; }
public int BookId { get; set; }
public string BookTitle { get; set; } = string.Empty;
public string[] BookAuthors { get; set; } = [];
public string? BookCoverUrl { get; set; }
public DateTime AddedAt { get; set; }
public string Status { get; set; } = string.Empty;
public string? FormatPreference { get; set; }
public int MinSeeders { get; set; }
}
public class AddWantedRequest
{
public int BookId { get; set; }
public string? FormatPreference { get; set; }
public int MinSeeders { get; set; } = 1;
}
@@ -0,0 +1,100 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PageManager.Api.Services;
namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class DownloadsController(
AppDbContext db,
IQBittorrentClient torrent,
IConfiguration config,
ILogger<DownloadsController> logger) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<DownloadDto>> GetDownloads()
{
var items = await db.ImportQueueItems
.Include(i => i.Book)
.OrderByDescending(i => i.AddedAt)
.ToListAsync();
return items.Select(ToDto);
}
[HttpPost]
public async Task<ActionResult<DownloadDto>> AddDownload(AddDownloadRequest req)
{
if (string.IsNullOrWhiteSpace(req.Magnet))
return BadRequest("Magnet link is required.");
var savePath = config["Torrent:SavePath"] ?? "/data/books/incoming";
var error = await torrent.AddMagnetAsync(req.Magnet, savePath, "books");
if (error is not null)
{
logger.LogWarning("qBittorrent rejected magnet: {Error}", error);
return BadRequest($"qBittorrent rejected the magnet: {error}");
}
var hash = ExtractInfoHash(req.Magnet);
var item = new ImportQueueItem
{
Filename = hash ?? req.Magnet[..Math.Min(60, req.Magnet.Length)],
Status = QueueItemStatus.Downloading,
SourceType = DownloadSourceType.Torrent,
TorrentHash = hash,
TorrentMagnet = req.Magnet,
BookId = req.BookId,
};
db.ImportQueueItems.Add(item);
await db.SaveChangesAsync();
await db.Entry(item).Reference(i => i.Book).LoadAsync();
return CreatedAtAction(nameof(GetDownloads), ToDto(item));
}
[HttpDelete("{id}")]
public async Task<IActionResult> CancelDownload(string id)
{
var item = await db.ImportQueueItems.FindAsync(id);
if (item is null) return NotFound();
if (item.TorrentHash is not null)
{
try { await torrent.RemoveTorrentAsync(item.TorrentHash, deleteFiles: false); }
catch (Exception ex) { logger.LogWarning(ex, "Could not remove torrent {Hash}", item.TorrentHash); }
}
db.ImportQueueItems.Remove(item);
await db.SaveChangesAsync();
return NoContent();
}
private static DownloadDto ToDto(ImportQueueItem i) => new()
{
Id = i.Id,
Filename = i.Filename,
SizeBytes = i.SizeBytes,
DownloadedBytes = i.DownloadedBytes,
Status = i.Status.ToString().ToLowerInvariant(),
SourceType = i.SourceType.ToString().ToLowerInvariant(),
TorrentHash = i.TorrentHash,
BookId = i.BookId,
BookTitle = i.Book?.Title,
Error = i.Error,
AddedAt = i.AddedAt,
};
private static string? ExtractInfoHash(string magnet)
{
var m = System.Text.RegularExpressions.Regex.Match(magnet,
@"urn:btih:([a-fA-F0-9]{40}|[A-Z2-7]{32})",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return m.Success ? m.Groups[1].Value.ToLowerInvariant() : null;
}
}
@@ -8,7 +8,12 @@ namespace PageManager.Api.Controllers;
[ApiController]
[Route("api")]
public class FilesController(IFilesRepository filesRepo, IFileScannerService scanner) : ControllerBase
public class FilesController(
IFilesRepository filesRepo,
IFileScannerService scanner,
IFileOrganizerService organizer,
IMetadataWriterService metadataWriter,
ILogger<FilesController> logger) : ControllerBase
{
// GET /api/books/{id}/files
[HttpGet("books/{id:int}/files")]
@@ -35,9 +40,50 @@ public class FilesController(IFilesRepository filesRepo, IFileScannerService sca
{
var file = await filesRepo.AssignAsync(id, req.BookId, req.EditionId);
if (file is null) return NotFound();
// Auto-organize when assigning to a book (not when unlinking)
if (req.BookId is not null)
{
try
{
var result = await organizer.OrganizeAsync(id);
if (result.Moved)
logger.LogInformation("Auto-organized file {Id} to {Path}", id, result.NewRelativePath);
else if (result.SkipReason is not null)
logger.LogDebug("Organize skipped for file {Id}: {Reason}", id, result.SkipReason);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Auto-organize failed for file {Id}, assignment still saved.", id);
}
}
// Re-fetch so path/filename reflect any rename
file = await filesRepo.GetByIdAsync(id) ?? file;
return Ok(ToDto(file));
}
// POST /api/files/{id}/organize
[HttpPost("files/{id:int}/organize")]
public async Task<IActionResult> Organize(int id, CancellationToken ct)
{
try
{
var result = await organizer.OrganizeAsync(id, ct);
return Ok(new
{
moved = result.Moved,
newRelativePath = result.NewRelativePath,
newFilename = result.NewFilename,
skipReason = result.SkipReason,
});
}
catch (KeyNotFoundException)
{
return NotFound();
}
}
// DELETE /api/files/{id}
[HttpDelete("files/{id:int}")]
public async Task<IActionResult> Delete(int id)
@@ -47,6 +93,37 @@ public class FilesController(IFilesRepository filesRepo, IFileScannerService sca
return NoContent();
}
// POST /api/files/{id}/write-metadata
[HttpPost("files/{id:int}/write-metadata")]
public async Task<IActionResult> WriteMetadata(int id, CancellationToken ct)
{
try
{
var result = await metadataWriter.WriteAsync(id, ct);
return Ok(new { success = result.Success, message = result.Message });
}
catch (KeyNotFoundException)
{
return NotFound();
}
}
// POST /api/books/{id}/write-metadata
[HttpPost("books/{id:int}/write-metadata")]
public async Task<IActionResult> WriteMetadataForBook(int id, CancellationToken ct)
{
var files = (await filesRepo.GetByBookIdAsync(id)).ToList();
if (files.Count == 0) return Ok(new { results = Array.Empty<object>() });
var results = new List<object>();
foreach (var file in files)
{
var r = await metadataWriter.WriteAsync(file.Id, ct);
results.Add(new { fileId = file.Id, filename = file.Filename, success = r.Success, message = r.Message });
}
return Ok(new { results });
}
// POST /api/scan
[HttpPost("scan")]
public async Task<IActionResult> TriggerScan(CancellationToken ct)
@@ -6,7 +6,7 @@ namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SearchController(IHardcoverService hardcover) : ControllerBase
public class SearchController(IHardcoverService hardcover, IIndexerService indexer) : ControllerBase
{
[HttpGet("books")]
public async Task<ActionResult<IEnumerable<HardcoverBookResult>>> SearchBooks([FromQuery] string q)
@@ -17,4 +17,25 @@ public class SearchController(IHardcoverService hardcover) : ControllerBase
var results = await hardcover.SearchBooksAsync(q);
return Ok(results);
}
[HttpGet("torrents")]
public async Task<ActionResult<IEnumerable<TorrentSearchResultDto>>> SearchTorrents(
[FromQuery] string q, [FromQuery] string type = "ebook")
{
if (string.IsNullOrWhiteSpace(q))
return BadRequest("Query parameter 'q' is required.");
var results = await indexer.SearchAsync(q, type);
return Ok(results.Select(r => new TorrentSearchResultDto
{
Title = r.Title,
Magnet = r.Magnet,
DownloadUrl = r.DownloadUrl,
SizeBytes = r.SizeBytes,
Seeders = r.Seeders,
Leechers = r.Leechers,
Indexer = r.Indexer,
PublishDate = r.PublishDate,
}));
}
}
@@ -0,0 +1,118 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PageManager.Api.Api.Dtos;
using PageManager.Api.Data;
using PageManager.Api.Data.Models;
using PageManager.Api.Services;
namespace PageManager.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class WantedController(
AppDbContext db,
IIndexerService indexer) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<WantedBookDto>> GetWanted()
{
var items = await db.WantedBooks
.Include(w => w.Book).ThenInclude(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.OrderByDescending(w => w.AddedAt)
.ToListAsync();
return items.Select(ToDto);
}
[HttpPost]
public async Task<ActionResult<WantedBookDto>> AddWanted(AddWantedRequest req)
{
var book = await db.Books.FindAsync(req.BookId);
if (book is null) return NotFound($"Book {req.BookId} not found.");
// Upsert — one wanted entry per book
var existing = await db.WantedBooks.FirstOrDefaultAsync(w => w.BookId == req.BookId);
if (existing is not null)
{
existing.FormatPreference = ParseFormat(req.FormatPreference);
existing.MinSeeders = req.MinSeeders;
existing.Status = WantedStatus.Wanted;
}
else
{
existing = new WantedBook
{
BookId = req.BookId,
FormatPreference = ParseFormat(req.FormatPreference),
MinSeeders = req.MinSeeders,
};
db.WantedBooks.Add(existing);
}
await db.SaveChangesAsync();
await db.Entry(existing).Reference(w => w.Book).LoadAsync();
await db.Entry(existing.Book).Collection(b => b.BookAuthors).Query()
.Include(ba => ba.Author).LoadAsync();
return Ok(ToDto(existing));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> RemoveWanted(int id)
{
var item = await db.WantedBooks.FindAsync(id);
if (item is null) return NotFound();
db.WantedBooks.Remove(item);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("{id:int}/search-now")]
public async Task<ActionResult<IEnumerable<TorrentSearchResultDto>>> SearchNow(int id)
{
var w = await db.WantedBooks
.Include(w => w.Book).ThenInclude(b => b.BookAuthors).ThenInclude(ba => ba.Author)
.FirstOrDefaultAsync(w => w.Id == id);
if (w is null) return NotFound();
var primaryAuthor = w.Book.BookAuthors.FirstOrDefault()?.Author.Name ?? "";
var query = string.IsNullOrEmpty(primaryAuthor)
? w.Book.Title
: $"{w.Book.Title} {primaryAuthor}";
var type = w.FormatPreference.HasValue && IsAudioFormat(w.FormatPreference.Value)
? "audiobook" : "ebook";
var results = await indexer.SearchAsync(query, type);
return Ok(results.Select(r => new TorrentSearchResultDto
{
Title = r.Title,
Magnet = r.Magnet,
DownloadUrl = r.DownloadUrl,
SizeBytes = r.SizeBytes,
Seeders = r.Seeders,
Leechers = r.Leechers,
Indexer = r.Indexer,
PublishDate = r.PublishDate,
}));
}
private static WantedBookDto ToDto(WantedBook w) => new()
{
Id = w.Id,
BookId = w.BookId,
BookTitle = w.Book?.Title ?? "",
BookAuthors = w.Book?.BookAuthors.Select(ba => ba.Author.Name).ToArray() ?? [],
BookCoverUrl = w.Book?.CoverUrl,
AddedAt = w.AddedAt,
Status = w.Status.ToString().ToLowerInvariant(),
FormatPreference = w.FormatPreference?.ToString()?.ToLowerInvariant(),
MinSeeders = w.MinSeeders,
};
private static FileFormat? ParseFormat(string? s) =>
Enum.TryParse<FileFormat>(s, ignoreCase: true, out var f) ? f : null;
private static bool IsAudioFormat(FileFormat f) =>
f is FileFormat.M4b or FileFormat.Mp3 or FileFormat.Aac or FileFormat.Flac;
}
@@ -14,6 +14,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<ImportSource> ImportSources => Set<ImportSource>();
public DbSet<ImportQueueItem> ImportQueueItems => Set<ImportQueueItem>();
public DbSet<BookFile> BookFiles => Set<BookFile>();
public DbSet<WantedBook> WantedBooks => Set<WantedBook>();
protected override void OnModelCreating(ModelBuilder model)
{
@@ -96,6 +97,26 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
{
e.Property(i => i.Id).ValueGeneratedNever();
e.Property(i => i.Status).HasConversion<string>();
e.Property(i => i.SourceType).HasConversion<string>();
e.HasOne(i => i.Book)
.WithMany()
.HasForeignKey(i => i.BookId)
.OnDelete(DeleteBehavior.SetNull);
});
// ── WantedBook ────────────────────────────────────────────────────────
model.Entity<WantedBook>(e =>
{
e.Property(w => w.Status).HasConversion<string>();
e.Property(w => w.FormatPreference).HasConversion<string>();
e.HasOne(w => w.Book)
.WithMany(b => b.WantedBooks)
.HasForeignKey(w => w.BookId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(w => w.BookId).IsUnique(); // one entry per book
});
// ── BookFile ──────────────────────────────────────────────────────────
@@ -29,4 +29,5 @@ public class Book
public ICollection<SeriesEntry> SeriesEntries { get; set; } = [];
public ICollection<Edition> Editions { get; set; } = [];
public ICollection<BookFile> BookFiles { get; set; } = [];
public ICollection<WantedBook> WantedBooks { get; set; } = [];
}
@@ -1,6 +1,7 @@
namespace PageManager.Api.Data.Models;
public enum QueueItemStatus { Queued, Downloading, Completed, Failed }
public enum 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
}
]
}