diff --git a/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileOrganizerServiceTests.cs b/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileOrganizerServiceTests.cs new file mode 100644 index 0000000..b265a51 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileOrganizerServiceTests.cs @@ -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")); + } +} diff --git a/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileScannerServiceTests.cs b/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileScannerServiceTests.cs index 596dd04..571f2da 100644 --- a/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileScannerServiceTests.cs +++ b/PageManager.Api/PageManager.Api.Tests/Unit/Services/FileScannerServiceTests.cs @@ -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); + } } diff --git a/PageManager.Api/PageManager.Api.Tests/Unit/Services/MetadataWriterServiceTests.cs b/PageManager.Api/PageManager.Api.Tests/Unit/Services/MetadataWriterServiceTests.cs new file mode 100644 index 0000000..ae8bbd4 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Unit/Services/MetadataWriterServiceTests.cs @@ -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 = """ + + + + + + + """; + + MetadataWriterService.ParseOpfPath(xml).Should().Be("OEBPS/content.opf"); + } + + // ── ModifyOpfXml ────────────────────────────────────────────────────────── + + private const string MinimalOpf = """ + + + + Old Title + Old Author + en + + + + + """; + + [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("Dune"); + } + + [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(""); + } + + [Fact] + public void ModifyOpfXml_UpdatesExistingIsbnIdentifier_RatherThanAdding() + { + var opfWithIsbn = MinimalOpf.Replace( + "en", + "en\n OLD-ISBN"); + + 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 + } +} diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs b/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs index d979205..f992ee6 100644 --- a/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs +++ b/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs @@ -40,4 +40,6 @@ public class BookDto public string? Isbn { get; set; } public int? HardcoverId { get; set; } public EditionDto[] Editions { get; set; } = []; + /// Distinct file formats of BookFile records actually in the library. + public string[] LocalFileFormats { get; set; } = []; } diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/DownloadDtos.cs b/PageManager.Api/PageManager.Api/Api/Dtos/DownloadDtos.cs new file mode 100644 index 0000000..15bcd73 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Api/Dtos/DownloadDtos.cs @@ -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; } +} diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/WantedDtos.cs b/PageManager.Api/PageManager.Api/Api/Dtos/WantedDtos.cs new file mode 100644 index 0000000..a9086e1 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Api/Dtos/WantedDtos.cs @@ -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; +} diff --git a/PageManager.Api/PageManager.Api/Controllers/DownloadsController.cs b/PageManager.Api/PageManager.Api/Controllers/DownloadsController.cs new file mode 100644 index 0000000..fd2a05e --- /dev/null +++ b/PageManager.Api/PageManager.Api/Controllers/DownloadsController.cs @@ -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 logger) : ControllerBase +{ + [HttpGet] + public async Task> GetDownloads() + { + var items = await db.ImportQueueItems + .Include(i => i.Book) + .OrderByDescending(i => i.AddedAt) + .ToListAsync(); + + return items.Select(ToDto); + } + + [HttpPost] + public async Task> 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 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; + } +} diff --git a/PageManager.Api/PageManager.Api/Controllers/FilesController.cs b/PageManager.Api/PageManager.Api/Controllers/FilesController.cs index 38a3a5f..e08cadb 100644 --- a/PageManager.Api/PageManager.Api/Controllers/FilesController.cs +++ b/PageManager.Api/PageManager.Api/Controllers/FilesController.cs @@ -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 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 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 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 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 WriteMetadataForBook(int id, CancellationToken ct) + { + var files = (await filesRepo.GetByBookIdAsync(id)).ToList(); + if (files.Count == 0) return Ok(new { results = Array.Empty() }); + + var results = new List(); + 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 TriggerScan(CancellationToken ct) diff --git a/PageManager.Api/PageManager.Api/Controllers/SearchController.cs b/PageManager.Api/PageManager.Api/Controllers/SearchController.cs index 2f03a71..8909198 100644 --- a/PageManager.Api/PageManager.Api/Controllers/SearchController.cs +++ b/PageManager.Api/PageManager.Api/Controllers/SearchController.cs @@ -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>> 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>> 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, + })); + } } diff --git a/PageManager.Api/PageManager.Api/Controllers/WantedController.cs b/PageManager.Api/PageManager.Api/Controllers/WantedController.cs new file mode 100644 index 0000000..d016f96 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Controllers/WantedController.cs @@ -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> 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> 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 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>> 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(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; +} diff --git a/PageManager.Api/PageManager.Api/Data/AppDbContext.cs b/PageManager.Api/PageManager.Api/Data/AppDbContext.cs index 1b9b721..80f233e 100644 --- a/PageManager.Api/PageManager.Api/Data/AppDbContext.cs +++ b/PageManager.Api/PageManager.Api/Data/AppDbContext.cs @@ -14,6 +14,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet ImportSources => Set(); public DbSet ImportQueueItems => Set(); public DbSet BookFiles => Set(); + public DbSet WantedBooks => Set(); protected override void OnModelCreating(ModelBuilder model) { @@ -96,6 +97,26 @@ public class AppDbContext(DbContextOptions options) : DbContext(op { e.Property(i => i.Id).ValueGeneratedNever(); e.Property(i => i.Status).HasConversion(); + e.Property(i => i.SourceType).HasConversion(); + + e.HasOne(i => i.Book) + .WithMany() + .HasForeignKey(i => i.BookId) + .OnDelete(DeleteBehavior.SetNull); + }); + + // ── WantedBook ──────────────────────────────────────────────────────── + model.Entity(e => + { + e.Property(w => w.Status).HasConversion(); + e.Property(w => w.FormatPreference).HasConversion(); + + 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 ────────────────────────────────────────────────────────── diff --git a/PageManager.Api/PageManager.Api/Data/Models/Book.cs b/PageManager.Api/PageManager.Api/Data/Models/Book.cs index d31a422..8c1df43 100644 --- a/PageManager.Api/PageManager.Api/Data/Models/Book.cs +++ b/PageManager.Api/PageManager.Api/Data/Models/Book.cs @@ -29,4 +29,5 @@ public class Book public ICollection SeriesEntries { get; set; } = []; public ICollection Editions { get; set; } = []; public ICollection BookFiles { get; set; } = []; + public ICollection WantedBooks { get; set; } = []; } diff --git a/PageManager.Api/PageManager.Api/Data/Models/ImportQueueItem.cs b/PageManager.Api/PageManager.Api/Data/Models/ImportQueueItem.cs index 6b2ea55..d49edd2 100644 --- a/PageManager.Api/PageManager.Api/Data/Models/ImportQueueItem.cs +++ b/PageManager.Api/PageManager.Api/Data/Models/ImportQueueItem.cs @@ -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; } diff --git a/PageManager.Api/PageManager.Api/Data/Models/WantedBook.cs b/PageManager.Api/PageManager.Api/Data/Models/WantedBook.cs new file mode 100644 index 0000000..5f2c616 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/WantedBook.cs @@ -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; + /// Preferred file format — null means any. + public FileFormat? FormatPreference { get; set; } + public int MinSeeders { get; set; } = 1; +} diff --git a/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs b/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs index 9a4756a..d984f3d 100644 --- a/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs +++ b/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs @@ -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)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 FindByHardcoverIdAsync(int hardcoverId) => diff --git a/PageManager.Api/PageManager.Api/Migrations/20260330000000_AddTorrentSupport.cs b/PageManager.Api/PageManager.Api/Migrations/20260330000000_AddTorrentSupport.cs new file mode 100644 index 0000000..c77d9f4 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260330000000_AddTorrentSupport.cs @@ -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 + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // ── Extend import_queue_items ──────────────────────────────────────── + migrationBuilder.AddColumn( + name: "source_type", + table: "import_queue_items", + type: "text", + nullable: false, + defaultValue: "Manual"); + + migrationBuilder.AddColumn( + name: "torrent_hash", + table: "import_queue_items", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "torrent_magnet", + table: "import_queue_items", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "book_id", + table: "import_queue_items", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "added_at", + table: "import_queue_items", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now()"); + + // Make source nullable (was required) + migrationBuilder.AlterColumn( + 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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + book_id = table.Column(type: "integer", nullable: false), + added_at = table.Column(type: "timestamp with time zone", nullable: false), + status = table.Column(type: "text", nullable: false), + format_preference = table.Column(type: "text", nullable: true), + min_seeders = table.Column(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); + } + + /// + 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( + name: "source", + table: "import_queue_items", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs b/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs index a0480ec..1d79932 100644 --- a/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs @@ -299,6 +299,14 @@ namespace PageManager.Api.Migrations .HasColumnType("text") .HasColumnName("id"); + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + b.Property("DownloadedBytes") .HasColumnType("bigint") .HasColumnName("downloaded_bytes"); @@ -317,21 +325,79 @@ namespace PageManager.Api.Migrations .HasColumnName("size_bytes"); b.Property("Source") - .IsRequired() .HasColumnType("text") .HasColumnName("source"); + b.Property("SourceType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source_type") + .HasConversion(); + b.Property("Status") .IsRequired() .HasColumnType("text") .HasColumnName("status"); + b.Property("TorrentHash") + .HasColumnType("text") + .HasColumnName("torrent_hash"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("FormatPreference") + .HasColumnType("text") + .HasColumnName("format_preference") + .HasConversion(); + + b.Property("MinSeeders") + .HasColumnType("integer") + .HasColumnName("min_seeders"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status") + .HasConversion(); + + 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("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 => diff --git a/PageManager.Api/PageManager.Api/PageManager.Api.csproj b/PageManager.Api/PageManager.Api/PageManager.Api.csproj index b4cdcd1..39d3a76 100644 --- a/PageManager.Api/PageManager.Api/PageManager.Api.csproj +++ b/PageManager.Api/PageManager.Api/PageManager.Api.csproj @@ -21,9 +21,11 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/PageManager.Api/PageManager.Api/Program.cs b/PageManager.Api/PageManager.Api/Program.cs index e66ece9..64c5637 100644 --- a/PageManager.Api/PageManager.Api/Program.cs +++ b/PageManager.Api/PageManager.Api/Program.cs @@ -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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); + +// Phase 4: torrent + indexer +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHttpClient("qbittorrent"); +builder.Services.AddHttpClient("indexer"); + builder.Services.AddOpenApi(); builder.Services.AddDbContext(options => diff --git a/PageManager.Api/PageManager.Api/Services/BooksService.cs b/PageManager.Api/PageManager.Api/Services/BooksService.cs index 6ec4603..3943dd7 100644 --- a/PageManager.Api/PageManager.Api/Services/BooksService.cs +++ b/PageManager.Api/PageManager.Api/Services/BooksService.cs @@ -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, diff --git a/PageManager.Api/PageManager.Api/Services/DownloadWorkerService.cs b/PageManager.Api/PageManager.Api/Services/DownloadWorkerService.cs new file mode 100644 index 0000000..bb1fd6f --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/DownloadWorkerService.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data; +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Services; + +/// +/// Background service that polls qBittorrent every 30 seconds and syncs +/// torrent progress/state back into the ImportQueueItems table. +/// +public class DownloadWorkerService( + IServiceScopeFactory scopeFactory, + IQBittorrentClient torrent, + ILogger 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(); + var scanner = scope.ServiceProvider.GetRequiredService(); + + var active = await db.ImportQueueItems + .Where(i => i.Status == QueueItemStatus.Downloading && i.TorrentHash != null) + .ToListAsync(ct); + + if (active.Count == 0) return; + + IReadOnlyList 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); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Services/FileOrganizerService.cs b/PageManager.Api/PageManager.Api/Services/FileOrganizerService.cs new file mode 100644 index 0000000..de9cd0a --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/FileOrganizerService.cs @@ -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 logger) : IFileOrganizerService +{ + private static readonly FileFormat[] AudioFormats = [FileFormat.M4b, FileFormat.Mp3, FileFormat.Aac, FileFormat.Flac]; + + public async Task 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); +} diff --git a/PageManager.Api/PageManager.Api/Services/FileScannerService.cs b/PageManager.Api/PageManager.Api/Services/FileScannerService.cs index 76f6b9c..2358c7c 100644 --- a/PageManager.Api/PageManager.Api/Services/FileScannerService.cs +++ b/PageManager.Api/PageManager.Api/Services/FileScannerService.cs @@ -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 logger) : IFileScannerService { - private static readonly HashSet 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 ScanSourceAsync(ImportSource source, CancellationToken ct) { IEnumerable 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 BuildConfigSources() + { + var sources = new List(); + + 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 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; } } diff --git a/PageManager.Api/PageManager.Api/Services/IFileOrganizerService.cs b/PageManager.Api/PageManager.Api/Services/IFileOrganizerService.cs new file mode 100644 index 0000000..39dde7b --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IFileOrganizerService.cs @@ -0,0 +1,18 @@ +namespace PageManager.Api.Services; + +public interface IFileOrganizerService +{ + /// + /// 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. + /// + Task OrganizeAsync(int fileId, CancellationToken ct = default); +} + +public record OrganizeResult( + bool Moved, + string? OldAbsolutePath, + string? NewAbsolutePath, + string NewRelativePath, + string NewFilename, + string? SkipReason = null); diff --git a/PageManager.Api/PageManager.Api/Services/IFileSystem.cs b/PageManager.Api/PageManager.Api/Services/IFileSystem.cs index c6b2e9e..326dafd 100644 --- a/PageManager.Api/PageManager.Api/Services/IFileSystem.cs +++ b/PageManager.Api/PageManager.Api/Services/IFileSystem.cs @@ -3,7 +3,11 @@ namespace PageManager.Api.Services; public interface IFileSystem { bool DirectoryExists(string path); + bool FileExists(string path); IEnumerable EnumerateFiles(string path, SearchOption searchOption); long GetFileSize(string path); Task ComputeSha256Async(string path, CancellationToken cancellationToken = default); + void EnsureDirectoryExists(string path); + Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken ct = default); + void DeleteFile(string path); } diff --git a/PageManager.Api/PageManager.Api/Services/IIndexerService.cs b/PageManager.Api/PageManager.Api/Services/IIndexerService.cs new file mode 100644 index 0000000..02bb199 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IIndexerService.cs @@ -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 +{ + /// "ebook" or "audiobook" + Task> SearchAsync(string query, string type, CancellationToken ct = default); +} diff --git a/PageManager.Api/PageManager.Api/Services/IMetadataWriterService.cs b/PageManager.Api/PageManager.Api/Services/IMetadataWriterService.cs new file mode 100644 index 0000000..d1c11cf --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IMetadataWriterService.cs @@ -0,0 +1,9 @@ +namespace PageManager.Api.Services; + +public interface IMetadataWriterService +{ + /// Writes book metadata into the file at . + Task WriteAsync(int fileId, CancellationToken ct = default); +} + +public record WriteMetadataResult(bool Success, string Message); diff --git a/PageManager.Api/PageManager.Api/Services/IQBittorrentClient.cs b/PageManager.Api/PageManager.Api/Services/IQBittorrentClient.cs new file mode 100644 index 0000000..69438b4 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IQBittorrentClient.cs @@ -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 AddMagnetAsync(string magnet, string savePath, string? category = null, CancellationToken ct = default); + Task GetTorrentAsync(string hash, CancellationToken ct = default); + Task> GetTorrentsAsync(CancellationToken ct = default); + Task RemoveTorrentAsync(string hash, bool deleteFiles = false, CancellationToken ct = default); +} diff --git a/PageManager.Api/PageManager.Api/Services/IndexerConfig.cs b/PageManager.Api/PageManager.Api/Services/IndexerConfig.cs new file mode 100644 index 0000000..2d457c6 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IndexerConfig.cs @@ -0,0 +1,19 @@ +namespace PageManager.Api.Services; + +public class IndexerConfig +{ + public string Name { get; set; } = string.Empty; + /// "prowlarr" or "jackett" + 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", + }; +} diff --git a/PageManager.Api/PageManager.Api/Services/IndexerService.cs b/PageManager.Api/PageManager.Api/Services/IndexerService.cs new file mode 100644 index 0000000..48cba22 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IndexerService.cs @@ -0,0 +1,104 @@ +using System.Xml.Linq; + +namespace PageManager.Api.Services; + +public class IndexerService( + IHttpClientFactory httpFactory, + IConfiguration config, + ILogger 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> SearchAsync( + string query, string type, CancellationToken ct = default) + { + var indexers = config.GetSection("Indexers").Get() ?? []; + 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> 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 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(); + 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; + } +} diff --git a/PageManager.Api/PageManager.Api/Services/MetadataWriterService.cs b/PageManager.Api/PageManager.Api/Services/MetadataWriterService.cs new file mode 100644 index 0000000..d862511 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/MetadataWriterService.cs @@ -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 logger) : IMetadataWriterService +{ + private static readonly FileFormat[] AudioFormats = [FileFormat.M4b, FileFormat.Mp3, FileFormat.Aac, FileFormat.Flac]; + + public async Task 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 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 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 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); +} diff --git a/PageManager.Api/PageManager.Api/Services/MonitoringWorkerService.cs b/PageManager.Api/PageManager.Api/Services/MonitoringWorkerService.cs new file mode 100644 index 0000000..c2ebe8d --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/MonitoringWorkerService.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data; +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Services; + +/// +/// Background service that periodically searches indexers for Wanted books +/// and auto-queues the best torrent found. +/// +public class MonitoringWorkerService( + IServiceScopeFactory scopeFactory, + IIndexerService indexer, + IQBittorrentClient torrent, + ILogger 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(); + 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(); + var cfg = scope.ServiceProvider.GetRequiredService(); + + 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; + } +} diff --git a/PageManager.Api/PageManager.Api/Services/PhysicalFileSystem.cs b/PageManager.Api/PageManager.Api/Services/PhysicalFileSystem.cs index c08047e..41bf03e 100644 --- a/PageManager.Api/PageManager.Api/Services/PhysicalFileSystem.cs +++ b/PageManager.Api/PageManager.Api/Services/PhysicalFileSystem.cs @@ -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 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); } diff --git a/PageManager.Api/PageManager.Api/Services/QBittorrentClient.cs b/PageManager.Api/PageManager.Api/Services/QBittorrentClient.cs new file mode 100644 index 0000000..a0f2fd2 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/QBittorrentClient.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace PageManager.Api.Services; + +/// Client for the qBittorrent Web API v2. +public class QBittorrentClient( + IHttpClientFactory httpFactory, + IConfiguration config, + ILogger 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 AddMagnetAsync( + string magnet, string savePath, string? category = null, CancellationToken ct = default) + { + var fields = new Dictionary + { + ["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 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> 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 + { + ["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 + { + ["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 ExecuteAsync( + Func> 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() ?? ""; + var name = obj["name"]?.GetValue() ?? ""; + var size = obj["size"]?.GetValue() ?? 0; + var completed = obj["completed"]?.GetValue() ?? 0; + var progress = obj["progress"]?.GetValue() ?? 0f; + var stateStr = obj["state"]?.GetValue() ?? ""; + + 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); + } +} diff --git a/PageManager.Api/PageManager.Api/appsettings.json b/PageManager.Api/PageManager.Api/appsettings.json index 375f589..139c3ae 100644 --- a/PageManager.Api/PageManager.Api/appsettings.json +++ b/PageManager.Api/PageManager.Api/appsettings.json @@ -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 + } + ] } diff --git a/PageManager.Web/src/api/downloads.ts b/PageManager.Web/src/api/downloads.ts new file mode 100644 index 0000000..6708a35 --- /dev/null +++ b/PageManager.Web/src/api/downloads.ts @@ -0,0 +1,28 @@ +import type { Download, TorrentSearchResult } from '../types' + +export async function fetchDownloads(): Promise { + const res = await fetch('/api/downloads') + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function addDownload(magnet: string, bookId?: number): Promise { + const res = await fetch('/api/downloads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ magnet, bookId: bookId ?? null }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function cancelDownload(id: string): Promise { + const res = await fetch(`/api/downloads/${id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error(await res.text()) +} + +export async function searchTorrents(q: string, type: 'ebook' | 'audiobook' = 'ebook'): Promise { + const res = await fetch(`/api/search/torrents?q=${encodeURIComponent(q)}&type=${type}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} diff --git a/PageManager.Web/src/api/files.ts b/PageManager.Web/src/api/files.ts index 8551d4d..18f7292 100644 --- a/PageManager.Web/src/api/files.ts +++ b/PageManager.Web/src/api/files.ts @@ -23,3 +23,15 @@ export function deleteFile(id: number): Promise { export function triggerScan(): Promise { return fetch('/api/scan', { method: 'POST' }).then(() => undefined) } + +export function organizeFile(id: number): Promise<{ moved: boolean; newRelativePath: string; newFilename: string; skipReason: string | null }> { + return fetch(`/api/files/${id}/organize`, { method: 'POST' }).then(r => r.json()) +} + +export function writeFileMetadata(id: number): Promise<{ success: boolean; message: string }> { + return fetch(`/api/files/${id}/write-metadata`, { method: 'POST' }).then(r => r.json()) +} + +export function writeBookMetadata(bookId: number): Promise<{ results: { fileId: number; filename: string; success: boolean; message: string }[] }> { + return fetch(`/api/books/${bookId}/write-metadata`, { method: 'POST' }).then(r => r.json()) +} diff --git a/PageManager.Web/src/api/wanted.ts b/PageManager.Web/src/api/wanted.ts new file mode 100644 index 0000000..38832b3 --- /dev/null +++ b/PageManager.Web/src/api/wanted.ts @@ -0,0 +1,28 @@ +import type { TorrentSearchResult, WantedBook } from '../types' + +export async function fetchWanted(): Promise { + const res = await fetch('/api/wanted') + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function addWanted(bookId: number, formatPreference?: string, minSeeders = 1): Promise { + const res = await fetch('/api/wanted', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bookId, formatPreference: formatPreference ?? null, minSeeders }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function removeWanted(id: number): Promise { + const res = await fetch(`/api/wanted/${id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error(await res.text()) +} + +export async function searchNow(id: number): Promise { + const res = await fetch(`/api/wanted/${id}/search-now`, { method: 'POST' }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} diff --git a/PageManager.Web/src/components/BookCard/BookCard.module.css b/PageManager.Web/src/components/BookCard/BookCard.module.css index a4f0cd6..e7448d6 100644 --- a/PageManager.Web/src/components/BookCard/BookCard.module.css +++ b/PageManager.Web/src/components/BookCard/BookCard.module.css @@ -72,6 +72,26 @@ border-radius: 4px; } +.fileBadges { + position: absolute; + bottom: 8px; + left: 8px; + display: flex; + gap: 4px; +} + +.fileBadge { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: rgba(0,0,0,.55); + border-radius: 4px; + font-size: 12px; + line-height: 1; +} + .body { padding: 8px 10px 10px; display: flex; diff --git a/PageManager.Web/src/components/BookCard/BookCard.tsx b/PageManager.Web/src/components/BookCard/BookCard.tsx index d674370..21781ff 100644 --- a/PageManager.Web/src/components/BookCard/BookCard.tsx +++ b/PageManager.Web/src/components/BookCard/BookCard.tsx @@ -1,7 +1,9 @@ -import { Tag } from 'antd' -import type { Book } from '../../types' +import type { Book, FileFormat } from '../../types' import s from './BookCard.module.css' +const EBOOK_FORMATS: FileFormat[] = ['epub', 'mobi', 'pdf'] +const AUDIOBOOK_FORMATS: FileFormat[] = ['m4b', 'mp3', 'aac', 'flac'] + interface Props { book: Book onClick: (book: Book) => void @@ -16,6 +18,9 @@ export default function BookCard({ book, onClick, selected }: Props) { .join('') .toUpperCase() + const hasEbook = book.localFileFormats.some(f => EBOOK_FORMATS.includes(f)) + const hasAudiobook = book.localFileFormats.some(f => AUDIOBOOK_FORMATS.includes(f)) + return (
#{book.series.position} )} + {(hasEbook || hasAudiobook) && ( + + {hasEbook && 📖} + {hasAudiobook && 🎧} + + )}

{book.title}

{book.authors.map(a => a.name).join(', ')}

-
- {book.year && {book.year}} -
- {book.formats.map(f => ( - - {f.toUpperCase()} - - ))} + {book.year && ( +
+ {book.year}
-
+ )}
diff --git a/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx b/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx index 92cdff1..6987e84 100644 --- a/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx +++ b/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx @@ -15,10 +15,12 @@ function makeBook(overrides: Partial = {}): Book { formats: ['epub'], color: '#6366f1', genres: ['Science Fiction'], - authors: [{ id: 1, name: 'Frank Herbert' }], + authors: [{ id: 1, name: 'Frank Herbert', bio: null, bornYear: null, imageUrl: null, slug: null, role: 'Author' }], coverUrl: null, isbn: null, hardcoverId: null, + editions: [], + localFileFormats: [], ...overrides, } } @@ -34,10 +36,20 @@ describe('BookCard', () => { expect(screen.getByText('Frank Herbert')).toBeInTheDocument() }) - it('renders format chips', () => { - render() - expect(screen.getByText('EPUB')).toBeInTheDocument() - expect(screen.getByText('MOBI')).toBeInTheDocument() + it('renders ebook badge when ebook file is present', () => { + render() + expect(screen.getByTitle('Ebook in library')).toBeInTheDocument() + }) + + it('renders audiobook badge when audiobook file is present', () => { + render() + expect(screen.getByTitle('Audiobook in library')).toBeInTheDocument() + }) + + it('renders no file badges when no local files', () => { + render() + expect(screen.queryByTitle('Ebook in library')).not.toBeInTheDocument() + expect(screen.queryByTitle('Audiobook in library')).not.toBeInTheDocument() }) it('renders series position pill when series exists', () => { diff --git a/PageManager.Web/src/pages/BookDetail/BookDetail.tsx b/PageManager.Web/src/pages/BookDetail/BookDetail.tsx index 4438b9f..4873d78 100644 --- a/PageManager.Web/src/pages/BookDetail/BookDetail.tsx +++ b/PageManager.Web/src/pages/BookDetail/BookDetail.tsx @@ -1,29 +1,43 @@ import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { Button, Divider, Flex, Popconfirm, Skeleton, Tag, Typography } from 'antd' +import { Button, Divider, Flex, Popconfirm, Select, Skeleton, Tag, Tooltip, Typography, message } from 'antd' import { ArrowLeftOutlined, AudioOutlined, BankOutlined, CalendarOutlined, DeleteOutlined, + DownOutlined, EditOutlined, FileTextOutlined, + FolderOutlined, LinkOutlined, ReadOutlined, + SearchOutlined, + StarFilled, + StarOutlined, TabletOutlined, + TagsOutlined, } from '@ant-design/icons' -import type { Book, BookFile, Edition, ReadingFormat } from '../../types' +import type { Book, BookFile, Edition, FileFormat, ReadingFormat, WantedBook } from '../../types' + +const EBOOK_FORMATS: FileFormat[] = ['epub', 'mobi', 'pdf'] +const AUDIOBOOK_FORMATS: FileFormat[] = ['m4b', 'mp3', 'aac', 'flac'] import { fetchBook } from '../../api/books' -import { assignFile, deleteFile, fetchBookFiles } from '../../api/files' +import { assignFile, deleteFile, fetchBookFiles, organizeFile, writeFileMetadata, writeBookMetadata } from '../../api/files' +import { addWanted, fetchWanted, removeWanted } from '../../api/wanted' import s from './BookDetail.module.css' export default function BookDetail() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() - const [book, setBook] = useState(null) - const [loading, setLoading] = useState(true) - const [files, setFiles] = useState([]) + const [book, setBook] = useState(null) + const [loading, setLoading] = useState(true) + const [files, setFiles] = useState([]) + const [editionsOpen, setEditionsOpen] = useState(false) + const [writingAll, setWritingAll] = useState(false) + const [wanted, setWanted] = useState(null) + const [toggling, setToggling] = useState(false) useEffect(() => { if (!id) return @@ -31,13 +45,36 @@ export default function BookDetail() { fetchBook(Number(id)) .then(b => { setBook(b) - return fetchBookFiles(b.id) + return Promise.all([ + fetchBookFiles(b.id), + fetchWanted().then(ws => ws.find(w => w.bookId === b.id) ?? null), + ]) }) - .then(setFiles) + .then(([fs, w]) => { setFiles(fs); setWanted(w) }) .catch(() => setBook(null)) .finally(() => setLoading(false)) }, [id]) + async function handleToggleMonitor() { + if (!book) return + setToggling(true) + try { + if (wanted) { + await removeWanted(wanted.id) + setWanted(null) + message.success('Removed from monitoring.') + } else { + const w = await addWanted(book.id) + setWanted(w) + message.success('Book is now monitored.') + } + } catch { + message.error('Failed to update monitoring.') + } finally { + setToggling(false) + } + } + function handleUnlink(fileId: number) { assignFile(fileId, null, null).then(updated => setFiles(fs => fs.map(f => f.id === fileId ? updated : f)) @@ -48,6 +85,43 @@ export default function BookDetail() { deleteFile(fileId).then(() => setFiles(fs => fs.filter(f => f.id !== fileId))) } + function handleAssignEdition(fileId: number, bookId: number | null, editionId: number | null) { + assignFile(fileId, bookId, editionId).then(updated => + setFiles(fs => fs.map(f => f.id === fileId ? updated : f)) + ) + } + + function handleOrganize(fileId: number) { + organizeFile(fileId).then(result => { + if (result.moved) + setFiles(fs => fs.map(f => f.id === fileId + ? { ...f, path: result.newRelativePath, filename: result.newFilename } + : f + )) + }) + } + + function handleWriteMetadata(fileId: number) { + return writeFileMetadata(fileId).then(r => { + if (r.success) message.success(r.message) + else message.error(r.message) + }) + } + + function handleWriteAllMetadata() { + if (!book) return + setWritingAll(true) + writeBookMetadata(book.id) + .then(r => { + const failed = r.results.filter(x => !x.success) + if (failed.length === 0) + message.success(`Metadata written to ${r.results.length} file(s).`) + else + message.warning(`${r.results.length - failed.length} succeeded, ${failed.length} failed.`) + }) + .finally(() => setWritingAll(false)) + } + return (
@@ -62,13 +136,28 @@ export default function BookDetail() {
{book && ( - + <> + + + + )} @@ -132,6 +221,31 @@ export default function BookDetail() { ))} )} + + {book.localFileFormats.length > 0 && ( + + {book.localFileFormats.some(f => EBOOK_FORMATS.includes(f)) && ( + + + Ebook + + )} + {book.localFileFormats.some(f => AUDIOBOOK_FORMATS.includes(f)) && ( + + + Audiobook + + )} + + )}
@@ -152,23 +266,55 @@ export default function BookDetail() { ) return filtered.length > 0 ? (
- - Editions from Hardcover ({filtered.length}) - -
- {filtered.map(ed => ( - - ))} -
+ + {editionsOpen && ( +
+ {filtered.map(ed => ( + + ))} +
+ )}
) : null })()} {/* Files */}
- - Files {files.length > 0 && `(${files.length})`} - + + + Files {files.length > 0 && `(${files.length})`} + + {files.length > 0 && ( + + + + )} + {files.length === 0 ? ( No files linked to this book. @@ -179,8 +325,14 @@ export default function BookDetail() { + !ed.language || ed.language === 'English' || ed.language === 'Latvian' + )} onUnlink={handleUnlink} onDelete={handleDeleteFile} + onAssignEdition={handleAssignEdition} + onOrganize={handleOrganize} + onWriteMetadata={handleWriteMetadata} /> ))}
@@ -271,18 +423,42 @@ function formatBytes(bytes: number): string { return `${bytes} B` } +function editionLabel(ed: Edition): string { + const parts: string[] = [] + if (ed.editionFormat) parts.push(ed.editionFormat) + else if (ed.readingFormat) parts.push(ed.readingFormat) + if (ed.releaseYear) parts.push(String(ed.releaseYear)) + if (ed.isbn) parts.push(`ISBN ${ed.isbn}`) + else if (ed.asin) parts.push(`ASIN ${ed.asin}`) + return parts.join(' · ') || `Edition #${ed.id}` +} + function FileRow({ file, + editions, onUnlink, onDelete, + onAssignEdition, + onOrganize, + onWriteMetadata, }: { file: BookFile + editions: Edition[] onUnlink: (id: number) => void onDelete: (id: number) => void + onAssignEdition: (fileId: number, bookId: number | null, editionId: number | null) => void + onOrganize: (id: number) => void + onWriteMetadata: (id: number) => void }) { + const [writing, setWriting] = useState(false) + + function handleWrite() { + setWriting(true) + onWriteMetadata(file.id).finally(() => setWriting(false)) + } return ( - - + + {file.format}
@@ -296,13 +472,42 @@ function FileRow({ {formatBytes(file.sizeBytes)}
- -
+
+ Format preference (optional) + setAddMinSeeders(Number(e.target.value) || 1)} + style={{ width: 120 }} + /> +
+
+ + + ) +} + +// ── Sources tab ────────────────────────────────────────────────────────────── + +function SourcesTab() { + const [sources, setSources] = useState([]) + const [scanning, setScanning] = useState(false) + const [unmatched, setUnmatched] = useState([]) + + // Match modal state + const [matchingFile, setMatchingFile] = useState(null) + const [matchTab, setMatchTab] = useState<'library' | 'hardcover'>('library') + const [books, setBooks] = useState([]) + const [bookSearch, setBookSearch] = useState('') + const [assigning, setAssigning] = useState(false) + const [hcQuery, setHcQuery] = useState('') + const [hcResults, setHcResults] = useState([]) + const [hcLoading, setHcLoading] = useState(false) + const [hcAdding, setHcAdding] = useState(null) + const [hcAdded, setHcAdded] = useState>(new Set()) + const hcTimerRef = useRef | null>(null) useEffect(() => { - fetchQueue().then(setQueue) fetchSources().then(setSources) fetchUnmatchedFiles().then(setUnmatched) + fetchBooks().then(setBooks) }, []) function handleScan() { @@ -40,16 +521,6 @@ export default function Import() { .finally(() => setScanning(false)) } - function handleRetry(id: string) { - retryQueueItem(id).then(() => - setQueue(q => q.map(i => i.id === id ? { ...i, status: 'queued' as const } : i)) - ) - } - - function handleRemove(id: string) { - removeQueueItem(id).then(() => setQueue(q => q.filter(i => i.id !== id))) - } - function toggleSource(id: string) { const current = sources.find(s => s.id === id) if (!current) return @@ -60,55 +531,84 @@ export default function Import() { ) } - const active = queue.filter(i => i.status === 'downloading' || i.status === 'queued') - const finished = queue.filter(i => i.status === 'completed' || i.status === 'failed') + function openMatchModal(file: BookFile) { + setMatchingFile(file) + setMatchTab('library') + setBookSearch('') + setHcQuery('') + setHcResults([]) + setHcAdded(new Set()) + } + + function closeMatchModal() { + setMatchingFile(null) + setBookSearch('') + setHcQuery('') + setHcResults([]) + } + + function handleAssign(book: Book) { + if (!matchingFile) return + setAssigning(true) + assignFile(matchingFile.id, book.id, null) + .then(() => { + setUnmatched(fs => fs.filter(f => f.id !== matchingFile.id)) + closeMatchModal() + }) + .finally(() => setAssigning(false)) + } + + function handleHcQueryChange(q: string) { + setHcQuery(q) + if (hcTimerRef.current) clearTimeout(hcTimerRef.current) + if (!q.trim()) { setHcResults([]); return } + hcTimerRef.current = setTimeout(() => { + setHcLoading(true) + searchHardcover(q) + .then(setHcResults) + .catch(() => setHcResults([])) + .finally(() => setHcLoading(false)) + }, 400) + } + + async function handleHcAddAndMatch(result: HardcoverSearchResult) { + if (!matchingFile || hcAdding !== null || hcAdded.has(result.id)) return + setHcAdding(result.id) + try { + const book = await addBookFromHardcover(result.id) + setHcAdded(prev => new Set(prev).add(result.id)) + await assignFile(matchingFile.id, book.id, null) + setUnmatched(fs => fs.filter(f => f.id !== matchingFile.id)) + closeMatchModal() + } finally { + setHcAdding(null) + } + } + + const filteredBooks = useMemo(() => { + const q = bookSearch.toLowerCase() + if (!q) return books + return books.filter(b => + b.title.toLowerCase().includes(q) || + b.authors.some(a => a.name.toLowerCase().includes(q)) + ) + }, [books, bookSearch]) return ( -
- {/* Left column */} -
-
- Drop files - { - console.log('file:', file.name) - return false - }} - style={{ padding: '8px 0' }} - > -
- - Drop EPUB, MOBI, PDF files here -
- or click to browse -
-
-
- + <> +
Sources
{sources.map(src => (
{SOURCE_ICONS[src.type] ?? }
@@ -121,16 +621,13 @@ export default function Import() { toggleSource(src.id)} - aria-label={`${src.enabled ? 'Disable' : 'Enable'} ${src.name}`} size="small" />
))}
-
- - {/* Right column */} -
- {active.length > 0 && ( -
- - Downloading - - -
- {active.map(item => ( - - ))} -
-
- )} - - {finished.length > 0 && ( -
- History -
- {finished.map(item => ( - - ))} -
-
- )} - - {queue.length === 0 && unmatched.length === 0 && ( - - No recent activity. - - )} {unmatched.length > 0 && (
@@ -200,28 +652,239 @@ export default function Import() {
{unmatched.map(f => ( - + {f.format} - - {f.filename} - +
+ {f.filename} + {f.path !== f.filename && ( + + {f.path} + + )} +
+
))}
)}
+ + {/* Manual match modal */} + +
Match file to book
+ {matchingFile && ( + + {matchingFile.filename} + + )} +
+ } + open={matchingFile !== null} + onCancel={closeMatchModal} + footer={null} + width={520} + > + setMatchTab(v as 'library' | 'hardcover')} + options={[ + { label: 'Library', value: 'library' }, + { label: 'Search Hardcover', value: 'hardcover' }, + ]} + style={{ marginBottom: 12 }} + /> + + {matchTab === 'library' && ( + <> + } + placeholder="Search by title or author…" + value={bookSearch} + onChange={e => setBookSearch(e.target.value)} + style={{ marginBottom: 12 }} + autoFocus + /> +
+ {filteredBooks.length === 0 && ( + + No books found. + + )} + {filteredBooks.map(b => ( + + ))} +
+ + )} + + {matchTab === 'hardcover' && ( + <> + } + placeholder="Search Hardcover…" + value={hcQuery} + onChange={e => handleHcQueryChange(e.target.value)} + allowClear + style={{ marginBottom: 12 }} + autoFocus + /> +
+ {hcLoading && ( +
+ } /> +
+ )} + {!hcLoading && !hcQuery.trim() && ( + + Start typing to search Hardcover + + )} + {!hcLoading && hcQuery.trim() && hcResults.length === 0 && ( + + No results for "{hcQuery}" + + )} + {!hcLoading && hcResults.map(r => { + const isAdded = hcAdded.has(r.id) + const isAdding = hcAdding === r.id + return ( +
handleHcAddAndMatch(r)} + role="button" + tabIndex={0} + onKeyDown={e => { if (e.key === 'Enter') handleHcAddAndMatch(r) }} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: '10px 8px', borderRadius: 6, + cursor: isAdded ? 'default' : 'pointer', + background: isAdded ? '#f6f0ff' : 'transparent', + opacity: isAdding ? 0.6 : 1, transition: 'background 150ms', + }} + onMouseEnter={e => { if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'rgba(0,0,0,.03)' }} + onMouseLeave={e => { if (!isAdded) (e.currentTarget as HTMLElement).style.background = 'transparent' }} + > +
+ {r.title} + + {r.authors.join(', ')}{r.year ? ` · ${r.year}` : ''} + +
+ + {isAdding ? : isAdded ? : } + +
+ ) + })} +
+ + )} + + + ) +} + +// ── Main page ──────────────────────────────────────────────────────────────── + +export default function Import() { + const [searchParams] = useSearchParams() + const initialTab = searchParams.get('tab') ?? 'downloads' + const initialSearch = searchParams.get('q') ?? undefined + const [activeTab, setActiveTab] = useState(initialTab) + + const items = [ + { + key: 'downloads', + label: ( + Downloads + ), + children: ( +
+ +
+ ), + }, + { + key: 'search', + label: ( + Search + ), + children: ( +
+ +
+ ), + }, + { + key: 'wanted', + label: ( + Wanted + ), + children: ( +
+ +
+ ), + }, + { + key: 'sources', + label: ( + Sources + ), + children: ( +
+ +
+ ), + }, + ] + + return ( +
+
) } diff --git a/PageManager.Web/src/types/index.ts b/PageManager.Web/src/types/index.ts index 898a0eb..df43d29 100644 --- a/PageManager.Web/src/types/index.ts +++ b/PageManager.Web/src/types/index.ts @@ -61,6 +61,8 @@ export interface Book { isbn: string | null hardcoverId: number | null editions: Edition[] + /** Distinct formats of BookFile records actually present in the library. */ + localFileFormats: FileFormat[] } export interface QueueItem { @@ -108,6 +110,49 @@ export interface ImportSource { enabled: boolean } +export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'failed' +export type DownloadSourceType = 'manual' | 'torrent' | 'localscan' + +export interface Download { + id: string + filename: string + sizeBytes: number + downloadedBytes: number + status: DownloadStatus + sourceType: DownloadSourceType + torrentHash: string | null + bookId: number | null + bookTitle: string | null + error: string | null + addedAt: string +} + +export type WantedStatus = 'wanted' | 'downloading' | 'found' +export type FileFormatPreference = 'epub' | 'mobi' | 'm4b' | 'mp3' | 'aac' | 'flac' + +export interface WantedBook { + id: number + bookId: number + bookTitle: string + bookAuthors: string[] + bookCoverUrl: string | null + addedAt: string + status: WantedStatus + formatPreference: FileFormatPreference | null + minSeeders: number +} + +export interface TorrentSearchResult { + title: string + magnet: string | null + downloadUrl: string | null + sizeBytes: number | null + seeders: number + leechers: number + indexer: string + publishDate: string | null +} + export type FileFormat = 'epub' | 'mobi' | 'pdf' | 'm4b' | 'mp3' | 'aac' | 'flac' export interface BookFile { diff --git a/compose.yaml b/compose.yaml index 4544429..14e21e1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,15 +1,4 @@ services: - postgres: - image: postgres:17-alpine - environment: - POSTGRES_DB: pagemanager - POSTGRES_USER: pm - POSTGRES_PASSWORD: pm - volumes: - - pgdata:/var/lib/postgresql/data - ports: - - "5432:5432" - pagemanager.api: image: pagemanager.api build: @@ -19,11 +8,14 @@ services: - "5278:8080" environment: - ASPNETCORE_ENVIRONMENT=Production - - ConnectionStrings__Postgres=Host=postgres;Database=pagemanager;Username=pm;Password=pm + - ConnectionStrings__Postgres=${POSTGRES_CONNECTION_STRING} + - Torrent__QBittorrentUrl=http://qbittorrent:8080 + - Torrent__SavePath=/data/books/incoming volumes: - books:/data/books + - audiobooks:/data/audiobooks depends_on: - - postgres + - qbittorrent pagemanager.web: image: pagemanager.web @@ -35,6 +27,35 @@ services: depends_on: - pagemanager.api + # Optional: self-hosted qBittorrent (comment out if using an external instance) + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + environment: + - PUID=1000 + - PGID=1000 + - TZ=Etc/UTC + - WEBUI_PORT=8080 + volumes: + - qbt-config:/config + - books:/data/books + ports: + - "6881:6881" + - "6881:6881/udp" + - "8090:8080" # WebUI on host port 8090 to avoid conflict with pagemanager.web + + # Optional: Prowlarr indexer aggregator (comment out if not needed) + # prowlarr: + # image: lscr.io/linuxserver/prowlarr:latest + # environment: + # - PUID=1000 + # - PGID=1000 + # - TZ=Etc/UTC + # volumes: + # - prowlarr-config:/config + # ports: + # - "9696:9696" + volumes: - pgdata: books: + audiobooks: + qbt-config: