Added support for bittorrent

This commit is contained in:
2026-03-28 17:36:25 +02:00
parent 5acde17a53
commit 4f7036ca27
45 changed files with 3383 additions and 225 deletions
@@ -0,0 +1,133 @@
using FluentAssertions;
using PageManager.Api.Services;
using PageManager.Api.Tests.Helpers;
namespace PageManager.Api.Tests.Unit.Services;
public class FileOrganizerServiceTests
{
// ── SanitizePathComponent ─────────────────────────────────────────────────
[Theory]
[InlineData("Andy Weir", "Andy Weir")]
[InlineData("Brandon Sanderson", "Brandon Sanderson")]
[InlineData("Hello: World", "Hello_ World")] // colon is invalid
[InlineData("Title/Sub", "Title_Sub")] // slash is invalid
[InlineData("Name.", "Name")] // trailing dot stripped
[InlineData(" Name ", "Name")] // whitespace trimmed
public void SanitizePathComponent_ReturnsExpected(string input, string expected)
{
FileOrganizerService.SanitizePathComponent(input).Should().Be(expected);
}
// ── ComputeCanonicalRelativePath — ebooks ─────────────────────────────────
[Fact]
public void Ebook_WithYear_ProducesAuthorTitleYearFolder()
{
var book = BookFactory.Create(title: "The Martian", year: 2011)
.WithAuthors((1, "Andy Weir"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().Be(Path.Combine("Andy Weir", "The Martian (2011)", "The Martian.epub"));
}
[Fact]
public void Ebook_WithoutYear_OmitsYearFromFolder()
{
var book = BookFactory.Create(title: "Dune", year: null)
.WithAuthors((1, "Frank Herbert"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().Be(Path.Combine("Frank Herbert", "Dune", "Dune.epub"));
}
[Fact]
public void Ebook_SeriesIgnored_SeriesNotInEbookPath()
{
var book = BookFactory.Create(title: "The Way of Kings", year: 2010)
.WithAuthors((1, "Brandon Sanderson"))
.WithSeries(seriesName: "The Stormlight Archive");
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
// Series folder should NOT appear for ebooks
result.Should().Be(Path.Combine("Brandon Sanderson", "The Way of Kings (2010)", "The Way of Kings.epub"));
}
// ── ComputeCanonicalRelativePath — audiobooks ─────────────────────────────
[Fact]
public void Audiobook_WithoutSeries_ProducesAuthorTitleTitle()
{
var book = BookFactory.Create(title: "Project Hail Mary", year: 2021)
.WithAuthors((1, "Andy Weir"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".m4b", isAudio: true);
result.Should().Be(Path.Combine("Andy Weir", "Project Hail Mary", "Project Hail Mary.m4b"));
}
[Fact]
public void Audiobook_WithSeries_InsertSeriesFolderBetweenAuthorAndTitle()
{
var book = BookFactory.Create(title: "The Way of Kings", year: 2010)
.WithAuthors((1, "Brandon Sanderson"))
.WithSeries(seriesName: "The Stormlight Archive");
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".m4b", isAudio: true);
result.Should().Be(Path.Combine("Brandon Sanderson", "The Stormlight Archive", "The Way of Kings", "The Way of Kings.m4b"));
}
[Fact]
public void Audiobook_Mp3Extension_PreservedInFilename()
{
var book = BookFactory.Create(title: "Dune", year: 1965)
.WithAuthors((1, "Frank Herbert"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".mp3", isAudio: true);
result.Should().Be(Path.Combine("Frank Herbert", "Dune", "Dune.mp3"));
}
// ── Multiple authors ──────────────────────────────────────────────────────
[Fact]
public void MultipleAuthors_UsesPrimaryAuthorForPath()
{
var book = BookFactory.Create(title: "Good Omens", year: 1990)
.WithAuthors((1, "Terry Pratchett"), (2, "Neil Gaiman"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().StartWith("Terry Pratchett");
result.Should().NotContain("Neil Gaiman");
}
[Fact]
public void NoAuthors_UsesUnknownFolder()
{
var book = BookFactory.Create(title: "Anonymous Work", year: 2000);
// No authors added
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().StartWith("Unknown");
}
// ── Sanitization edge cases ───────────────────────────────────────────────
[Fact]
public void TitleWithInvalidChars_Sanitized()
{
var book = BookFactory.Create(title: "Title: A Story", year: 2020)
.WithAuthors((1, "Author Name"));
var result = FileOrganizerService.ComputeCanonicalRelativePath(book, ".epub", isAudio: false);
result.Should().Be(Path.Combine("Author Name", "Title_ A Story (2020)", "Title_ A Story.epub"));
}
}
@@ -87,4 +87,39 @@ public class FileScannerServiceTests
var act = () => FileScannerService.FindMatch(file, books);
act.Should().NotThrow();
}
[Fact]
public void FindMatch_YearPrefixedFilename_StillMatchesTitle()
{
var books = new[] { BookFactory.Create(id: 1, title: "Project Hail Mary") };
var file = new BookFile { Filename = "2021 - Project Hail Mary.epub", Path = "2021 - Project Hail Mary.epub" };
FileScannerService.FindMatch(file, books)!.Id.Should().Be(1);
}
[Fact]
public void FindMatch_AudiobookInSubfolder_MatchesByParentFolderName()
{
var books = new[] { BookFactory.Create(id: 2, title: "Project Hail Mary") };
// m4b lives at "Andy Weir/2021 - Project Hail Mary/Project Hail Mary.m4b"
var file = new BookFile
{
Filename = "Project Hail Mary.m4b",
Path = "Andy Weir/2021 - Project Hail Mary/Project Hail Mary.m4b",
};
FileScannerService.FindMatch(file, books)!.Id.Should().Be(2);
}
// ── StripYearPrefix ───────────────────────────────────────────────────────
[Theory]
[InlineData("2021 - project hail mary", "project hail mary")]
[InlineData("2015 - artemis", "artemis")]
[InlineData("project hail mary", "project hail mary")] // no prefix → unchanged
[InlineData("diary of an asscan", "diary of an asscan")]
public void StripYearPrefix_ReturnsExpected(string input, string expected)
{
FileScannerService.StripYearPrefix(input).Should().Be(expected);
}
}
@@ -0,0 +1,136 @@
using FluentAssertions;
using PageManager.Api.Services;
using PageManager.Api.Tests.Helpers;
namespace PageManager.Api.Tests.Unit.Services;
public class MetadataWriterServiceTests
{
// ── ParseOpfPath ──────────────────────────────────────────────────────────
[Fact]
public void ParseOpfPath_ReturnsFullPath()
{
var xml = """
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
""";
MetadataWriterService.ParseOpfPath(xml).Should().Be("OEBPS/content.opf");
}
// ── ModifyOpfXml ──────────────────────────────────────────────────────────
private const string MinimalOpf = """
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Old Title</dc:title>
<dc:creator>Old Author</dc:creator>
<dc:language>en</dc:language>
</metadata>
<manifest/>
<spine/>
</package>
""";
[Fact]
public void ModifyOpfXml_UpdatesTitle()
{
var book = BookFactory.Create(title: "Dune", year: 1965).WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("<dc:title>Dune</dc:title>");
}
[Fact]
public void ModifyOpfXml_UpdatesCreator()
{
var book = BookFactory.Create(title: "Dune").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("Frank Herbert");
}
[Fact]
public void ModifyOpfXml_AddsDescription_WhenPresent()
{
var book = BookFactory.Create(title: "Dune", description: "A sci-fi epic.").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("A sci-fi epic.");
}
[Fact]
public void ModifyOpfXml_SkipsDescription_WhenNull()
{
var book = BookFactory.Create(title: "Dune", description: null).WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().NotContain("dc:description");
}
[Fact]
public void ModifyOpfXml_AddsIsbnIdentifier_WhenPresent()
{
var book = BookFactory.Create(title: "Dune", isbn: "9780441013593").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("9780441013593");
result.Should().Contain("ISBN");
}
[Fact]
public void ModifyOpfXml_AddsDate_WhenYearPresent()
{
var book = BookFactory.Create(title: "Dune", year: 1965).WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("1965");
}
[Fact]
public void ModifyOpfXml_PreservesExistingXmlStructure()
{
var book = BookFactory.Create(title: "Dune").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
// Manifest and spine should still be present
result.Should().Contain("manifest");
result.Should().Contain("spine");
result.Should().Contain("dc:language");
}
[Fact]
public void ModifyOpfXml_WithMultipleAuthors_UsesPrimaryAuthorForCreator()
{
var book = BookFactory.Create(title: "Good Omens")
.WithAuthors((1, "Terry Pratchett"), (2, "Neil Gaiman"));
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
result.Should().Contain("Terry Pratchett");
}
[Fact]
public void ModifyOpfXml_WithNoAuthors_DoesNotAddCreator()
{
var book = BookFactory.Create(title: "Anonymous");
// No authors added
var result = MetadataWriterService.ModifyOpfXml(MinimalOpf, book);
// Should not crash and should not add empty creator
result.Should().NotContain("<dc:creator />");
}
[Fact]
public void ModifyOpfXml_UpdatesExistingIsbnIdentifier_RatherThanAdding()
{
var opfWithIsbn = MinimalOpf.Replace(
"<dc:language>en</dc:language>",
"<dc:language>en</dc:language>\n <dc:identifier opf:scheme=\"ISBN\">OLD-ISBN</dc:identifier>");
var book = BookFactory.Create(title: "Dune", isbn: "9780441013593").WithAuthors((1, "Frank Herbert"));
var result = MetadataWriterService.ModifyOpfXml(opfWithIsbn, book);
result.Should().Contain("9780441013593");
result.Should().NotContain("OLD-ISBN");
// Should not have two ISBN identifiers
result.Split("ISBN").Length.Should().BeLessThanOrEqualTo(3); // "ISBN" appears in scheme + value
}
}