Skip to content
Merged
79 changes: 79 additions & 0 deletions .github/workflows/publish-unraid-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Publishes a Docker image to GHCR for testing on Unraid.
# This workflow is separate from the main CI pipeline.

name: Publish Unraid Image

permissions:
contents: read
packages: write

on:
workflow_dispatch:
push:
branches:
- develop

env:
DOTNET_VERSION: "6.0.428"
READARR_VERSION: 0.4.20
IMAGE_TAG: ghcr.io/edlaughton/bookshelf:import-match-threshold-ui

jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1

- uses: jdx/mise-action@v3

- name: Setup environment variables
run: |
echo "DATE=$(date --rfc-3339=date)" >> "$GITHUB_ENV"

- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: _cache/nuget
key: nuget-${{ runner.os }}-${{ hashFiles('src/Directory.Packages.props', 'src/**/*.csproj', 'global.json') }}
restore-keys: |
nuget-${{ runner.os }}-

- name: Cache Node modules
uses: actions/cache@v4
with:
path: |
_cache/node
node_modules
key: node-${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock') }}
restore-keys: |
node-${{ runner.os }}-

- name: Build backend and frontend
run: ./build.sh --backend --frontend
env:
READARRVERSION: ${{ env.READARR_VERSION }}.${{ github.run_number }}
BUILD_SOURCEBRANCHNAME: ${{ github.ref_name }}

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.IMAGE_TAG }}
build-args: |
GIT_BRANCH=${{ github.ref_name }}
COMMIT_HASH=${{ github.sha }}
BUILD_DATE=${{ env.DATE }}
METADATA_URL=https://hardcover.bookinfo.pro
HARDCOVER=true
21 changes: 21 additions & 0 deletions frontend/src/Settings/MediaManagement/MediaManagement.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,27 @@ class MediaManagement extends Component {
/>
</FormGroup>

<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>
{translate('MinimumAutoImportTitleMatch')}
</FormLabel>

<FormInputGroup
type={inputTypes.NUMBER}
unit='%'
name="bookImportMinimumMatchPercent"
helpText={translate('MinimumAutoImportTitleMatchHelpText')}
min={50}
max={99}
onChange={onInputChange}
{...settings.bookImportMinimumMatchPercent}
/>
</FormGroup>

{
settings.importExtraFiles.value ?
<FormGroup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ private void GivenAuthorMatch()
public void should_not_mark_as_imported_if_all_files_were_rejected()
{
Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(
Expand All @@ -139,7 +139,7 @@ public void should_not_mark_as_imported_if_all_files_were_rejected()
public void should_not_mark_as_imported_if_no_tracks_were_parsed()
{
Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(
Expand All @@ -162,7 +162,7 @@ public void should_not_mark_as_imported_if_no_tracks_were_parsed()
public void should_not_mark_as_failed_if_nothing_found_to_import()
{
Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>());

Subject.Import(_trackedDownload);
Expand All @@ -174,7 +174,7 @@ public void should_not_mark_as_failed_if_nothing_found_to_import()
public void should_not_mark_as_imported_if_all_files_were_skipped()
{
Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(new ImportDecision<LocalBook>(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() }), "Test Failure"),
Expand All @@ -197,7 +197,7 @@ public void should_mark_as_imported_if_all_tracks_were_imported_but_extra_files_
};

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(new ImportDecision<LocalBook>(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Author = _author })),
Expand All @@ -224,7 +224,7 @@ public void should_not_mark_as_imported_if_some_tracks_were_not_imported()
};

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(new ImportDecision<LocalBook>(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })),
Expand Down Expand Up @@ -258,7 +258,7 @@ public void should_not_mark_as_imported_if_some_of_episodes_were_not_imported_in
_trackedDownload.RemoteBook.Books = books;

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(new ImportDecision<LocalBook>(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv" })),
Expand Down Expand Up @@ -291,7 +291,7 @@ public void should_mark_as_imported_if_all_tracks_were_imported()
};

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(
Expand All @@ -316,7 +316,7 @@ public void should_mark_as_imported_if_all_episodes_were_imported_including_hist
_trackedDownload.RemoteBook.Books = books;

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(
Expand Down Expand Up @@ -353,7 +353,7 @@ public void should_mark_as_imported_if_the_download_can_be_tracked_using_the_sou
GivenABadlyNamedDownload();

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(new ImportDecision<LocalBook>(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Author = _author }))
Expand All @@ -364,6 +364,100 @@ public void should_mark_as_imported_if_the_download_can_be_tracked_using_the_sou
AssertImported();
}

[Test]
public void should_pass_book_override_when_remotebook_has_single_book()
{
var book = CreateBook(42);
_trackedDownload.RemoteBook.Books = new List<Book> { book };

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(new ImportDecision<LocalBook>(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Author = _author }))
});

Subject.Import(_trackedDownload);

Mocker.GetMock<IDownloadedBooksImportService>()
.Verify(v => v.ProcessPath(
It.IsAny<string>(),
ImportMode.Auto,
It.Is<IdentificationOverrides>(o => o.Author == _trackedDownload.RemoteBook.Author && o.Book == book),
It.IsAny<DownloadClientItem>()),
Times.Once());
}

[Test]
public void should_not_pass_book_override_when_remotebook_has_multiple_books()
{
_trackedDownload.RemoteBook.Books = new List<Book>
{
CreateBook(1),
CreateBook(2),
CreateBook(3)
};

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>
{
new ImportResult(new ImportDecision<LocalBook>(new LocalBook { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Author = _author })),
new ImportResult(new ImportDecision<LocalBook>(new LocalBook { Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic(), Author = _author })),
new ImportResult(new ImportDecision<LocalBook>(new LocalBook { Path = @"C:\TestPath\Droned.S01E03.mkv".AsOsAgnostic(), Author = _author }))
});

Subject.Import(_trackedDownload);

Mocker.GetMock<IDownloadedBooksImportService>()
.Verify(v => v.ProcessPath(
It.IsAny<string>(),
ImportMode.Auto,
It.Is<IdentificationOverrides>(o => o.Author == _trackedDownload.RemoteBook.Author && o.Book == null),
It.IsAny<DownloadClientItem>()),
Times.Once());
}

[Test]
public void should_pass_null_overrides_when_remotebook_is_null()
{
_trackedDownload.RemoteBook = null;

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>());

Subject.Import(_trackedDownload);

Mocker.GetMock<IDownloadedBooksImportService>()
.Verify(v => v.ProcessPath(
It.IsAny<string>(),
ImportMode.Auto,
It.Is<IdentificationOverrides>(o => o == null),
It.IsAny<DownloadClientItem>()),
Times.Once());
}

[Test]
public void should_pass_author_only_override_when_remotebook_has_empty_books()
{
_trackedDownload.RemoteBook.Books = new List<Book>();

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>());

Subject.Import(_trackedDownload);

Mocker.GetMock<IDownloadedBooksImportService>()
.Verify(v => v.ProcessPath(
It.IsAny<string>(),
ImportMode.Auto,
It.Is<IdentificationOverrides>(o => o.Author == _trackedDownload.RemoteBook.Author && o.Book == null),
It.IsAny<DownloadClientItem>()),
Times.Once());
}

private void AssertNotImported()
{
Mocker.GetMock<IEventAggregator>()
Expand All @@ -375,7 +469,7 @@ private void AssertNotImported()
private void AssertImported()
{
Mocker.GetMock<IDownloadedBooksImportService>()
.Verify(v => v.ProcessPath(_trackedDownload.DownloadItem.OutputPath.FullPath, ImportMode.Auto, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem), Times.Once());
.Verify(v => v.ProcessPath(_trackedDownload.DownloadItem.OutputPath.FullPath, ImportMode.Auto, It.IsAny<IdentificationOverrides>(), _trackedDownload.DownloadItem), Times.Once());

Mocker.GetMock<IEventAggregator>()
.Verify(v => v.PublishEvent(It.IsAny<DownloadCompletedEvent>()), Times.Once());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public void Setup()
.Returns(new List<ImportResult>());

Mocker.GetMock<IDownloadedBooksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Author>(), It.IsAny<DownloadClientItem>()))
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<IdentificationOverrides>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult>());

var downloadItem = Builder<DownloadClientItem>.CreateNew()
Expand Down Expand Up @@ -106,7 +106,7 @@ public void should_process_folder_with_downloadclientitem_if_available()

Subject.Execute(new DownloadedBooksScanCommand() { Path = _downloadFolder, DownloadClientId = "sab1" });

Mocker.GetMock<IDownloadedBooksImportService>().Verify(c => c.ProcessPath(_downloadFolder, ImportMode.Auto, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem), Times.Once());
Mocker.GetMock<IDownloadedBooksImportService>().Verify(c => c.ProcessPath(_downloadFolder, ImportMode.Auto, It.Is<IdentificationOverrides>(o => o != null && o.Author == _trackedDownload.RemoteBook.Author), _trackedDownload.DownloadItem), Times.Once());
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ public void should_not_delete_folder_after_import()

_trackedDownload.DownloadItem.CanMoveFiles = false;

Subject.ProcessPath(_droneFactory, ImportMode.Auto, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem);
Subject.ProcessPath(_droneFactory, ImportMode.Auto, new IdentificationOverrides { Author = _trackedDownload.RemoteBook.Author }, _trackedDownload.DownloadItem);

DiskProvider.FolderExists(_subFolders[0]).Should().BeTrue();
}
Expand All @@ -316,7 +316,7 @@ public void should_delete_folder_if_importmode_move()

_trackedDownload.DownloadItem.CanMoveFiles = false;

Subject.ProcessPath(_droneFactory, ImportMode.Move, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem);
Subject.ProcessPath(_droneFactory, ImportMode.Move, new IdentificationOverrides { Author = _trackedDownload.RemoteBook.Author }, _trackedDownload.DownloadItem);

DiskProvider.FolderExists(_subFolders[0]).Should().BeFalse();
}
Expand All @@ -330,7 +330,7 @@ public void should_not_delete_folder_if_importmode_copy()

_trackedDownload.DownloadItem.CanMoveFiles = true;

Subject.ProcessPath(_droneFactory, ImportMode.Copy, _trackedDownload.RemoteBook.Author, _trackedDownload.DownloadItem);
Subject.ProcessPath(_droneFactory, ImportMode.Copy, new IdentificationOverrides { Author = _trackedDownload.RemoteBook.Author }, _trackedDownload.DownloadItem);

DiskProvider.FolderExists(_subFolders[0]).Should().BeTrue();
}
Expand Down
Loading
Loading