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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user