{visibleFields.map((field) => {
- const disabledState = getDisabledState(field, values);
- return (
-
- {renderField(
- field,
- values[field.key],
- (v) => onChange(field.key, v),
- () => onAction(field.key),
- disabledState.disabled
- )}
-
- );
- })}
+ const disabledState = getDisabledState(field, values);
+ return (
+
+ {renderField(
+ field,
+ values[field.key],
+ (v) => onChange(field.key, v),
+ () => onAction(field.key),
+ disabledState.disabled,
+ values
+ )}
+
+ );
+ })}
diff --git a/src/frontend/src/components/settings/fields/SelectField.tsx b/src/frontend/src/components/settings/fields/SelectField.tsx
index 4f4b05a..e39db8a 100644
--- a/src/frontend/src/components/settings/fields/SelectField.tsx
+++ b/src/frontend/src/components/settings/fields/SelectField.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useMemo, useRef } from 'react';
import { SelectFieldConfig } from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
@@ -6,17 +7,53 @@ interface SelectFieldProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
+ filterValue?: string;
}
-export const SelectField = ({ field, value, onChange, disabled }: SelectFieldProps) => {
- // disabled prop is already computed by SettingsContent.getDisabledState()
+export const SelectField = ({ field, value, onChange, disabled, filterValue }: SelectFieldProps) => {
const isDisabled = disabled ?? false;
+ const prevFilterValue = useRef(filterValue);
+
+ const normalizedOptions = useMemo(
+ () =>
+ field.options.map((opt) => ({
+ ...opt,
+ value: String(opt.value),
+ childOf:
+ opt.childOf === undefined || opt.childOf === null
+ ? undefined
+ : String(opt.childOf),
+ label: opt.label ?? String(opt.value),
+ })),
+ [field.options]
+ );
+
+ // Filter options based on filterValue (cascading dropdown support)
+ const filteredOptions = useMemo(() => {
+ if (!filterValue) {
+ return normalizedOptions.filter((opt) => !opt.childOf);
+ }
+ // Filter to options that belong to the selected parent or have no parent
+ return normalizedOptions.filter((opt) => !opt.childOf || opt.childOf === filterValue);
+ }, [normalizedOptions, filterValue]);
+
+ // Clear selection when filter value changes and current value is not in filtered options
+ useEffect(() => {
+ if (prevFilterValue.current !== filterValue && filterValue !== undefined) {
+ const currentValueInOptions = filteredOptions.some((opt) => opt.value === value);
+ if (!currentValueInOptions && value) {
+ onChange('');
+ }
+ }
+ prevFilterValue.current = filterValue;
+ }, [filterValue, filteredOptions, value, onChange]);
+
// Use field's default value as fallback when value is empty
const effectiveValue = value || field.default || '';
// Convert options to DropdownList format
- const dropdownOptions = field.options.map((opt) => ({
+ const dropdownOptions = filteredOptions.map((opt) => ({
value: opt.value,
label: opt.label,
description: opt.description,
@@ -30,7 +67,7 @@ export const SelectField = ({ field, value, onChange, disabled }: SelectFieldPro
if (isDisabled) {
// When disabled, show a static display instead of the dropdown
- const selectedOption = field.options.find((opt) => opt.value === effectiveValue);
+ const selectedOption = filteredOptions.find((opt) => opt.value === effectiveValue);
return (
{selectedOption?.label || 'Select...'}
diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts
index f842897..e640157 100644
--- a/src/frontend/src/services/api.ts
+++ b/src/frontend/src/services/api.ts
@@ -39,6 +39,7 @@ export class TimeoutError extends Error {
// Default request timeout in milliseconds (30 seconds)
const DEFAULT_TIMEOUT_MS = 30000;
+const EXPANDED_RELEASES_TIMEOUT_MS = 60000;
// Utility function for JSON fetch with credentials and timeout
async function fetchJSON(url: string, opts: RequestInit = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise {
@@ -347,5 +348,6 @@ export const getReleases = async (
if (contentType) {
params.set('content_type', contentType);
}
- return fetchJSON(`${API_BASE}/releases?${params.toString()}`);
+ const timeoutMs = expandSearch ? EXPANDED_RELEASES_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
+ return fetchJSON(`${API_BASE}/releases?${params.toString()}`, {}, timeoutMs);
};
diff --git a/src/frontend/src/types/settings.ts b/src/frontend/src/types/settings.ts
index c189e41..c24e5c9 100644
--- a/src/frontend/src/types/settings.ts
+++ b/src/frontend/src/types/settings.ts
@@ -25,6 +25,8 @@ export interface ShowWhenCondition {
notEmpty?: boolean; // If true, show when field has any non-empty value
}
+export type ShowWhen = ShowWhenCondition | ShowWhenCondition[];
+
// Conditional disable configuration
export interface DisabledWhenCondition {
field: string; // The field key to check
@@ -42,7 +44,7 @@ export interface BaseField {
fromEnv?: boolean; // True if value is set via environment variable
disabled?: boolean; // True if field is disabled/greyed out
disabledReason?: string; // Explanation shown when field is disabled
- showWhen?: ShowWhenCondition; // Conditional visibility based on another field's value
+ showWhen?: ShowWhen; // Conditional visibility based on another field's value
disabledWhen?: DisabledWhenCondition; // Conditional disable based on another field's value
requiresRestart?: boolean; // True if changing this setting requires a container restart
universalOnly?: boolean; // Only show in Universal search mode (hide in Direct mode)
@@ -80,6 +82,7 @@ export interface SelectFieldConfig extends BaseField {
value: string;
options: SelectOption[];
default?: string;
+ filterByField?: string; // Field key whose value filters options via childOf property
}
export interface MultiSelectFieldConfig extends BaseField {
@@ -122,7 +125,7 @@ export interface HeadingFieldConfig {
description?: string;
linkUrl?: string;
linkText?: string;
- showWhen?: ShowWhenCondition; // Conditional visibility based on another field's value
+ showWhen?: ShowWhen; // Conditional visibility based on another field's value
universalOnly?: boolean; // Only show in Universal search mode (hide in Direct mode)
}
diff --git a/tests/config/test_environment.py b/tests/config/test_environment.py
index a12fed8..dcd6a3e 100644
--- a/tests/config/test_environment.py
+++ b/tests/config/test_environment.py
@@ -27,13 +27,13 @@ class TestDirectorySetup:
def test_staging_dir_created_on_demand(self):
"""Staging directory should be created if it doesn't exist."""
- from shelfmark.download.orchestrator import get_staging_dir
+ from shelfmark.download.staging import get_staging_dir
with tempfile.TemporaryDirectory() as tmpdir:
test_staging = Path(tmpdir) / "staging"
assert not test_staging.exists()
- with patch("shelfmark.download.orchestrator.TMP_DIR", test_staging):
+ with patch("shelfmark.config.env.TMP_DIR", test_staging):
result = get_staging_dir()
assert test_staging.exists()
@@ -41,24 +41,24 @@ class TestDirectorySetup:
def test_staging_dir_handles_existing_directory(self):
"""Staging directory creation should be idempotent."""
- from shelfmark.download.orchestrator import get_staging_dir
+ from shelfmark.download.staging import get_staging_dir
with tempfile.TemporaryDirectory() as tmpdir:
test_staging = Path(tmpdir) / "staging"
test_staging.mkdir()
- with patch("shelfmark.download.orchestrator.TMP_DIR", test_staging):
+ with patch("shelfmark.config.env.TMP_DIR", test_staging):
result = get_staging_dir()
assert result == test_staging
def test_staging_path_handles_special_characters(self):
"""Staging path should handle task IDs with special characters."""
- from shelfmark.download.orchestrator import get_staging_path
+ from shelfmark.download.staging import get_staging_path
with tempfile.TemporaryDirectory() as tmpdir:
with patch(
- "shelfmark.download.orchestrator.TMP_DIR", Path(tmpdir)
+ "shelfmark.config.env.TMP_DIR", Path(tmpdir)
):
# Task ID with URL-like characters
path = get_staging_path(
@@ -74,11 +74,11 @@ class TestDirectorySetup:
def test_staging_path_normalizes_extension(self):
"""Staging path should handle extensions with or without dot."""
- from shelfmark.download.orchestrator import get_staging_path
+ from shelfmark.download.staging import get_staging_path
with tempfile.TemporaryDirectory() as tmpdir:
with patch(
- "shelfmark.download.orchestrator.TMP_DIR", Path(tmpdir)
+ "shelfmark.config.env.TMP_DIR", Path(tmpdir)
):
path1 = get_staging_path("task1", "epub")
path2 = get_staging_path("task1", ".epub")
@@ -309,14 +309,14 @@ class TestConfigValidation:
def test_missing_required_directory_handling(self):
"""Application should handle missing directories gracefully."""
- from shelfmark.download.orchestrator import get_staging_dir
+ from shelfmark.download.staging import get_staging_dir
with tempfile.TemporaryDirectory() as tmpdir:
# Use a path that doesn't exist yet
nonexistent = Path(tmpdir) / "deeply" / "nested" / "path"
with patch(
- "shelfmark.download.orchestrator.TMP_DIR", nonexistent
+ "shelfmark.config.env.TMP_DIR", nonexistent
):
result = get_staging_dir()
@@ -346,6 +346,68 @@ class TestConfigValidation:
os.chmod(readonly_dir, 0o755) # Restore for cleanup
+# =============================================================================
+# Settings Validation Tests
+# =============================================================================
+
+
+class TestSettingsValidation:
+ """Tests for settings save-time validation."""
+
+ def test_downloads_books_rename_template_rejects_path_separators(self):
+ import shelfmark.config.settings # noqa: F401
+ from shelfmark.core.settings_registry import update_settings
+
+ result = update_settings(
+ "downloads",
+ {
+ "FILE_ORGANIZATION": "rename",
+ "TEMPLATE_RENAME": "{Author}/{Title}",
+ },
+ )
+
+ assert result["success"] is False
+ assert "Naming Template" in result["message"]
+ assert "Organize" in result["message"]
+
+ def test_downloads_audiobooks_rename_template_rejects_path_separators(self):
+ import shelfmark.config.settings # noqa: F401
+ from shelfmark.core.settings_registry import update_settings
+
+ result = update_settings(
+ "downloads",
+ {
+ "FILE_ORGANIZATION_AUDIOBOOK": "rename",
+ "TEMPLATE_AUDIOBOOK_RENAME": "{Author}/{Title}",
+ },
+ )
+
+ assert result["success"] is False
+ assert "Naming Template" in result["message"]
+ assert "Organize" in result["message"]
+
+ def test_downloads_books_rename_validation_uses_existing_values(self):
+ import shelfmark.config.settings # noqa: F401
+ from shelfmark.core.settings_registry import update_settings
+
+ with patch(
+ "shelfmark.config.settings.load_config_file",
+ return_value={
+ "BOOKS_OUTPUT_MODE": "folder",
+ "TEMPLATE_RENAME": "{Author}/{Title}",
+ },
+ ):
+ result = update_settings(
+ "downloads",
+ {
+ "FILE_ORGANIZATION": "rename",
+ },
+ )
+
+ assert result["success"] is False
+ assert "Naming Template" in result["message"]
+
+
# =============================================================================
# Debug and Logging Configuration Tests
# =============================================================================
@@ -462,7 +524,7 @@ class TestFileCollisionHandling:
def test_stage_file_handles_collision(self):
"""stage_file should add suffix on collision."""
- from shelfmark.download.orchestrator import stage_file
+ from shelfmark.download.staging import stage_file
with tempfile.TemporaryDirectory() as tmpdir:
staging = Path(tmpdir) / "staging"
@@ -476,7 +538,7 @@ class TestFileCollisionHandling:
(staging / "book.epub").write_text("existing")
with patch(
- "shelfmark.download.orchestrator.TMP_DIR", staging
+ "shelfmark.config.env.TMP_DIR", staging
):
result = stage_file(source, "task1", copy=True)
@@ -486,7 +548,7 @@ class TestFileCollisionHandling:
def test_stage_file_copy_vs_move(self):
"""stage_file should copy or move based on parameter."""
- from shelfmark.download.orchestrator import stage_file
+ from shelfmark.download.staging import stage_file
with tempfile.TemporaryDirectory() as tmpdir:
staging = Path(tmpdir) / "staging"
@@ -497,7 +559,7 @@ class TestFileCollisionHandling:
source1.write_text("content1")
with patch(
- "shelfmark.download.orchestrator.TMP_DIR", staging
+ "shelfmark.config.env.TMP_DIR", staging
):
result1 = stage_file(source1, "task1", copy=True)
@@ -509,7 +571,7 @@ class TestFileCollisionHandling:
source2.write_text("content2")
with patch(
- "shelfmark.download.orchestrator.TMP_DIR", staging
+ "shelfmark.config.env.TMP_DIR", staging
):
result2 = stage_file(source2, "task2", copy=False)
diff --git a/tests/core/test_download_processing.py b/tests/core/test_download_processing.py
index 9903f25..fd22ccb 100644
--- a/tests/core/test_download_processing.py
+++ b/tests/core/test_download_processing.py
@@ -62,6 +62,23 @@ def temp_dirs(tmp_path):
}
+def _mock_destination_config(ingest_dir: Path, extra=None):
+ values = {
+ "DESTINATION": str(ingest_dir),
+ "INGEST_DIR": str(ingest_dir),
+ }
+ if extra:
+ values.update(extra)
+ return MagicMock(side_effect=lambda key, default=None: values.get(key, default))
+
+
+def _sync_core_config(mock_config, mock_core_config, mock_archive_config=None):
+ mock_core_config.get = mock_config.get
+ mock_core_config.CUSTOM_SCRIPT = getattr(mock_config, "CUSTOM_SCRIPT", None)
+ if mock_archive_config is not None:
+ mock_archive_config.get = mock_config.get
+
+
# =============================================================================
# _atomic_copy Tests
# =============================================================================
@@ -71,7 +88,7 @@ class TestAtomicCopy:
def test_copies_file(self, tmp_path):
"""Copies file to destination."""
- from shelfmark.download.orchestrator import _atomic_copy
+ from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -87,7 +104,7 @@ class TestAtomicCopy:
def test_preserves_source(self, tmp_path):
"""Source file is preserved after copy."""
- from shelfmark.download.orchestrator import _atomic_copy
+ from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("original content")
@@ -100,7 +117,7 @@ class TestAtomicCopy:
def test_handles_collision_with_counter(self, tmp_path):
"""Appends counter suffix when destination exists."""
- from shelfmark.download.orchestrator import _atomic_copy
+ from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("new content")
@@ -116,7 +133,7 @@ class TestAtomicCopy:
def test_multiple_collisions(self, tmp_path):
"""Increments counter until finding free slot."""
- from shelfmark.download.orchestrator import _atomic_copy
+ from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("new")
@@ -130,7 +147,7 @@ class TestAtomicCopy:
def test_preserves_extension(self, tmp_path):
"""Keeps extension when adding counter suffix."""
- from shelfmark.download.orchestrator import _atomic_copy
+ from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "book.epub"
source.write_bytes(b"epub content")
@@ -143,7 +160,7 @@ class TestAtomicCopy:
def test_creates_distinct_file(self, tmp_path):
"""Copy creates a distinct file (not hardlink)."""
- from shelfmark.download.orchestrator import _atomic_copy
+ from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -156,7 +173,7 @@ class TestAtomicCopy:
def test_copy_preserves_permissions(self, tmp_path):
"""Copy preserves file permissions (copy2 behavior)."""
- from shelfmark.download.orchestrator import _atomic_copy
+ from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -170,7 +187,7 @@ class TestAtomicCopy:
def test_atomic_no_partial_file(self, tmp_path):
"""If copy fails, no partial file remains."""
- from shelfmark.download.orchestrator import _atomic_copy
+ from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -186,7 +203,7 @@ class TestAtomicCopy:
def test_max_attempts_exceeded(self, tmp_path):
"""Raises after max collision attempts."""
- from shelfmark.download.orchestrator import _atomic_copy
+ from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -211,15 +228,20 @@ class TestProcessDirectory:
def test_finds_book_files(self, temp_dirs, sample_task):
"""Finds and moves book files to ingest."""
- from shelfmark.download.orchestrator import process_directory
+ from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book.epub").write_bytes(b"epub content")
- with patch('shelfmark.download.orchestrator.config') as mock_config:
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
- mock_config.get = MagicMock(return_value=["epub"])
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: {
+ "SUPPORTED_FORMATS": ["epub"],
+ "FILE_ORGANIZATION": "none",
+ }.get(key, default))
+ _sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -236,16 +258,21 @@ class TestProcessDirectory:
def test_multiple_book_files(self, temp_dirs, sample_task):
"""Handles multiple book files in directory."""
- from shelfmark.download.orchestrator import process_directory
+ from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book1.epub").write_bytes(b"epub1")
(directory / "book2.epub").write_bytes(b"epub2")
- with patch('shelfmark.download.orchestrator.config') as mock_config:
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
- mock_config.get = MagicMock(return_value=["epub"])
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: {
+ "SUPPORTED_FORMATS": ["epub"],
+ "FILE_ORGANIZATION": "none",
+ }.get(key, default))
+ _sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -258,15 +285,20 @@ class TestProcessDirectory:
def test_no_book_files_returns_error(self, temp_dirs, sample_task):
"""Returns error when no book files found."""
- from shelfmark.download.orchestrator import process_directory
+ from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
# Use a file type that isn't trackable (not epub, pdf, txt, etc.)
(directory / "readme.log").write_text("not a book")
- with patch('shelfmark.download.orchestrator.config') as mock_config:
- mock_config.get = MagicMock(return_value=["epub"])
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: {
+ "SUPPORTED_FORMATS": ["epub"],
+ "FILE_ORGANIZATION": "none",
+ }.get(key, default))
+ _sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -280,14 +312,19 @@ class TestProcessDirectory:
def test_unsupported_format_error_message(self, temp_dirs, sample_task):
"""Returns helpful error when files exist but format unsupported."""
- from shelfmark.download.orchestrator import process_directory
+ from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book.pdf").write_bytes(b"pdf content")
- with patch('shelfmark.download.orchestrator.config') as mock_config:
- mock_config.get = MagicMock(return_value=["epub"]) # PDF not supported
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: {
+ "SUPPORTED_FORMATS": ["epub"], # PDF not supported
+ "FILE_ORGANIZATION": "none",
+ }.get(key, default))
+ _sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -299,75 +336,22 @@ class TestProcessDirectory:
assert "format not supported" in error
assert ".pdf" in error
- def test_extracts_archive_when_no_books(self, temp_dirs, sample_task):
- """Extracts archives when no direct book files found."""
- from shelfmark.download.orchestrator import process_directory
-
- directory = temp_dirs["staging"] / "download"
- directory.mkdir()
-
- # Create a mock archive file
- archive = directory / "books.zip"
- archive.write_bytes(b"PK...") # ZIP signature
-
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.is_archive', return_value=True), \
- patch('shelfmark.download.orchestrator.process_archive') as mock_extract:
-
- mock_config.get = MagicMock(return_value=["epub"])
-
- # Mock successful extraction
- mock_result = MagicMock()
- mock_result.success = True
- mock_result.final_paths = [temp_dirs["ingest"] / "extracted.epub"]
- mock_result.error = None
- mock_extract.return_value = mock_result
-
- final_paths, error = process_directory(
- directory=directory,
- ingest_dir=temp_dirs["ingest"],
- task=sample_task,
- )
-
- assert error is None
- mock_extract.assert_called_once()
-
- def test_prefers_book_files_over_archives(self, temp_dirs, sample_task):
- """Uses book files directly when present, ignores archives."""
- from shelfmark.download.orchestrator import process_directory
-
- directory = temp_dirs["staging"] / "download"
- directory.mkdir()
- (directory / "book.epub").write_bytes(b"epub content")
- (directory / "extra.zip").write_bytes(b"PK...") # Archive ignored
-
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.process_archive') as mock_extract:
-
- mock_config.USE_BOOK_TITLE = False
- mock_config.get = MagicMock(return_value=["epub"])
-
- final_paths, error = process_directory(
- directory=directory,
- ingest_dir=temp_dirs["ingest"],
- task=sample_task,
- )
-
- assert error is None
- assert len(final_paths) == 1
- mock_extract.assert_not_called()
-
def test_uses_book_title_for_single_file(self, temp_dirs, sample_task):
"""Uses formatted title for single file when USE_BOOK_TITLE enabled."""
- from shelfmark.download.orchestrator import process_directory
+ from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "random_name.epub").write_bytes(b"content")
- with patch('shelfmark.download.orchestrator.config') as mock_config:
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True
- mock_config.get = MagicMock(return_value=["epub"])
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: {
+ "SUPPORTED_FORMATS": ["epub"],
+ "FILE_ORGANIZATION": "rename",
+ }.get(key, default))
+ _sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -382,16 +366,21 @@ class TestProcessDirectory:
def test_preserves_filenames_for_multifile(self, temp_dirs, sample_task):
"""Preserves original filenames for multi-file downloads."""
- from shelfmark.download.orchestrator import process_directory
+ from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "Part 1.epub").write_bytes(b"part1")
(directory / "Part 2.epub").write_bytes(b"part2")
- with patch('shelfmark.download.orchestrator.config') as mock_config:
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True # Ignored for multi-file
- mock_config.get = MagicMock(return_value=["epub"])
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: {
+ "SUPPORTED_FORMATS": ["epub"],
+ "FILE_ORGANIZATION": "none",
+ }.get(key, default))
+ _sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -406,16 +395,21 @@ class TestProcessDirectory:
def test_nested_directory_files(self, temp_dirs, sample_task):
"""Finds book files in nested subdirectories."""
- from shelfmark.download.orchestrator import process_directory
+ from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
subdir = directory / "subdir"
subdir.mkdir(parents=True)
(subdir / "book.epub").write_bytes(b"content")
- with patch('shelfmark.download.orchestrator.config') as mock_config:
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
- mock_config.get = MagicMock(return_value=["epub"])
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: {
+ "SUPPORTED_FORMATS": ["epub"],
+ "FILE_ORGANIZATION": "none",
+ }.get(key, default))
+ _sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -428,17 +422,22 @@ class TestProcessDirectory:
def test_cleans_up_on_error(self, temp_dirs, sample_task):
"""Cleans up directory even on error."""
- from shelfmark.download.orchestrator import process_directory
+ from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book.epub").write_bytes(b"content")
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator._atomic_move', side_effect=Exception("Move failed")):
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
+ patch('shelfmark.download.postprocess.transfer.atomic_move', side_effect=Exception("Move failed")):
mock_config.USE_BOOK_TITLE = False
- mock_config.get = MagicMock(return_value=["epub"])
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: {
+ "SUPPORTED_FORMATS": ["epub"],
+ "FILE_ORGANIZATION": "none",
+ }.get(key, default))
+ _sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -461,7 +460,7 @@ class TestPostProcessDownload:
def test_simple_file_move_to_ingest(self, temp_dirs, sample_direct_task):
"""Simple file is moved to ingest directory."""
- from shelfmark.download.orchestrator import _post_process_download
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"epub content")
@@ -469,12 +468,14 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
- mock_config.get = MagicMock(return_value=None)
+ _sync_core_config(mock_config, mock_config)
+ mock_config.get = _mock_destination_config(temp_dirs["ingest"])
+ _sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -492,7 +493,7 @@ class TestPostProcessDownload:
def test_uses_formatted_filename(self, temp_dirs, sample_direct_task):
"""Uses task title when USE_BOOK_TITLE enabled."""
- from shelfmark.download.orchestrator import _post_process_download
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "random.epub"
temp_file.write_bytes(b"content")
@@ -500,12 +501,14 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True
mock_config.CUSTOM_SCRIPT = None
- mock_config.get = MagicMock(return_value=None)
+ _sync_core_config(mock_config, mock_config)
+ mock_config.get = _mock_destination_config(temp_dirs["ingest"])
+ _sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -517,9 +520,9 @@ class TestPostProcessDownload:
result_path = Path(result)
assert "The Way of Kings" in result_path.name
- def test_library_mode_for_universal(self, temp_dirs, sample_task):
- """Universal mode tries library mode when configured."""
- from shelfmark.download.orchestrator import _post_process_download
+ def test_organize_mode_for_universal(self, temp_dirs, sample_task):
+ """Universal mode organizes when configured."""
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
library = temp_dirs["base"] / "library"
library.mkdir()
@@ -529,16 +532,18 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True
mock_config.CUSTOM_SCRIPT = None
+ _sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "PROCESSING_MODE": "library",
- "LIBRARY_PATH": str(library),
- "LIBRARY_TEMPLATE": "{Author}/{Title}",
+ "DESTINATION": str(library),
+ "FILE_ORGANIZATION": "organize",
+ "TEMPLATE_ORGANIZE": "{Author}/{Title}",
}.get(key, default))
+ _sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -552,9 +557,9 @@ class TestPostProcessDownload:
assert library in result_path.parents or result_path.parent == library
status_cb.assert_called_with("complete", "Complete")
- def test_direct_mode_skips_library(self, temp_dirs, sample_direct_task):
- """Direct mode skips library mode even when configured."""
- from shelfmark.download.orchestrator import _post_process_download
+ def test_direct_mode_uses_ingest(self, temp_dirs, sample_direct_task):
+ """Direct mode keeps ingest destination when not organizing."""
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
library = temp_dirs["base"] / "library"
library.mkdir()
@@ -564,15 +569,17 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
+ _sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "PROCESSING_MODE": "library", # Configured but ignored
- "LIBRARY_PATH": str(library),
+ "DESTINATION": str(temp_dirs["ingest"]),
+ "FILE_ORGANIZATION": "none",
}.get(key, default))
+ _sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -587,7 +594,7 @@ class TestPostProcessDownload:
def test_cancellation_before_ingest(self, temp_dirs, sample_direct_task):
"""Respects cancellation before final move."""
- from shelfmark.download.orchestrator import _post_process_download
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"content")
@@ -596,12 +603,14 @@ class TestPostProcessDownload:
cancel_flag = Event()
cancel_flag.set() # Already cancelled
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
- mock_config.get = MagicMock(return_value=None)
+ _sync_core_config(mock_config, mock_config)
+ mock_config.get = _mock_destination_config(temp_dirs["ingest"])
+ _sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -614,109 +623,12 @@ class TestPostProcessDownload:
# File should be cleaned up
assert not temp_file.exists()
- def test_archive_extraction(self, temp_dirs, sample_direct_task):
- """Archives are extracted."""
- from shelfmark.download.orchestrator import _post_process_download
-
- archive = temp_dirs["staging"] / "book.zip"
- archive.write_bytes(b"PK...")
-
- status_cb = MagicMock()
- cancel_flag = Event()
-
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
- patch('shelfmark.download.orchestrator.is_archive', return_value=True), \
- patch('shelfmark.download.orchestrator.process_archive') as mock_extract:
-
- mock_config.CUSTOM_SCRIPT = None
- mock_config.get = MagicMock(return_value=None)
-
- mock_result = MagicMock()
- mock_result.success = True
- mock_result.final_paths = [temp_dirs["ingest"] / "book.epub"]
- mock_result.message = "Extracted 1 file"
- mock_extract.return_value = mock_result
-
- result = _post_process_download(
- temp_file=archive,
- task=sample_direct_task,
- cancel_flag=cancel_flag,
- status_callback=status_cb,
- )
-
- assert result is not None
- mock_extract.assert_called_once()
- status_cb.assert_called_with("complete", "Extracted 1 file")
-
- def test_directory_processing(self, temp_dirs, sample_direct_task):
- """Directories are processed via process_directory."""
- from shelfmark.download.orchestrator import _post_process_download
-
- directory = temp_dirs["staging"] / "download"
- directory.mkdir()
- (directory / "book.epub").write_bytes(b"content")
-
- status_cb = MagicMock()
- cancel_flag = Event()
-
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
- patch('shelfmark.download.orchestrator.is_archive', return_value=False):
-
- mock_config.USE_BOOK_TITLE = False
- mock_config.CUSTOM_SCRIPT = None
- mock_config.get = MagicMock(return_value=["epub"])
-
- result = _post_process_download(
- temp_file=directory,
- task=sample_direct_task,
- cancel_flag=cancel_flag,
- status_callback=status_cb,
- )
-
- assert result is not None
- status_cb.assert_called_with("complete", "Complete")
-
- def test_torrent_staging_for_ingest_mode(self, temp_dirs, sample_task):
- """Torrent files are copied to staging before ingest."""
- from shelfmark.download.orchestrator import _post_process_download
-
- # Simulate torrent client download location
- torrent_path = temp_dirs["base"] / "downloads" / "book.epub"
- torrent_path.parent.mkdir()
- torrent_path.write_bytes(b"content")
-
- sample_task.original_download_path = str(torrent_path)
-
- status_cb = MagicMock()
- cancel_flag = Event()
-
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
- patch('shelfmark.download.orchestrator.get_staging_dir', return_value=temp_dirs["staging"]), \
- patch('shelfmark.download.orchestrator.is_archive', return_value=False):
-
- mock_config.USE_BOOK_TITLE = False
- mock_config.CUSTOM_SCRIPT = None
- mock_config.get = MagicMock(return_value=None)
-
- result = _post_process_download(
- temp_file=torrent_path,
- task=sample_task,
- cancel_flag=cancel_flag,
- status_callback=status_cb,
- )
-
- assert result is not None
- # Original torrent file should still exist
- assert torrent_path.exists()
- # Result should be in ingest
- assert Path(result).parent == temp_dirs["ingest"]
+ # NOTE: archive extraction and torrent hardlink/copy behaviour are exercised via
+ # black-box matrix scenarios in `tests/core/test_processing_integration.py`.
def test_audiobook_uses_dedicated_ingest(self, temp_dirs, sample_task):
"""Audiobooks use dedicated ingest directory when configured."""
- from shelfmark.download.orchestrator import _post_process_download
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
audiobook_ingest = temp_dirs["base"] / "audiobook_ingest"
audiobook_ingest.mkdir()
@@ -728,15 +640,18 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
- patch('shelfmark.download.orchestrator.is_archive', return_value=False):
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
+ _sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "INGEST_DIR_AUDIOBOOK": str(audiobook_ingest),
+ "DESTINATION": str(temp_dirs["ingest"]),
+ "INGEST_DIR": str(temp_dirs["ingest"]),
+ "DESTINATION_AUDIOBOOK": str(audiobook_ingest),
}.get(key, default))
+ _sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -758,7 +673,7 @@ class TestCustomScriptExecution:
def test_runs_custom_script(self, temp_dirs, sample_direct_task):
"""Runs custom script when configured."""
- from shelfmark.download.orchestrator import _post_process_download
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
import subprocess
temp_file = temp_dirs["staging"] / "book.epub"
@@ -767,14 +682,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
- patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run') as mock_run:
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
- mock_config.get = MagicMock(return_value=None)
+ _sync_core_config(mock_config, mock_config)
+ mock_config.get = _mock_destination_config(temp_dirs["ingest"])
+ _sync_core_config(mock_config, mock_config)
mock_run.return_value = MagicMock(stdout="", returncode=0)
@@ -792,7 +708,7 @@ class TestCustomScriptExecution:
def test_script_not_found_error(self, temp_dirs, sample_direct_task):
"""Returns error when script not found."""
- from shelfmark.download.orchestrator import _post_process_download
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"content")
@@ -800,14 +716,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
- patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run', side_effect=FileNotFoundError("not found")):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/nonexistent/script.sh"
- mock_config.get = MagicMock(return_value=None)
+ _sync_core_config(mock_config, mock_config)
+ mock_config.get = _mock_destination_config(temp_dirs["ingest"])
+ _sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -821,7 +738,7 @@ class TestCustomScriptExecution:
def test_script_not_executable_error(self, temp_dirs, sample_direct_task):
"""Returns error when script not executable."""
- from shelfmark.download.orchestrator import _post_process_download
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"content")
@@ -829,14 +746,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
- patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run', side_effect=PermissionError("not executable")):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
- mock_config.get = MagicMock(return_value=None)
+ _sync_core_config(mock_config, mock_config)
+ mock_config.get = _mock_destination_config(temp_dirs["ingest"])
+ _sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -850,7 +768,7 @@ class TestCustomScriptExecution:
def test_script_timeout_error(self, temp_dirs, sample_direct_task):
"""Returns error when script times out."""
- from shelfmark.download.orchestrator import _post_process_download
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
import subprocess
temp_file = temp_dirs["staging"] / "book.epub"
@@ -859,14 +777,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
- patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run', side_effect=subprocess.TimeoutExpired("script", 300)):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
- mock_config.get = MagicMock(return_value=None)
+ _sync_core_config(mock_config, mock_config)
+ mock_config.get = _mock_destination_config(temp_dirs["ingest"])
+ _sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -880,7 +799,7 @@ class TestCustomScriptExecution:
def test_script_nonzero_exit_error(self, temp_dirs, sample_direct_task):
"""Returns error when script exits non-zero."""
- from shelfmark.download.orchestrator import _post_process_download
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
import subprocess
temp_file = temp_dirs["staging"] / "book.epub"
@@ -889,14 +808,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
- patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run') as mock_run:
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
- mock_config.get = MagicMock(return_value=None)
+ _sync_core_config(mock_config, mock_config)
+ mock_config.get = _mock_destination_config(temp_dirs["ingest"])
+ _sync_core_config(mock_config, mock_config)
error = subprocess.CalledProcessError(1, "script", stderr="Something failed")
mock_run.side_effect = error
@@ -912,116 +832,5 @@ class TestCustomScriptExecution:
status_cb.assert_called_with("error", "Custom script failed: Something failed")
-# =============================================================================
-# Integration-style Tests
-# =============================================================================
-
-class TestDownloadProcessingIntegration:
- """Integration tests for the full download processing pipeline."""
-
- def test_full_direct_download_flow(self, temp_dirs, sample_direct_task):
- """Full flow: download → staging → ingest."""
- from shelfmark.download.orchestrator import _post_process_download
-
- # Simulate downloaded file
- temp_file = temp_dirs["staging"] / "download.epub"
- temp_file.write_bytes(b"epub content")
-
- status_cb = MagicMock()
- cancel_flag = Event()
-
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
-
- mock_config.USE_BOOK_TITLE = True
- mock_config.CUSTOM_SCRIPT = None
- mock_config.get = MagicMock(return_value=None)
-
- result = _post_process_download(
- temp_file=temp_file,
- task=sample_direct_task,
- cancel_flag=cancel_flag,
- status_callback=status_cb,
- )
-
- assert result is not None
- result_path = Path(result)
-
- # File is in ingest
- assert result_path.parent == temp_dirs["ingest"]
- # File has formatted name
- assert "The Way of Kings" in result_path.name
- # Staging file is gone
- assert not temp_file.exists()
- # Final status reported
- status_cb.assert_called_with("complete", "Complete")
-
- def test_full_universal_library_flow(self, temp_dirs, sample_task):
- """Full flow: download → library mode with organization."""
- from shelfmark.download.orchestrator import _post_process_download
-
- library = temp_dirs["base"] / "library"
- library.mkdir()
- temp_file = temp_dirs["staging"] / "book.epub"
- temp_file.write_bytes(b"content")
-
- status_cb = MagicMock()
- cancel_flag = Event()
-
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
-
- mock_config.USE_BOOK_TITLE = True
- mock_config.CUSTOM_SCRIPT = None
- mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "PROCESSING_MODE": "library",
- "LIBRARY_PATH": str(library),
- "LIBRARY_TEMPLATE": "{Author}/{Title}",
- }.get(key, default))
-
- result = _post_process_download(
- temp_file=temp_file,
- task=sample_task,
- cancel_flag=cancel_flag,
- status_callback=status_cb,
- )
-
- result_path = Path(result)
-
- # File is in library with author folder
- assert result_path.parent.name == "Brandon Sanderson"
- assert "The Way of Kings" in result_path.name
- # Staging file cleaned up
- assert not temp_file.exists()
- status_cb.assert_called_with("complete", "Complete")
-
- def test_library_fallback_to_ingest(self, temp_dirs, sample_task):
- """Falls back to ingest when library mode fails."""
- from shelfmark.download.orchestrator import _post_process_download
-
- temp_file = temp_dirs["staging"] / "book.epub"
- temp_file.write_bytes(b"content")
-
- status_cb = MagicMock()
- cancel_flag = Event()
-
- with patch('shelfmark.download.orchestrator.config') as mock_config, \
- patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
-
- mock_config.USE_BOOK_TITLE = False
- mock_config.CUSTOM_SCRIPT = None
- mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "PROCESSING_MODE": "library",
- "LIBRARY_PATH": None, # Not configured
- }.get(key, default))
-
- result = _post_process_download(
- temp_file=temp_file,
- task=sample_task,
- cancel_flag=cancel_flag,
- status_callback=status_cb,
- )
-
- result_path = Path(result)
- # Falls back to ingest
- assert result_path.parent == temp_dirs["ingest"]
+# Integration-style end-to-end processing scenarios live in
+# `tests/core/test_processing_integration.py`.
diff --git a/tests/core/test_hardlink.py b/tests/core/test_hardlink.py
index 63e7ad3..0e3a884 100644
--- a/tests/core/test_hardlink.py
+++ b/tests/core/test_hardlink.py
@@ -13,25 +13,62 @@ Two approaches to preserve torrent files for seeding:
import os
import pytest
+import shutil
import tempfile
from pathlib import Path
+from threading import Event
from unittest.mock import MagicMock, patch
from shelfmark.core.naming import same_filesystem
+def _run_organize_post_process(
+ temp_file: Path,
+ task,
+ library: Path,
+ hardlink_enabled: bool = True,
+ same_fs: bool = True,
+):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ status_cb = MagicMock()
+ cancel_flag = Event()
+
+ with patch('shelfmark.core.config.config') as mock_config, \
+ patch('shelfmark.download.postprocess.transfer.same_filesystem', return_value=same_fs):
+
+ mock_config.CUSTOM_SCRIPT = None
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: {
+ "DESTINATION": str(library),
+ "FILE_ORGANIZATION": "organize",
+ "HARDLINK_TORRENTS": hardlink_enabled,
+ "HARDLINK_TORRENTS_AUDIOBOOK": hardlink_enabled,
+ "SUPPORTED_FORMATS": ["epub", "mp3"],
+ }.get(key, default))
+
+
+ result = _post_process_download(
+ temp_file=temp_file,
+ task=task,
+ cancel_flag=cancel_flag,
+ status_callback=status_cb,
+ )
+
+ return result, status_cb
+
+
class TestStageFile:
"""Tests for stage_file() - the ingest mode approach for torrents."""
def test_copy_mode_preserves_original(self, tmp_path):
"""copy=True preserves original file (for torrent seeding)."""
- from shelfmark.download.orchestrator import stage_file, get_staging_dir
+ from shelfmark.download.staging import stage_file, get_staging_dir
source = tmp_path / "downloads" / "book.epub"
source.parent.mkdir()
source.write_bytes(b"content")
- with patch('shelfmark.download.orchestrator.TMP_DIR', tmp_path / "staging"):
+ with patch('shelfmark.config.env.TMP_DIR', tmp_path / "staging"):
staged = stage_file(source, "task123", copy=True)
assert staged.exists()
@@ -40,13 +77,13 @@ class TestStageFile:
def test_move_mode_removes_original(self, tmp_path):
"""copy=False moves file (original deleted)."""
- from shelfmark.download.orchestrator import stage_file
+ from shelfmark.download.staging import stage_file
source = tmp_path / "downloads" / "book.epub"
source.parent.mkdir()
source.write_bytes(b"content")
- with patch('shelfmark.download.orchestrator.TMP_DIR', tmp_path / "staging"):
+ with patch('shelfmark.config.env.TMP_DIR', tmp_path / "staging"):
staged = stage_file(source, "task123", copy=False)
assert staged.exists()
@@ -54,7 +91,7 @@ class TestStageFile:
def test_handles_filename_collision(self, tmp_path):
"""Adds counter suffix on collision."""
- from shelfmark.download.orchestrator import stage_file
+ from shelfmark.download.staging import stage_file
staging = tmp_path / "staging"
staging.mkdir()
@@ -64,7 +101,7 @@ class TestStageFile:
source.parent.mkdir()
source.write_bytes(b"new content")
- with patch('shelfmark.download.orchestrator.TMP_DIR', staging):
+ with patch('shelfmark.config.env.TMP_DIR', staging):
staged = stage_file(source, "task123", copy=True)
assert staged.name == "book_1.epub"
@@ -128,7 +165,7 @@ class TestAtomicHardlink:
def test_creates_hardlink(self, tmp_path):
"""Creates hardlink to source file."""
- from shelfmark.download.orchestrator import _atomic_hardlink
+ from shelfmark.download.fs import atomic_hardlink as _atomic_hardlink
source = tmp_path / "source.txt"
source.write_text("content")
@@ -144,7 +181,7 @@ class TestAtomicHardlink:
def test_handles_collision_with_counter(self, tmp_path):
"""Appends counter suffix when destination exists."""
- from shelfmark.download.orchestrator import _atomic_hardlink
+ from shelfmark.download.fs import atomic_hardlink as _atomic_hardlink
source = tmp_path / "source.txt"
source.write_text("new content")
@@ -159,7 +196,7 @@ class TestAtomicHardlink:
def test_multiple_collisions(self, tmp_path):
"""Increments counter until finding free slot."""
- from shelfmark.download.orchestrator import _atomic_hardlink
+ from shelfmark.download.fs import atomic_hardlink as _atomic_hardlink
source = tmp_path / "source.txt"
source.write_text("new")
@@ -173,7 +210,7 @@ class TestAtomicHardlink:
def test_preserves_extension(self, tmp_path):
"""Keeps extension when adding counter suffix."""
- from shelfmark.download.orchestrator import _atomic_hardlink
+ from shelfmark.download.fs import atomic_hardlink as _atomic_hardlink
source = tmp_path / "book.epub"
source.write_bytes(b"epub content")
@@ -184,13 +221,33 @@ class TestAtomicHardlink:
assert result.suffix == ".epub"
assert result.name == "book_1.epub"
+ def test_falls_back_to_copy_on_permission_error(self, tmp_path, monkeypatch):
+ """Falls back to copy when hardlink is not permitted."""
+ from shelfmark.download.fs import atomic_hardlink as _atomic_hardlink
+
+ source = tmp_path / "source.txt"
+ source.write_text("content")
+ dest = tmp_path / "dest.txt"
+
+ def _raise_perm(*_args, **_kwargs):
+ raise PermissionError("hardlink not permitted")
+
+ monkeypatch.setattr(os, "link", _raise_perm)
+
+ result = _atomic_hardlink(source, dest)
+
+ assert result == dest
+ assert result.read_text() == "content"
+ assert source.exists()
+ assert os.stat(source).st_ino != os.stat(result).st_ino
+
class TestAtomicMove:
"""Tests for _atomic_move() function."""
def test_moves_file(self, tmp_path):
"""Moves file from source to destination."""
- from shelfmark.download.orchestrator import _atomic_move
+ from shelfmark.download.fs import atomic_move as _atomic_move
source = tmp_path / "source.txt"
source.write_text("content")
@@ -204,7 +261,7 @@ class TestAtomicMove:
def test_handles_collision(self, tmp_path):
"""Appends counter on collision."""
- from shelfmark.download.orchestrator import _atomic_move
+ from shelfmark.download.fs import atomic_move as _atomic_move
source = tmp_path / "source.txt"
source.write_text("new")
@@ -220,7 +277,7 @@ class TestAtomicMove:
def test_cross_filesystem_fallback(self):
"""Falls back to copy when cross-filesystem."""
- from shelfmark.download.orchestrator import _atomic_move
+ from shelfmark.download.fs import atomic_move as _atomic_move
import errno
with tempfile.TemporaryDirectory() as dir1, tempfile.TemporaryDirectory() as dir2:
@@ -235,6 +292,35 @@ class TestAtomicMove:
assert not source.exists()
assert result.read_text() == "content"
+ def test_cross_filesystem_permission_fallback(self, tmp_path, monkeypatch):
+ """Falls back to copy when cross-filesystem move hits permission error."""
+ from shelfmark.download.fs import atomic_move as _atomic_move
+ import errno
+
+ source = tmp_path / "source.txt"
+ source.write_text("content")
+ dest = tmp_path / "dest.txt"
+
+ def _raise_exdev(*_args, **_kwargs):
+ raise OSError(errno.EXDEV, "Cross-device link")
+
+ def _fallback_copy(src, dst, is_move):
+ shutil.copyfile(str(src), str(dst))
+ if is_move:
+ Path(src).unlink()
+
+ monkeypatch.setattr(os, "rename", _raise_exdev)
+
+ with patch("shelfmark.download.fs.shutil.copy2", side_effect=PermissionError("no")) as mock_copy, \
+ patch("shelfmark.download.fs._perform_nfs_fallback", side_effect=_fallback_copy) as mock_fallback:
+ result = _atomic_move(source, dest)
+
+ assert result == dest
+ assert not source.exists()
+ assert dest.read_text() == "content"
+ assert mock_copy.called
+ assert mock_fallback.called
+
class TestHardlinkWithLibraryMode:
"""Tests for hardlinking in library mode context."""
@@ -242,7 +328,7 @@ class TestHardlinkWithLibraryMode:
@pytest.fixture
def mock_config(self):
"""Mock config for library mode."""
- with patch('shelfmark.download.orchestrator.config') as mock:
+ with patch('shelfmark.core.config.config') as mock:
mock.get = MagicMock(side_effect=lambda key, default=None: {
"LIBRARY_PATH": None,
"LIBRARY_PATH_AUDIOBOOK": None,
@@ -270,7 +356,7 @@ class TestHardlinkWithLibraryMode:
def test_transfer_file_hardlink(self, tmp_path, sample_task):
"""Single file transferred via hardlink."""
- from shelfmark.download.orchestrator import _transfer_file_to_library
+ from shelfmark.download.postprocess.pipeline import transfer_file_to_library
library = tmp_path / "library"
library.mkdir()
@@ -283,16 +369,17 @@ class TestHardlinkWithLibraryMode:
status_cb = MagicMock()
- result = _transfer_file_to_library(
- source_path=source,
- library_base=str(library),
- template="{Author}/{Title}",
- metadata={"Author": "Brandon Sanderson", "Title": "Mistborn"},
- task=sample_task,
- temp_file=temp_file,
- status_callback=status_cb,
- use_hardlink=True,
- )
+ with patch('shelfmark.config.env.TMP_DIR', temp_file.parent):
+ result = transfer_file_to_library(
+ source_path=source,
+ library_base=str(library),
+ template="{Author}/{Title}",
+ metadata={"Author": "Brandon Sanderson", "Title": "Mistborn"},
+ task=sample_task,
+ temp_file=temp_file,
+ status_callback=status_cb,
+ use_hardlink=True,
+ )
assert result is not None
result_path = Path(result)
@@ -307,7 +394,7 @@ class TestHardlinkWithLibraryMode:
def test_transfer_file_move(self, tmp_path, sample_task):
"""Single file transferred via move."""
- from shelfmark.download.orchestrator import _transfer_file_to_library
+ from shelfmark.download.postprocess.pipeline import transfer_file_to_library
library = tmp_path / "library"
library.mkdir()
@@ -317,7 +404,7 @@ class TestHardlinkWithLibraryMode:
status_cb = MagicMock()
- result = _transfer_file_to_library(
+ result = transfer_file_to_library(
source_path=source,
library_base=str(library),
template="{Author}/{Title}",
@@ -337,7 +424,7 @@ class TestHardlinkWithLibraryMode:
def test_transfer_directory_hardlink_multifile(self, tmp_path, sample_task):
"""Directory with multiple files transferred via hardlinks."""
- from shelfmark.download.orchestrator import _transfer_directory_to_library
+ from shelfmark.download.postprocess.pipeline import transfer_directory_to_library
library = tmp_path / "library"
library.mkdir()
@@ -359,8 +446,9 @@ class TestHardlinkWithLibraryMode:
sample_task.content_type = "audiobook"
status_cb = MagicMock()
- with patch('shelfmark.download.orchestrator._get_supported_formats', return_value=["mp3"]):
- result = _transfer_directory_to_library(
+ with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["mp3"]), \
+ patch('shelfmark.config.env.TMP_DIR', temp_dir.parent):
+ result = transfer_directory_to_library(
source_dir=source_dir,
library_base=str(library),
template="{Author}/{Title}{ - PartNumber}", # Correct token format
@@ -393,7 +481,7 @@ class TestHardlinkWithLibraryMode:
def test_transfer_directory_move(self, tmp_path, sample_task):
"""Directory transferred via move (non-torrent)."""
- from shelfmark.download.orchestrator import _transfer_directory_to_library
+ from shelfmark.download.postprocess.pipeline import transfer_directory_to_library
library = tmp_path / "library"
library.mkdir()
@@ -406,8 +494,9 @@ class TestHardlinkWithLibraryMode:
sample_task.content_type = "audiobook"
status_cb = MagicMock()
- with patch('shelfmark.download.orchestrator._get_supported_formats', return_value=["mp3"]):
- result = _transfer_directory_to_library(
+ with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["mp3"]), \
+ patch('shelfmark.config.env.TMP_DIR', source_dir.parent):
+ result = transfer_directory_to_library(
source_dir=source_dir,
library_base=str(library),
template="{Author}/{Title}{ - Part PartNumber}",
@@ -428,7 +517,7 @@ class TestHardlinkWithLibraryMode:
def test_single_file_in_directory_no_part_number(self, tmp_path, sample_task):
"""Single file in directory doesn't get part number."""
- from shelfmark.download.orchestrator import _transfer_directory_to_library
+ from shelfmark.download.postprocess.pipeline import transfer_directory_to_library
library = tmp_path / "library"
library.mkdir()
@@ -442,8 +531,8 @@ class TestHardlinkWithLibraryMode:
status_cb = MagicMock()
- with patch('shelfmark.download.orchestrator._get_supported_formats', return_value=["epub"]):
- result = _transfer_directory_to_library(
+ with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["epub"]):
+ result = transfer_directory_to_library(
source_dir=source_dir,
library_base=str(library),
template="{Author}/{Title}{ - PartNumber}", # Correct token format
@@ -477,8 +566,6 @@ class TestHardlinkDecisionLogic:
def test_hardlink_enabled_same_filesystem(self, tmp_path, sample_task):
"""Hardlink used when enabled and same filesystem."""
- from shelfmark.download.orchestrator import _process_organize_mode
-
library = tmp_path / "library"
library.mkdir()
staging = tmp_path / "staging"
@@ -494,15 +581,13 @@ class TestHardlinkDecisionLogic:
status_cb = MagicMock()
- with patch('shelfmark.download.orchestrator.config') as mock_config:
- mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "LIBRARY_PATH": str(library),
- "LIBRARY_TEMPLATE": "{Author}/{Title}",
- "TORRENT_HARDLINK": True,
- "PROCESSING_MODE": "library",
- }.get(key, default))
-
- result = _process_organize_mode(staged, sample_task, status_cb)
+ result, _ = _run_organize_post_process(
+ temp_file=staged,
+ task=sample_task,
+ library=library,
+ hardlink_enabled=True,
+ same_fs=True,
+ )
assert result is not None
# Source should still exist (hardlinked)
@@ -510,8 +595,6 @@ class TestHardlinkDecisionLogic:
def test_hardlink_disabled_falls_back_to_move(self, tmp_path, sample_task):
"""Move used when hardlink disabled in config."""
- from shelfmark.download.orchestrator import _process_organize_mode
-
library = tmp_path / "library"
library.mkdir()
source = tmp_path / "downloads" / "book.epub"
@@ -525,47 +608,38 @@ class TestHardlinkDecisionLogic:
status_cb = MagicMock()
- with patch('shelfmark.download.orchestrator.config') as mock_config:
- mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "LIBRARY_PATH": str(library),
- "LIBRARY_TEMPLATE": "{Author}/{Title}",
- "TORRENT_HARDLINK": False, # Disabled
- "PROCESSING_MODE": "library",
- }.get(key, default))
-
- result = _process_organize_mode(staged, sample_task, status_cb)
+ result, _ = _run_organize_post_process(
+ temp_file=staged,
+ task=sample_task,
+ library=library,
+ hardlink_enabled=False,
+ )
assert result is not None
# Staged file should be moved (not exist)
assert not staged.exists()
def test_no_original_path_uses_staging(self, tmp_path, sample_task):
- """Without original_download_path, moves from staging."""
- from shelfmark.download.orchestrator import _process_organize_mode
-
+ """Non-prowlarr downloads move staged files into destination."""
library = tmp_path / "library"
library.mkdir()
staged = tmp_path / "staging" / "book.epub"
staged.parent.mkdir()
staged.write_bytes(b"content")
- # No original_download_path (direct download scenario)
+ # Simulate a non-external download (e.g. direct download) where Shelfmark owns the
+ # temp file in TMP_DIR and can safely move it.
+ sample_task.source = "direct_download"
sample_task.original_download_path = None
- status_cb = MagicMock()
-
- with patch('shelfmark.download.orchestrator.config') as mock_config:
- mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "LIBRARY_PATH": str(library),
- "LIBRARY_TEMPLATE": "{Author}/{Title}",
- "TORRENT_HARDLINK": True,
- "PROCESSING_MODE": "library",
- }.get(key, default))
-
- result = _process_organize_mode(staged, sample_task, status_cb)
+ result, _ = _run_organize_post_process(
+ temp_file=staged,
+ task=sample_task,
+ library=library,
+ hardlink_enabled=True,
+ )
assert result is not None
- # Staged file should be moved
assert not staged.exists()
@@ -574,7 +648,7 @@ class TestHardlinkInodeVerification:
def test_hardlink_shares_inode(self, tmp_path):
"""Hardlinked files share same inode."""
- from shelfmark.download.orchestrator import _atomic_hardlink
+ from shelfmark.download.fs import atomic_hardlink as _atomic_hardlink
source = tmp_path / "source.txt"
source.write_text("shared content")
@@ -588,7 +662,7 @@ class TestHardlinkInodeVerification:
def test_hardlink_reflects_changes(self, tmp_path):
"""Changes to source reflect in hardlink."""
- from shelfmark.download.orchestrator import _atomic_hardlink
+ from shelfmark.download.fs import atomic_hardlink as _atomic_hardlink
source = tmp_path / "source.txt"
source.write_text("original")
@@ -604,7 +678,7 @@ class TestHardlinkInodeVerification:
def test_hardlink_count_increases(self, tmp_path):
"""Link count increases with each hardlink."""
- from shelfmark.download.orchestrator import _atomic_hardlink
+ from shelfmark.download.fs import atomic_hardlink as _atomic_hardlink
source = tmp_path / "source.txt"
source.write_text("content")
@@ -635,39 +709,39 @@ class TestTorrentOptimization:
search_mode=SearchMode.UNIVERSAL,
)
- def test_is_torrent_source_true(self, tmp_path, sample_task):
+ def testis_torrent_source_true(self, tmp_path, sample_task):
"""Detects when source is the torrent client path."""
- from shelfmark.download.orchestrator import _is_torrent_source
+ from shelfmark.download.postprocess.pipeline import is_torrent_source
torrent_path = tmp_path / "downloads" / "book.epub"
torrent_path.parent.mkdir()
torrent_path.touch()
sample_task.original_download_path = str(torrent_path)
- assert _is_torrent_source(torrent_path, sample_task) is True
+ assert is_torrent_source(torrent_path, sample_task) is True
- def test_is_torrent_source_false_no_original(self, tmp_path, sample_task):
+ def testis_torrent_source_false_no_original(self, tmp_path, sample_task):
"""Returns False when no original_download_path set."""
- from shelfmark.download.orchestrator import _is_torrent_source
+ from shelfmark.download.postprocess.pipeline import is_torrent_source
some_path = tmp_path / "staging" / "book.epub"
sample_task.original_download_path = None
- assert _is_torrent_source(some_path, sample_task) is False
+ assert is_torrent_source(some_path, sample_task) is False
- def test_is_torrent_source_false_different_path(self, tmp_path, sample_task):
+ def testis_torrent_source_false_different_path(self, tmp_path, sample_task):
"""Returns False when paths don't match."""
- from shelfmark.download.orchestrator import _is_torrent_source
+ from shelfmark.download.postprocess.pipeline import is_torrent_source
torrent_path = tmp_path / "downloads" / "book.epub"
staging_path = tmp_path / "staging" / "book.epub"
sample_task.original_download_path = str(torrent_path)
- assert _is_torrent_source(staging_path, sample_task) is False
+ assert is_torrent_source(staging_path, sample_task) is False
def test_library_mode_torrent_no_hardlink_copies(self, tmp_path, sample_task):
"""Library mode copies (not moves) torrent files when hardlink unavailable."""
- from shelfmark.download.orchestrator import _transfer_file_to_library
+ from shelfmark.download.postprocess.pipeline import transfer_file_to_library
library = tmp_path / "library"
library.mkdir()
@@ -680,7 +754,7 @@ class TestTorrentOptimization:
status_cb = MagicMock()
- result = _transfer_file_to_library(
+ result = transfer_file_to_library(
source_path=torrent_path,
library_base=str(library),
template="{Author}/{Title}",
@@ -698,7 +772,7 @@ class TestTorrentOptimization:
def test_library_mode_non_torrent_moves(self, tmp_path, sample_task):
"""Library mode moves (not copies) non-torrent files."""
- from shelfmark.download.orchestrator import _transfer_file_to_library
+ from shelfmark.download.postprocess.pipeline import transfer_file_to_library
library = tmp_path / "library"
library.mkdir()
@@ -711,7 +785,7 @@ class TestTorrentOptimization:
status_cb = MagicMock()
- result = _transfer_file_to_library(
+ result = transfer_file_to_library(
source_path=staging_path,
library_base=str(library),
template="{Author}/{Title}",
@@ -729,7 +803,7 @@ class TestTorrentOptimization:
def test_directory_torrent_copies_all_files(self, tmp_path, sample_task):
"""Multi-file torrent directory copies all files to library."""
- from shelfmark.download.orchestrator import _transfer_directory_to_library
+ from shelfmark.download.postprocess.pipeline import transfer_directory_to_library
library = tmp_path / "library"
library.mkdir()
@@ -742,8 +816,8 @@ class TestTorrentOptimization:
sample_task.content_type = "audiobook"
status_cb = MagicMock()
- with patch('shelfmark.download.orchestrator._get_supported_formats', return_value=["mp3"]):
- result = _transfer_directory_to_library(
+ with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["mp3"]):
+ result = transfer_directory_to_library(
source_dir=torrent_dir,
library_base=str(library),
template="{Author}/{Title}{ - PartNumber}",
@@ -763,12 +837,439 @@ class TestTorrentOptimization:
assert len(list(author_dir.glob("*.mp3"))) == 2
+class TestTorrentSourceCleanupProtection:
+ """Integration tests simulating real torrent download flows.
+
+ These tests simulate actual production scenarios where:
+ - Torrent client (qBittorrent/Transmission) downloads to /downloads/complete/
+ - Prowlarr handler returns that path directly (no staging for torrents)
+ - Orchestrator processes via _post_process_download()
+ - Source files must remain intact for seeding
+
+ Each test simulates a specific real-world content type and file structure.
+ """
+
+ def _make_config_mock(self, library_path: str, hardlink: bool = True):
+ """Create config mock for library/organize mode with hardlinking."""
+ return MagicMock(side_effect=lambda key, default=None: {
+ # Destination paths (what _get_final_destination uses)
+ "DESTINATION": library_path,
+ "DESTINATION_AUDIOBOOK": library_path,
+ # Templates (what _get_template uses)
+ "TEMPLATE_ORGANIZE": "{Author}/{Title}",
+ "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}",
+ # File organization mode
+ "FILE_ORGANIZATION": "organize",
+ "FILE_ORGANIZATION_AUDIOBOOK": "organize",
+ # Hardlink toggle
+ "HARDLINK_TORRENTS": hardlink,
+ "HARDLINK_TORRENTS_AUDIOBOOK": hardlink,
+ # Supported formats
+ "SUPPORTED_FORMATS": ["epub", "mobi"],
+ "SUPPORTED_AUDIOBOOK_FORMATS": ["mp3"],
+ }.get(key, default))
+
+ # ==================== EPUB EBOOK TESTS ====================
+
+ def test_torrent_epub_single_file_hardlink(self, tmp_path):
+ """Torrent: Single .epub ebook - hardlink preserves source for seeding.
+
+ Simulates: User downloads "The Way of Kings.epub" via qBittorrent.
+ qBittorrent saves to /downloads/complete/The Way of Kings.epub
+ Prowlarr handler returns this path directly.
+ Library mode hardlinks to /library/Brandon Sanderson/The Way of Kings.epub
+ Original MUST remain for seeding.
+ """
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+ from shelfmark.core.models import DownloadTask, SearchMode
+
+ # Simulate qBittorrent's download location
+ downloads = tmp_path / "downloads" / "complete"
+ downloads.mkdir(parents=True)
+ torrent_file = downloads / "The Way of Kings.epub"
+ torrent_file.write_bytes(b"PK\x03\x04" + b"epub content" * 1000) # Fake epub
+
+ library = tmp_path / "library"
+ library.mkdir()
+
+ # Task as returned by Prowlarr handler (original_download_path = torrent location)
+ task = DownloadTask(
+ task_id="prowlarr_12345",
+ source="prowlarr",
+ title="The Way of Kings",
+ author="Brandon Sanderson",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(torrent_file), # Handler sets this for torrents
+ )
+
+ status_cb = MagicMock()
+
+ # Patch config used by postprocess pipeline
+ with patch('shelfmark.core.config.config') as mock_orch:
+ mock_orch.get = self._make_config_mock(str(library), hardlink=True)
+ mock_orch.CUSTOM_SCRIPT = None
+ result = _post_process_download(torrent_file, task, Event(), status_cb)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+ assert result_path.suffix == ".epub"
+ assert "Brandon Sanderson" in str(result_path)
+
+ # CRITICAL: Torrent source must exist for seeding
+ assert torrent_file.exists(), "Torrent epub was deleted! qBittorrent seeding will fail."
+
+ # Verify hardlink (same inode = no extra disk space)
+ assert os.stat(torrent_file).st_ino == os.stat(result_path).st_ino
+
+ def test_torrent_mobi_single_file_hardlink(self, tmp_path):
+ """Torrent: Single .mobi ebook - hardlink preserves source.
+
+ Same flow as epub but with .mobi format.
+ """
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+ from shelfmark.core.models import DownloadTask, SearchMode
+
+ downloads = tmp_path / "downloads" / "complete"
+ downloads.mkdir(parents=True)
+ torrent_file = downloads / "Dune.mobi"
+ torrent_file.write_bytes(b"BOOKMOBI" + b"mobi content" * 1000)
+
+ library = tmp_path / "library"
+ library.mkdir()
+
+ task = DownloadTask(
+ task_id="prowlarr_67890",
+ source="prowlarr",
+ title="Dune",
+ author="Frank Herbert",
+ format="mobi",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(torrent_file),
+ )
+
+ status_cb = MagicMock()
+
+ with patch('shelfmark.core.config.config') as mock_orch:
+ mock_orch.get = self._make_config_mock(str(library), hardlink=True)
+ mock_orch.CUSTOM_SCRIPT = None
+ result = _post_process_download(torrent_file, task, Event(), status_cb)
+
+ assert result is not None
+ assert torrent_file.exists(), "Torrent mobi was deleted!"
+ assert os.stat(torrent_file).st_ino == os.stat(result).st_ino
+
+ # ==================== AUDIOBOOK TESTS ====================
+
+ def test_torrent_audiobook_multifile_hardlink(self, tmp_path):
+ """Torrent: Multi-file audiobook - all source files preserved for seeding.
+
+ Simulates: User downloads "Project Hail Mary Audiobook" torrent.
+ qBittorrent saves to /downloads/complete/Project Hail Mary/
+ Contains: Part 01.mp3, Part 02.mp3, ... Part 12.mp3
+ Handler returns directory path.
+ Library mode hardlinks all mp3s to /library/Andy Weir/Project Hail Mary - 01.mp3, etc.
+ ALL original files must remain for seeding.
+ """
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+ from shelfmark.core.models import DownloadTask, SearchMode
+
+ # Simulate torrent audiobook structure
+ downloads = tmp_path / "downloads" / "complete"
+ torrent_dir = downloads / "Project Hail Mary Audiobook"
+ torrent_dir.mkdir(parents=True)
+
+ # Create realistic audiobook files
+ audio_files = []
+ for i in range(1, 13):
+ audio_file = torrent_dir / f"Part {i:02d}.mp3"
+ audio_file.write_bytes(b"ID3" + f"audio content part {i}".encode() * 500)
+ audio_files.append(audio_file)
+
+ # Also include cover art and nfo (should be ignored)
+ (torrent_dir / "cover.jpg").write_bytes(b"fake jpg")
+ (torrent_dir / "info.nfo").write_text("release info")
+
+ library = tmp_path / "library"
+ library.mkdir()
+
+ task = DownloadTask(
+ task_id="prowlarr_audiobook_001",
+ source="prowlarr",
+ title="Project Hail Mary",
+ author="Andy Weir",
+ format="mp3",
+ content_type="audiobook",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(torrent_dir),
+ )
+
+ status_cb = MagicMock()
+
+ with patch('shelfmark.core.config.config') as mock_orch:
+ mock_orch.get = self._make_config_mock(str(library), hardlink=True)
+ mock_orch.CUSTOM_SCRIPT = None
+ result = _post_process_download(torrent_dir, task, Event(), status_cb)
+
+ assert result is not None
+
+ # CRITICAL: All torrent source files must exist for seeding
+ assert torrent_dir.exists(), "Torrent audiobook directory was deleted!"
+ for audio_file in audio_files:
+ assert audio_file.exists(), f"Torrent file {audio_file.name} was deleted!"
+
+ # Verify library has all 12 files
+ library_files = list((library / "Andy Weir").glob("*.mp3"))
+ assert len(library_files) == 12
+
+ # ==================== COMIC/CBZ TESTS ====================
+
+ def test_torrent_cbz_comic_hardlink(self, tmp_path):
+ """Torrent: Single .cbz comic - hardlink preserves source.
+
+ Simulates: User downloads comic via torrent.
+ """
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+ from shelfmark.core.models import DownloadTask, SearchMode
+
+ downloads = tmp_path / "downloads" / "complete"
+ downloads.mkdir(parents=True)
+ torrent_file = downloads / "Batman 001.cbz"
+ torrent_file.write_bytes(b"PK\x03\x04" + b"cbz content" * 500)
+
+ library = tmp_path / "library"
+ library.mkdir()
+
+ task = DownloadTask(
+ task_id="prowlarr_comic_001",
+ source="prowlarr",
+ title="Batman 001",
+ author="DC Comics",
+ format="cbz",
+ content_type="comic_book",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(torrent_file),
+ )
+
+ status_cb = MagicMock()
+
+ with patch('shelfmark.core.config.config') as mock_orch:
+ mock_orch.get = self._make_config_mock(str(library), hardlink=True)
+ mock_orch.CUSTOM_SCRIPT = None
+ result = _post_process_download(torrent_file, task, Event(), status_cb)
+
+ assert result is not None
+ assert torrent_file.exists(), "Torrent cbz was deleted!"
+
+ # ==================== NON-TORRENT TESTS (USENET/DIRECT) ====================
+
+ def test_usenet_epub_no_original_path_copies_file(self, tmp_path):
+ """Usenet: files are copied into destination and source is preserved.
+
+ For external usenet downloads, Shelfmark treats the client path as read-only and
+ avoids deleting anything itself. Client-side cleanup is handled separately.
+ """
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+ from shelfmark.core.models import DownloadTask, SearchMode
+
+ downloads = tmp_path / "downloads" / "complete"
+ downloads.mkdir(parents=True)
+ usenet_file = downloads / "book.epub"
+ usenet_file.write_bytes(b"usenet epub content")
+
+ library = tmp_path / "library"
+ library.mkdir()
+
+ task = DownloadTask(
+ task_id="nzbget_12345",
+ source="prowlarr",
+ title="Test Book",
+ author="Test Author",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=None,
+ )
+
+ status_cb = MagicMock()
+
+ with patch('shelfmark.core.config.config') as mock_orch:
+ mock_orch.get = self._make_config_mock(str(library), hardlink=True)
+ mock_orch.CUSTOM_SCRIPT = None
+ result = _post_process_download(usenet_file, task, Event(), status_cb)
+
+ assert result is not None
+ assert usenet_file.exists(), "Usenet source file should be preserved"
+ assert Path(result).exists()
+
+ def test_direct_download_moves_file(self, tmp_path):
+ """Direct download (Anna's Archive): File should be MOVED.
+
+ Simulates: User downloads directly from Anna's Archive.
+ No torrent client involved, no seeding needed.
+ """
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+ from shelfmark.core.models import DownloadTask, SearchMode
+
+ staging = tmp_path / "staging"
+ staging.mkdir()
+ staged_file = staging / "direct_download.epub"
+ staged_file.write_bytes(b"direct download content")
+
+ library = tmp_path / "library"
+ library.mkdir()
+
+ task = DownloadTask(
+ task_id="direct_12345",
+ source="annas_archive",
+ title="Direct Book",
+ author="Direct Author",
+ format="epub",
+ search_mode=SearchMode.DIRECT,
+ original_download_path=None,
+ )
+
+ status_cb = MagicMock()
+
+ with patch('shelfmark.core.config.config') as mock_orch:
+ mock_orch.get = self._make_config_mock(str(library), hardlink=True)
+ mock_orch.CUSTOM_SCRIPT = None
+ result = _post_process_download(staged_file, task, Event(), status_cb)
+
+ assert result is not None
+ assert not staged_file.exists(), "Direct download should be moved"
+
+ # ==================== HARDLINK DISABLED TESTS ====================
+
+ def test_torrent_with_hardlink_disabled_copies_file(self, tmp_path):
+ """Torrent with hardlink disabled: Should COPY (not move) to preserve seeding.
+
+ When user disables hardlinking but downloads via torrent,
+ the file must still be preserved for seeding (via copy).
+ """
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+ from shelfmark.core.models import DownloadTask, SearchMode
+
+ downloads = tmp_path / "downloads" / "complete"
+ downloads.mkdir(parents=True)
+ torrent_file = downloads / "book.epub"
+ torrent_file.write_bytes(b"torrent content")
+
+ library = tmp_path / "library"
+ library.mkdir()
+
+ task = DownloadTask(
+ task_id="prowlarr_no_hardlink",
+ source="prowlarr",
+ title="Test Book",
+ author="Test Author",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(torrent_file),
+ )
+
+ status_cb = MagicMock()
+
+ with patch('shelfmark.core.config.config') as mock_orch:
+ # Hardlink DISABLED
+ mock_orch.get = self._make_config_mock(str(library), hardlink=False)
+ mock_orch.CUSTOM_SCRIPT = None
+ result = _post_process_download(torrent_file, task, Event(), status_cb)
+
+ assert result is not None
+ # Even without hardlink, torrent source must be preserved (copied)
+ assert torrent_file.exists(), "Torrent source deleted even with hardlink disabled!"
+
+ # Verify NOT a hardlink (different inodes = separate copy)
+ assert os.stat(torrent_file).st_ino != os.stat(result).st_ino
+
+ # ==================== EDGE CASE TESTS ====================
+
+ def test_torrent_cross_filesystem_falls_back_to_copy(self, tmp_path):
+ """Torrent on different filesystem: Falls back to copy, preserves source.
+
+ When torrent is on different filesystem than library,
+ hardlink fails and should fall back to copy (not move).
+ """
+ from shelfmark.download.postprocess.pipeline import transfer_file_to_library
+ from shelfmark.core.models import DownloadTask, SearchMode
+
+ # Simulate by directly calling transfer_file_to_library with use_hardlink=False
+ # (this is what happens after same_filesystem check fails)
+ downloads = tmp_path / "downloads"
+ downloads.mkdir()
+ torrent_file = downloads / "book.epub"
+ torrent_file.write_bytes(b"content")
+
+ library = tmp_path / "library"
+ library.mkdir()
+
+ task = DownloadTask(
+ task_id="test",
+ source="prowlarr",
+ title="Test",
+ author="Author",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(torrent_file),
+ )
+
+ status_cb = MagicMock()
+
+ result = transfer_file_to_library(
+ source_path=torrent_file,
+ library_base=str(library),
+ template="{Author}/{Title}",
+ metadata={"Author": "Author", "Title": "Test"},
+ task=task,
+ temp_file=torrent_file,
+ status_callback=status_cb,
+ use_hardlink=False, # Simulating cross-filesystem fallback
+ )
+
+ assert result is not None
+ # Torrent source preserved (copied, not moved)
+ assert torrent_file.exists()
+
+ def testis_torrent_source_detection(self, tmp_path):
+ """Unit test: is_torrent_source correctly identifies torrent paths."""
+ from shelfmark.download.postprocess.pipeline import is_torrent_source
+ from shelfmark.core.models import DownloadTask, SearchMode
+
+ torrent_path = tmp_path / "downloads" / "book.epub"
+ torrent_path.parent.mkdir()
+ torrent_path.touch()
+
+ staging_path = tmp_path / "staging" / "book.epub"
+ staging_path.parent.mkdir()
+ staging_path.touch()
+
+ task = DownloadTask(
+ task_id="test",
+ source="prowlarr",
+ title="Test",
+ author="Author",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ )
+
+ # No original path = not a torrent source
+ task.original_download_path = None
+ assert is_torrent_source(torrent_path, task) is False
+ assert is_torrent_source(staging_path, task) is False
+
+ # With original path set
+ task.original_download_path = str(torrent_path)
+ assert is_torrent_source(torrent_path, task) is True
+ assert is_torrent_source(staging_path, task) is False
+
+
class TestEdgeCases:
"""Edge cases and error handling."""
def test_empty_directory_returns_none(self, tmp_path):
"""Empty source directory returns None."""
- from shelfmark.download.orchestrator import _transfer_directory_to_library
+ from shelfmark.download.postprocess.pipeline import transfer_directory_to_library
from shelfmark.core.models import DownloadTask, SearchMode
task = DownloadTask(
@@ -787,8 +1288,8 @@ class TestEdgeCases:
status_cb = MagicMock()
- with patch('shelfmark.download.orchestrator._get_supported_formats', return_value=["epub"]):
- result = _transfer_directory_to_library(
+ with patch('shelfmark.download.postprocess.scan.get_supported_formats', return_value=["epub"]):
+ result = transfer_directory_to_library(
source_dir=source_dir,
library_base=str(library),
template="{Title}",
@@ -803,7 +1304,7 @@ class TestEdgeCases:
def test_nonexistent_source_for_hardlink(self, tmp_path):
"""Missing source file prevents hardlink creation."""
- from shelfmark.download.orchestrator import _process_organize_mode
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
from shelfmark.core.models import DownloadTask, SearchMode
task = DownloadTask(
@@ -824,15 +1325,16 @@ class TestEdgeCases:
status_cb = MagicMock()
- with patch('shelfmark.download.orchestrator.config') as mock_config:
+ with patch('shelfmark.core.config.config') as mock_config:
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "LIBRARY_PATH": str(library),
- "LIBRARY_TEMPLATE": "{Title}",
- "TORRENT_HARDLINK": True,
- "PROCESSING_MODE": "library",
+ "DESTINATION": str(library),
+ "TEMPLATE_ORGANIZE": "{Title}",
+ "FILE_ORGANIZATION": "organize",
+ "HARDLINK_TORRENTS": True,
}.get(key, default))
+ mock_config.CUSTOM_SCRIPT = None
- result = _process_organize_mode(staged, task, status_cb)
+ result = _post_process_download(staged, task, Event(), status_cb)
# Should fall back to move since original doesn't exist
assert result is not None
@@ -840,7 +1342,7 @@ class TestEdgeCases:
def test_permission_denied_library_path(self, tmp_path):
"""Handles permission denied on library path."""
- from shelfmark.download.orchestrator import _process_organize_mode
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
from shelfmark.core.models import DownloadTask, SearchMode
task = DownloadTask(
@@ -858,14 +1360,14 @@ class TestEdgeCases:
status_cb = MagicMock()
- with patch('shelfmark.download.orchestrator.config') as mock_config:
+ with patch('shelfmark.core.config.config') as mock_config:
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
- "LIBRARY_PATH": "/nonexistent/protected/path",
- "LIBRARY_TEMPLATE": "{Title}",
- "PROCESSING_MODE": "library",
+ "DESTINATION": "/nonexistent/protected/path",
+ "TEMPLATE_ORGANIZE": "{Title}",
+ "FILE_ORGANIZATION": "organize",
}.get(key, default))
- result = _process_organize_mode(staged, task, status_cb)
+ result = _post_process_download(staged, task, Event(), status_cb)
# Should return None (fall back to ingest)
assert result is None
diff --git a/tests/core/test_permission_handling.py b/tests/core/test_permission_handling.py
new file mode 100644
index 0000000..55cd8e1
--- /dev/null
+++ b/tests/core/test_permission_handling.py
@@ -0,0 +1,96 @@
+import errno
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from shelfmark.core.models import DownloadTask, SearchMode
+from shelfmark.download.postprocess.pipeline import collect_directory_files, validate_destination
+
+
+def test_validate_destination_success_cleans_up_probe(tmp_path):
+ destination = tmp_path / "dest"
+ status_cb = MagicMock()
+
+ assert validate_destination(destination, status_cb) is True
+ assert list(destination.glob(".shelfmark_write_test_*")) == []
+
+
+def test_validate_destination_write_probe_permission_error(tmp_path):
+ destination = tmp_path / "dest"
+ destination.mkdir()
+ status_cb = MagicMock()
+
+ real_write_text = Path.write_text
+
+ def fake_write_text(self, data, *args, **kwargs):
+ if ".shelfmark_write_test_" in self.name:
+ raise PermissionError(errno.EACCES, "Permission denied", str(self))
+ return real_write_text(self, data, *args, **kwargs)
+
+ with patch("pathlib.Path.write_text", new=fake_write_text):
+ assert validate_destination(destination, status_cb) is False
+
+ status_cb.assert_called()
+ assert status_cb.call_args[0][0] == "error"
+ assert "Destination not writable" in status_cb.call_args[0][1]
+
+
+def test_collect_directory_files_ignores_permission_errors(tmp_path):
+ directory = tmp_path / "download"
+ directory.mkdir()
+ (directory / "book.epub").write_text("content")
+
+ task = DownloadTask(
+ task_id="scan-test",
+ source="direct_download",
+ title="Test",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ )
+
+ def fake_walk(top, onerror=None):
+ if onerror:
+ onerror(PermissionError(errno.EACCES, "Permission denied", str(Path(top) / "secret")))
+ yield str(top), [], ["book.epub"]
+
+ with patch("shelfmark.download.postprocess.scan.os.walk", side_effect=fake_walk):
+ files, rejected, cleanup, error = collect_directory_files(
+ directory,
+ task,
+ allow_archive_extraction=True,
+ status_callback=None,
+ )
+
+ assert error is None
+ assert rejected == []
+ assert cleanup == []
+ assert (directory / "book.epub") in files
+
+
+def test_collect_directory_files_permission_denied_root(tmp_path):
+ directory = tmp_path / "download"
+ directory.mkdir()
+
+ task = DownloadTask(
+ task_id="scan-test",
+ source="direct_download",
+ title="Test",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ )
+
+ with patch(
+ "shelfmark.download.postprocess.scan.os.scandir",
+ side_effect=PermissionError(errno.EACCES, "Permission denied", str(directory)),
+ ):
+ files, rejected, cleanup, error = collect_directory_files(
+ directory,
+ task,
+ allow_archive_extraction=True,
+ status_callback=None,
+ )
+
+ assert files == []
+ assert rejected == []
+ assert cleanup == []
+ assert error is not None
+ assert error.startswith("Permission denied")
diff --git a/tests/core/test_processing_integration.py b/tests/core/test_processing_integration.py
new file mode 100644
index 0000000..94dfcc6
--- /dev/null
+++ b/tests/core/test_processing_integration.py
@@ -0,0 +1,969 @@
+"""Integration tests for real filesystem processing flows."""
+
+import os
+import zipfile
+from pathlib import Path
+from threading import Event
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from shelfmark.core.models import DownloadTask, SearchMode
+
+
+def _build_config(
+ destination: Path,
+ organization: str,
+ hardlink: bool = False,
+ rename_template: str = "{Author} - {Title}",
+ supported_formats: list[str] | None = None,
+ supported_audiobook_formats: list[str] | None = None,
+):
+ values = {
+ "DESTINATION": str(destination),
+ "INGEST_DIR": str(destination),
+ "DESTINATION_AUDIOBOOK": str(destination),
+ "FILE_ORGANIZATION": organization,
+ "FILE_ORGANIZATION_AUDIOBOOK": organization,
+ "TEMPLATE_RENAME": rename_template,
+ "TEMPLATE_ORGANIZE": "{Author}/{Title}",
+ "TEMPLATE_AUDIOBOOK_RENAME": rename_template,
+ "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}",
+ "SUPPORTED_FORMATS": supported_formats or ["epub"],
+ "SUPPORTED_AUDIOBOOK_FORMATS": supported_audiobook_formats or ["mp3"],
+ "HARDLINK_TORRENTS": hardlink,
+ "HARDLINK_TORRENTS_AUDIOBOOK": hardlink,
+ }
+ return MagicMock(side_effect=lambda key, default=None: values.get(key, default))
+
+
+def _sync_config(mock_config, mock_core):
+ mock_core.get = mock_config.get
+ mock_core.CUSTOM_SCRIPT = mock_config.CUSTOM_SCRIPT
+
+
+def test_direct_download_rename_moves_file(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ staging.mkdir()
+ ingest.mkdir()
+
+ temp_file = staging / "book.epub"
+ temp_file.write_text("content")
+
+ task = DownloadTask(
+ task_id="direct-1",
+ source="direct_download",
+ title="The Way of Kings",
+ author="Brandon Sanderson",
+ format="epub",
+ search_mode=SearchMode.DIRECT,
+ )
+
+ statuses = []
+ status_cb = lambda status, message: statuses.append((status, message))
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = _build_config(ingest, organization="rename")
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(temp_file, task, Event(), status_cb)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+ assert result_path.parent == ingest
+ assert result_path.name == "Brandon Sanderson - The Way of Kings.epub"
+ assert not temp_file.exists()
+ assert any("Moving" in msg for _, msg in statuses)
+
+
+def test_torrent_hardlink_preserves_source(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ downloads = tmp_path / "downloads"
+ ingest = tmp_path / "ingest"
+ downloads.mkdir()
+ ingest.mkdir()
+
+ original = downloads / "Stormlight.epub"
+ original.write_text("content")
+
+ task = DownloadTask(
+ task_id="torrent-1",
+ source="prowlarr",
+ title="The Way of Kings",
+ author="Brandon Sanderson",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(original),
+ )
+
+ status_cb = lambda *_args: None
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"):
+ mock_config.get = _build_config(ingest, organization="organize", hardlink=True)
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(original, task, Event(), status_cb)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+ assert original.exists()
+ assert os.stat(original).st_ino == os.stat(result_path).st_ino
+
+
+def test_torrent_hardlink_enabled_archive_is_hardlinked_without_extraction(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ downloads = tmp_path / "downloads"
+ ingest = tmp_path / "ingest"
+ downloads.mkdir()
+ ingest.mkdir()
+
+ original = downloads / "Seed.zip"
+ with zipfile.ZipFile(original, "w") as zf:
+ zf.writestr("Seed.epub", "content")
+
+ task = DownloadTask(
+ task_id="torrent-zip-hardlink",
+ source="prowlarr",
+ title="Seed",
+ author="Seeder",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(original),
+ )
+
+ status_cb = lambda *_args: None
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"):
+ mock_config.get = _build_config(
+ ingest,
+ organization="none",
+ hardlink=True,
+ supported_formats=["zip"],
+ )
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(original, task, Event(), status_cb)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+ assert result_path.suffix == ".zip"
+
+ # Torrent source preserved for seeding.
+ assert original.exists()
+
+ # Hardlink success (same inode).
+ assert os.stat(original).st_ino == os.stat(result_path).st_ino
+
+ # No extraction should occur.
+ assert list(ingest.glob("*.epub")) == []
+
+
+def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ downloads = tmp_path / "downloads"
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ downloads.mkdir()
+ staging.mkdir()
+ ingest.mkdir()
+
+ original = downloads / "Seed.zip"
+ with zipfile.ZipFile(original, "w") as zf:
+ zf.writestr("Seed.epub", "content")
+
+ task = DownloadTask(
+ task_id="torrent-zip-fallback",
+ source="prowlarr",
+ title="Seed",
+ author="Seeder",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(original),
+ )
+
+ statuses = []
+ status_cb = lambda status, message: statuses.append((status, message))
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", staging), \
+ patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False):
+ mock_config.get = _build_config(ingest, organization="none", hardlink=True)
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(original, task, Event(), status_cb)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+ assert result_path.suffix == ".zip"
+
+ # Torrent source must remain for seeding.
+ assert original.exists()
+
+ # Most importantly: hardlink-setting-enabled fallback to copy should NOT extract.
+ assert list(ingest.glob("*.epub")) == []
+
+ assert any(msg.startswith("Copying") for _, msg in statuses)
+
+
+def test_torrent_hardlink_enabled_copy_fallback_directory_archive_kept_when_zip_supported(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ downloads = tmp_path / "downloads"
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ downloads.mkdir()
+ staging.mkdir()
+ ingest.mkdir()
+
+ original_dir = downloads / "release"
+ original_dir.mkdir()
+
+ archive_path = original_dir / "Seed.zip"
+ with zipfile.ZipFile(archive_path, "w") as zf:
+ zf.writestr("Seed.epub", "content")
+
+ task = DownloadTask(
+ task_id="torrent-zip-dir-fallback",
+ source="prowlarr",
+ title="Seed",
+ author="Seeder",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(original_dir),
+ )
+
+ status_cb = lambda *_args: None
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", staging), \
+ patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False):
+ mock_config.get = _build_config(
+ ingest,
+ organization="none",
+ hardlink=True,
+ supported_formats=["zip"],
+ )
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(original_dir, task, Event(), status_cb)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+ assert result_path.parent == ingest
+ assert result_path.name == "Seed.zip"
+
+ # Torrent source must remain intact for seeding.
+ assert archive_path.exists()
+
+ # Staging copy should be cleaned up.
+ assert list(staging.iterdir()) == []
+
+
+def test_torrent_copy_when_hardlink_disabled(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ downloads = tmp_path / "downloads"
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ downloads.mkdir()
+ staging.mkdir()
+ ingest.mkdir()
+
+ original = downloads / "Seed.epub"
+ original.write_text("content")
+
+ task = DownloadTask(
+ task_id="torrent-2",
+ source="prowlarr",
+ title="Seed",
+ author="Seeder",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(original),
+ )
+
+ status_cb = lambda *_args: None
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = _build_config(ingest, organization="none", hardlink=False)
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(original, task, Event(), status_cb)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+ assert result_path.name == "Seed.epub"
+ assert original.exists()
+ assert os.stat(original).st_ino != os.stat(result_path).st_ino
+ assert list(staging.iterdir()) == []
+
+
+def test_archive_extraction_flow(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ staging.mkdir()
+ ingest.mkdir()
+
+ archive_path = staging / "book.zip"
+ with zipfile.ZipFile(archive_path, "w") as zf:
+ zf.writestr("book.epub", "content")
+
+ task = DownloadTask(
+ task_id="direct-archive",
+ source="direct_download",
+ title="Archive Test",
+ author="Tester",
+ format="epub",
+ search_mode=SearchMode.DIRECT,
+ )
+
+ status_cb = lambda *_args: None
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = _build_config(ingest, organization="rename")
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(archive_path, task, Event(), status_cb)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+ assert result_path.parent == ingest
+
+
+def test_archive_extraction_organize_creates_directories(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ staging.mkdir()
+ ingest.mkdir()
+
+ archive_path = staging / "book.zip"
+ with zipfile.ZipFile(archive_path, "w") as zf:
+ zf.writestr("book.epub", "content")
+
+ task = DownloadTask(
+ task_id="direct-archive-organize",
+ source="direct_download",
+ title="Archive Test",
+ author="Tester",
+ format="epub",
+ search_mode=SearchMode.DIRECT,
+ )
+
+ status_cb = lambda *_args: None
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = _build_config(ingest, organization="organize")
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(archive_path, task, Event(), status_cb)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+ assert result_path.parent == ingest / "Tester"
+ assert result_path.name == "Archive Test.epub"
+
+
+def test_archive_extraction_organize_multifile_assigns_part_numbers(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ staging.mkdir()
+ ingest.mkdir()
+
+ archive_path = staging / "audio.zip"
+ with zipfile.ZipFile(archive_path, "w") as zf:
+ zf.writestr("Part 2.mp3", "audio2")
+ zf.writestr("Part 10.mp3", "audio10")
+
+ task = DownloadTask(
+ task_id="direct-archive-audio",
+ source="direct_download",
+ title="Archive Audio",
+ author="Tester",
+ format="mp3",
+ content_type="audiobook",
+ search_mode=SearchMode.DIRECT,
+ )
+
+ status_cb = lambda *_args: None
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = _build_config(ingest, organization="organize")
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(archive_path, task, Event(), status_cb)
+
+ assert result is not None
+ author_dir = ingest / "Tester"
+ files = sorted(author_dir.glob("*.mp3"))
+ assert len(files) == 2
+ assert files[0].name == "Archive Audio - 01.mp3"
+ assert files[1].name == "Archive Audio - 02.mp3"
+
+
+def test_booklore_mode_uploads_and_cleans_staging(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ staging = tmp_path / "staging"
+ staging.mkdir()
+
+ temp_file = staging / "book.epub"
+ temp_file.write_text("content")
+
+ task = DownloadTask(
+ task_id="direct-booklore",
+ source="direct_download",
+ title="The Way of Kings",
+ author="Brandon Sanderson",
+ format="epub",
+ search_mode=SearchMode.DIRECT,
+ )
+
+ statuses = []
+ status_cb = lambda status, message: statuses.append((status, message))
+ uploaded_files = []
+
+ def _upload_stub(_config, _token, file_path):
+ uploaded_files.append(file_path)
+ assert file_path.exists()
+
+ booklore_values = {
+ "BOOKS_OUTPUT_MODE": "booklore",
+ "BOOKLORE_HOST": "http://booklore:6060",
+ "BOOKLORE_USERNAME": "booklore",
+ "BOOKLORE_PASSWORD": "secret",
+ "BOOKLORE_LIBRARY_ID": 1,
+ "BOOKLORE_PATH_ID": 2,
+ }
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.download.outputs.booklore.booklore_login", return_value="token"), \
+ patch("shelfmark.download.outputs.booklore.booklore_upload_file", side_effect=_upload_stub), \
+ patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: booklore_values.get(key, default))
+
+ result = _post_process_download(temp_file, task, Event(), status_cb)
+
+ assert result is not None
+ assert uploaded_files
+ assert not temp_file.exists()
+ assert list(staging.iterdir()) == []
+ assert any("Booklore" in (message or "") for _, message in statuses)
+
+
+def test_booklore_mode_rejects_unsupported_files(tmp_path):
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ staging = tmp_path / "staging"
+ staging.mkdir()
+
+ temp_file = staging / "book.mobi"
+ temp_file.write_text("content")
+
+ task = DownloadTask(
+ task_id="direct-booklore-unsupported",
+ source="direct_download",
+ title="Unsupported Book",
+ author="Tester",
+ format="mobi",
+ search_mode=SearchMode.DIRECT,
+ )
+
+ status_cb = MagicMock()
+
+ booklore_values = {
+ "BOOKS_OUTPUT_MODE": "booklore",
+ "BOOKLORE_HOST": "http://booklore:6060",
+ "BOOKLORE_USERNAME": "booklore",
+ "BOOKLORE_PASSWORD": "secret",
+ "BOOKLORE_LIBRARY_ID": 1,
+ "BOOKLORE_PATH_ID": 2,
+ }
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.download.outputs.booklore.booklore_login") as mock_login, \
+ patch("shelfmark.download.outputs.booklore.booklore_upload_file") as mock_upload, \
+ patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = MagicMock(side_effect=lambda key, default=None: booklore_values.get(key, default))
+
+ result = _post_process_download(temp_file, task, Event(), status_cb)
+
+ assert result is None
+ assert mock_login.call_count == 0
+ assert mock_upload.call_count == 0
+ assert not temp_file.exists()
+ assert list(staging.iterdir()) == []
+
+ errors = [call for call in status_cb.call_args_list if call.args[0] == "error"]
+ assert errors
+ assert "Booklore does not support" in errors[-1].args[1]
+
+
+@pytest.mark.parametrize("organization", ["none", "rename", "organize"])
+@pytest.mark.parametrize("input_kind", ["file", "directory", "archive"])
+@pytest.mark.parametrize("source_kind", ["direct", "usenet"])
+@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
+
+def test_postprocess_folder_blackbox_matrix(
+ tmp_path,
+ source_kind: str,
+ input_kind: str,
+ organization: str,
+ content_kind: str,
+):
+ """Black-box matrix test over common pipeline knobs.
+
+ Goals:
+ - Exercise the real `post_process_download` flow end-to-end
+ - Vary key knobs (source semantics, input shape, organization mode)
+ - Assert invariants (TMP cleanup, external source preservation)
+
+ This intentionally avoids mocking internal pipeline helpers.
+ """
+
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ downloads = tmp_path / "downloads"
+ staging.mkdir()
+ ingest.mkdir()
+ downloads.mkdir()
+
+ author = "Tester"
+ title = "Matrix Book"
+
+ if content_kind == "audiobook":
+ extension = "mp3"
+ content_type = "audiobook"
+ else:
+ extension = "epub"
+ content_type = None
+
+ task = DownloadTask(
+ task_id=f"matrix-{source_kind}-{input_kind}-{organization}-{content_kind}",
+ source="direct_download" if source_kind == "direct" else "prowlarr",
+ title=title,
+ author=author,
+ format=extension,
+ content_type=content_type,
+ search_mode=SearchMode.DIRECT,
+ original_download_path=None,
+ )
+
+ base_dir = staging if source_kind == "direct" else downloads
+
+ if input_kind == "file":
+ input_path = base_dir / f"random.{extension}"
+ input_path.write_text("content")
+ expected_original_name = input_path.name
+ elif input_kind == "directory":
+ input_path = base_dir / "release"
+ input_path.mkdir()
+ (input_path / f"random.{extension}").write_text("content")
+ expected_original_name = f"random.{extension}"
+ elif input_kind == "archive":
+ input_path = base_dir / "release.zip"
+ with zipfile.ZipFile(input_path, "w") as zf:
+ zf.writestr(f"book.{extension}", "content")
+ expected_original_name = f"book.{extension}"
+ else:
+ raise AssertionError(f"Unknown input_kind: {input_kind}")
+
+ status_cb = lambda *_args: None
+
+ supported_formats = [extension] if extension != "mp3" else ["epub"]
+ supported_audiobook_formats = [extension] if extension == "mp3" else ["mp3"]
+
+ with patch("shelfmark.core.config.config") as mock_config, patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = _build_config(
+ ingest,
+ organization=organization,
+ supported_formats=supported_formats,
+ supported_audiobook_formats=supported_audiobook_formats,
+ )
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(input_path, task, Event(), status_cb)
+
+ assert result is not None
+
+ result_path = Path(result)
+ assert result_path.exists()
+
+ if organization == "organize":
+ assert result_path.parent == ingest / author
+ assert result_path.name == f"{title}.{extension}"
+ elif organization == "rename":
+ assert result_path.parent == ingest
+ assert result_path.name == f"{author} - {title}.{extension}"
+ else:
+ assert result_path.parent == ingest
+ assert result_path.name == expected_original_name
+
+ # TMP workspace should be cleaned up fully.
+ assert list(staging.iterdir()) == []
+
+ # Source preservation depends on whether Shelfmark owns the workspace.
+ if source_kind == "direct":
+ assert not input_path.exists()
+ else:
+ assert input_path.exists()
+
+
+@pytest.mark.parametrize("input_kind", ["file", "directory"])
+@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
+@pytest.mark.parametrize("organization", ["none", "organize"])
+@pytest.mark.parametrize("hardlink_enabled", [False, True])
+@pytest.mark.parametrize("same_filesystem", [True, False])
+
+def test_postprocess_torrent_blackbox_matrix(
+ tmp_path,
+ input_kind: str,
+ content_kind: str,
+ organization: str,
+ hardlink_enabled: bool,
+ same_filesystem: bool,
+):
+ """Torrent-like (original_download_path set) black-box test matrix.
+
+ This exercises:
+ - hardlink enabled/disabled
+ - same-filesystem hardlink vs copy fallback
+ - content type differences (book vs audiobook)
+
+ Assertions focus on invariants:
+ - source is never deleted (seeding safety)
+ - output is imported with expected naming
+ - hardlink shares inode when expected
+ - TMP workspace stays clean
+ """
+
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ downloads = tmp_path / "downloads"
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ downloads.mkdir()
+ staging.mkdir()
+ ingest.mkdir()
+
+ author = "Tester"
+ title = "Torrent Matrix"
+
+ if content_kind == "audiobook":
+ extension = "mp3"
+ content_type = "audiobook"
+ supported_formats = ["epub"]
+ supported_audiobook_formats = ["mp3"]
+ else:
+ extension = "epub"
+ content_type = None
+ supported_formats = ["epub"]
+ supported_audiobook_formats = ["mp3"]
+
+ if input_kind == "file":
+ input_path = downloads / f"random.{extension}"
+ input_path.write_text("content")
+ source_file = input_path
+ else:
+ input_path = downloads / "release"
+ input_path.mkdir()
+ source_file = input_path / f"random.{extension}"
+ source_file.write_text("content")
+
+ task = DownloadTask(
+ task_id=f"torrent-matrix-{input_kind}-{content_kind}-{organization}-{hardlink_enabled}-{same_filesystem}",
+ source="prowlarr",
+ title=title,
+ author=author,
+ format=extension,
+ content_type=content_type,
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=str(input_path),
+ )
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", staging), \
+ patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=same_filesystem):
+ mock_config.get = _build_config(
+ ingest,
+ organization=organization,
+ hardlink=hardlink_enabled,
+ supported_formats=supported_formats,
+ supported_audiobook_formats=supported_audiobook_formats,
+ )
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(input_path, task, Event(), lambda *_args: None)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+
+ # Source must always remain for seeding.
+ assert input_path.exists()
+ assert source_file.exists()
+
+ if organization == "organize":
+ assert result_path.parent == ingest / author
+ assert result_path.name == f"{title}.{extension}"
+ else:
+ assert result_path.parent == ingest
+ assert result_path.name == f"random.{extension}"
+
+ # Hardlink only when enabled and same filesystem.
+ if hardlink_enabled and same_filesystem:
+ assert os.stat(source_file).st_ino == os.stat(result_path).st_ino
+ else:
+ assert os.stat(source_file).st_ino != os.stat(result_path).st_ino
+
+ # TMP workspace should be cleaned.
+ assert list(staging.iterdir()) == []
+
+
+
+def test_custom_script_external_source_stages_copy_and_preserves_source(tmp_path):
+ """External (usenet-like) files should be staged into TMP before a custom script runs."""
+
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ downloads = tmp_path / "downloads"
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ downloads.mkdir()
+ staging.mkdir()
+ ingest.mkdir()
+
+ original = downloads / "Seed.epub"
+ original.write_text("content")
+
+ task = DownloadTask(
+ task_id="usenet-custom-script",
+ source="prowlarr",
+ title="Seed",
+ author="Seeder",
+ format="epub",
+ search_mode=SearchMode.UNIVERSAL,
+ original_download_path=None,
+ )
+
+ with patch("shelfmark.core.config.config") as mock_config, \
+ patch("shelfmark.config.env.TMP_DIR", staging), \
+ patch("subprocess.run") as mock_run:
+ mock_config.get = _build_config(ingest, organization="none")
+ mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
+ _sync_config(mock_config, mock_config)
+
+ mock_run.return_value = MagicMock(stdout="", returncode=0)
+
+ result = _post_process_download(original, task, Event(), lambda *_args: None)
+
+ assert result is not None
+ result_path = Path(result)
+ assert result_path.exists()
+
+ # Original external file must be preserved.
+ assert original.exists()
+
+ # Script should have run against a staged copy inside TMP.
+ assert mock_run.call_count == 1
+ script_args = mock_run.call_args[0][0]
+ assert script_args[0] == "/path/to/script.sh"
+ staged_path = Path(script_args[1])
+ assert staging in staged_path.parents
+ assert staged_path != original
+
+ # Staging directory should be cleaned.
+ assert list(staging.iterdir()) == []
+
+
+
+@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
+
+def test_external_directory_multiple_archives_extracts_all_and_keeps_source(tmp_path, content_kind: str):
+ """External directories with only archives should extract into TMP and not touch source archives."""
+
+ # This case is meant to model a usenet-like client "completed" directory containing
+ # one or more archive releases, where Shelfmark must treat the source as read-only.
+
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ downloads = tmp_path / "downloads"
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ downloads.mkdir()
+ staging.mkdir()
+ ingest.mkdir()
+
+ source_dir = downloads / "release"
+ source_dir.mkdir()
+
+ if content_kind == "audiobook":
+ extension = "mp3"
+ content_type = "audiobook"
+ supported_formats = ["epub"]
+ supported_audiobook_formats = ["mp3"]
+ else:
+ extension = "epub"
+ content_type = None
+ supported_formats = ["epub"]
+ supported_audiobook_formats = ["mp3"]
+
+ archive_1 = source_dir / "a.zip"
+ archive_2 = source_dir / "b.zip"
+
+ with zipfile.ZipFile(archive_1, "w") as zf:
+ zf.writestr(f"a.{extension}", f"content-a-{extension}")
+ with zipfile.ZipFile(archive_2, "w") as zf:
+ zf.writestr(f"b.{extension}", f"content-b-{extension}")
+
+ task = DownloadTask(
+ task_id=f"usenet-dir-archives-{content_kind}",
+ source="prowlarr",
+ title="Ignored",
+ author="Ignored",
+ format=extension,
+ content_type=content_type,
+ search_mode=SearchMode.DIRECT,
+ original_download_path=None,
+ )
+
+ with patch("shelfmark.core.config.config") as mock_config, patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = _build_config(
+ ingest,
+ organization="none",
+ supported_formats=supported_formats,
+ supported_audiobook_formats=supported_audiobook_formats,
+ )
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(source_dir, task, Event(), lambda *_args: None)
+
+ assert result is not None
+
+ # Both archives remain in the external source directory.
+ assert archive_1.exists()
+ assert archive_2.exists()
+
+ # Extracted files should have been imported.
+ assert (ingest / f"a.{extension}").exists()
+ assert (ingest / f"b.{extension}").exists()
+
+ # TMP staging should be cleaned.
+ assert list(staging.iterdir()) == []
+
+
+@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
+
+def test_external_directory_prefers_files_over_archives_and_keeps_source(tmp_path, content_kind: str):
+ """If supported files exist in an external directory, archives are ignored.
+
+ This models a usenet-like client directory that contains both a usable file and
+ an archive. Shelfmark should import the usable file and leave the archive alone.
+ """
+
+ from shelfmark.download.postprocess.router import post_process_download as _post_process_download
+
+ downloads = tmp_path / "downloads"
+ staging = tmp_path / "staging"
+ ingest = tmp_path / "ingest"
+ downloads.mkdir()
+ staging.mkdir()
+ ingest.mkdir()
+
+ source_dir = downloads / "release"
+ source_dir.mkdir()
+
+ if content_kind == "audiobook":
+ extension = "mp3"
+ content_type = "audiobook"
+ supported_formats = ["epub"]
+ supported_audiobook_formats = ["mp3"]
+ else:
+ extension = "epub"
+ content_type = None
+ supported_formats = ["epub"]
+ supported_audiobook_formats = ["mp3"]
+
+ primary_file = source_dir / f"keep.{extension}"
+ primary_file.write_text("primary")
+
+ archive = source_dir / "extra.zip"
+ with zipfile.ZipFile(archive, "w") as zf:
+ zf.writestr(f"from_archive.{extension}", "archive")
+
+ task = DownloadTask(
+ task_id=f"usenet-dir-mixed-{content_kind}",
+ source="prowlarr",
+ title="Ignored",
+ author="Ignored",
+ format=extension,
+ content_type=content_type,
+ search_mode=SearchMode.DIRECT,
+ original_download_path=None,
+ )
+
+ with patch("shelfmark.core.config.config") as mock_config, patch("shelfmark.config.env.TMP_DIR", staging):
+ mock_config.get = _build_config(
+ ingest,
+ organization="none",
+ supported_formats=supported_formats,
+ supported_audiobook_formats=supported_audiobook_formats,
+ )
+ mock_config.CUSTOM_SCRIPT = None
+ _sync_config(mock_config, mock_config)
+
+ result = _post_process_download(source_dir, task, Event(), lambda *_args: None)
+
+ assert result is not None
+
+ # External source directory and files must remain untouched.
+ assert source_dir.exists()
+ assert primary_file.exists()
+ assert archive.exists()
+
+ # Import should use the existing supported file, not extract the archive.
+ assert (ingest / f"keep.{extension}").exists()
+ assert not (ingest / f"from_archive.{extension}").exists()
+
+ # TMP staging should be cleaned.
+ assert list(staging.iterdir()) == []
diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py
index 0a46ddc..476eed4 100644
--- a/tests/e2e/test_api.py
+++ b/tests/e2e/test_api.py
@@ -312,6 +312,29 @@ class TestReleaseDownloadFlow:
data = resp.json()
assert data.get("status") == "queued"
+ def test_cancel_release_with_slash_id(
+ self, api_client: APIClient, download_tracker: DownloadTracker
+ ):
+ """Cancelling/clearing should work for IDs containing slashes."""
+ test_id = "e2e-test-release/with-slash"
+
+ resp = api_client.post(
+ "/api/releases/download",
+ json={
+ "source": "test_source",
+ "source_id": test_id,
+ "title": "E2E Test Book",
+ },
+ )
+
+ if resp.status_code != 200:
+ pytest.skip("Release download endpoint not available")
+
+ download_tracker.track(test_id)
+
+ cancel_resp = api_client.delete(f"/api/download/{test_id}/cancel")
+ assert cancel_resp.status_code in [200, 204]
+
@pytest.mark.e2e
class TestReleasesSearch:
diff --git a/tests/prowlarr/test_failure_scenarios.py b/tests/prowlarr/test_failure_scenarios.py
index 66aa8d3..e04659b 100644
--- a/tests/prowlarr/test_failure_scenarios.py
+++ b/tests/prowlarr/test_failure_scenarios.py
@@ -13,7 +13,6 @@ from threading import Event, Thread
from typing import List, Optional, Tuple
from unittest.mock import MagicMock, patch, PropertyMock
import tempfile
-import shutil
import pytest
@@ -217,8 +216,7 @@ class TestClientErrorStates:
assert result is None
assert recorder.had_error
assert "Tracker returned error" in recorder.last_message
- assert mock_client.remove_called
- assert mock_client.remove_with_delete # Should delete files on error
+ assert not mock_client.remove_called
def test_client_returns_error_with_complete_flag(
self, handler, mock_client, recorder, cancel_flag, sample_task, sample_release
@@ -229,7 +227,7 @@ class TestClientErrorStates:
progress=100,
state=DownloadState.ERROR,
message="Download corrupted",
- complete=True, # Complete but errored
+ complete=True,
file_path=None,
),
]
@@ -250,6 +248,8 @@ class TestClientErrorStates:
assert result is None
assert recorder.had_error
+ assert recorder.last_message == "Download corrupted"
+ assert not mock_client.remove_called
def test_error_without_message_uses_default(
self, handler, mock_client, recorder, cancel_flag, sample_task, sample_release
@@ -259,7 +259,7 @@ class TestClientErrorStates:
DownloadStatus(
progress=0,
state=DownloadState.ERROR,
- message=None, # No message
+ message=None,
complete=False,
file_path=None,
),
@@ -282,6 +282,7 @@ class TestClientErrorStates:
assert result is None
assert recorder.had_error
assert recorder.last_message == "Download failed"
+ assert not mock_client.remove_called
# =============================================================================
@@ -435,8 +436,7 @@ class TestCancellation:
assert result is None
assert "cancelled" in recorder.statuses
- assert mock_client.remove_called
- assert mock_client.remove_with_delete
+ assert not mock_client.remove_called
def test_cancel_before_download_starts(
self, handler, mock_client, recorder, sample_task, sample_release
@@ -461,7 +461,7 @@ class TestCancellation:
assert result is None
# Should have been cancelled quickly
- assert mock_client.remove_called
+ assert not mock_client.remove_called
# =============================================================================
@@ -581,12 +581,10 @@ class TestFileHandlingFailures:
assert recorder.had_error
assert "locate" in recorder.last_message.lower()
- def test_permission_denied_on_move(
+ def test_usenet_returns_original_path(
self, handler, mock_client, recorder, cancel_flag, sample_task
):
- """Handler should report permission errors during file staging (usenet only - torrents skip staging)."""
- # Use usenet protocol - torrents skip staging and return original path directly
- # Default usenet action is "move", so we mock shutil.move
+ """Usenet downloads return the original client path without staging."""
usenet_release = {
"guid": "test-task-123",
"title": "Test Book",
@@ -607,8 +605,6 @@ class TestFileHandlingFailures:
with tempfile.TemporaryDirectory() as tmpdir:
source_file = Path(tmpdir) / "source.epub"
source_file.write_text("test content")
- staging_dir = Path(tmpdir) / "staging"
- staging_dir.mkdir()
mock_client.get_download_path = lambda x: str(source_file)
@@ -619,11 +615,12 @@ class TestFileHandlingFailures:
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
- "shelfmark.release_sources.prowlarr.handler.shutil.move",
- side_effect=PermissionError("Permission denied"),
+ "shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
- "shelfmark.download.orchestrator.get_staging_dir",
- return_value=staging_dir,
+ "shelfmark.download.staging.get_staging_dir",
+ ) as mock_get_staging, patch(
+ "shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
+ 0.01,
):
result = handler.download(
task=sample_task,
@@ -632,9 +629,9 @@ class TestFileHandlingFailures:
status_callback=recorder.status_callback,
)
- assert result is None
- assert recorder.had_error
- assert "permission" in recorder.last_message.lower()
+ assert result == str(source_file)
+ assert not recorder.had_error
+ mock_get_staging.assert_not_called()
# =============================================================================
@@ -675,7 +672,7 @@ class TestProgressCallbacks:
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
- "shelfmark.download.orchestrator.get_staging_dir",
+ "shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -761,7 +758,7 @@ class TestStatusMessages:
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
- "shelfmark.download.orchestrator.get_staging_dir",
+ "shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -820,7 +817,7 @@ class TestStatusMessages:
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
- "shelfmark.download.orchestrator.get_staging_dir",
+ "shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -851,7 +848,7 @@ class TestErrorCleanup:
def test_cleanup_on_poll_exception(
self, handler, mock_client, recorder, cancel_flag, sample_task, sample_release
):
- """Download should be removed from client after polling exception."""
+ """Torrent downloads should not be removed after polling exception."""
call_count = 0
def exploding_get_status(download_id):
@@ -887,8 +884,7 @@ class TestErrorCleanup:
)
assert result is None
- assert mock_client.remove_called
- assert mock_client.remove_with_delete
+ assert not mock_client.remove_called
def test_cleanup_continues_even_if_remove_fails(
self, handler, mock_client, recorder, cancel_flag, sample_task, sample_release
@@ -904,14 +900,22 @@ class TestErrorCleanup:
),
]
+ remove_attempted = False
+
def failing_remove(download_id, delete_files=False):
+ nonlocal remove_attempted
+ remove_attempted = True
raise ConnectionError("Client not responding")
mock_client.remove = failing_remove
+ usenet_release = dict(sample_release)
+ usenet_release["protocol"] = "usenet"
+ usenet_release["downloadUrl"] = "https://indexer.example.com/download/123"
+
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
- return_value=sample_release,
+ return_value=usenet_release,
), patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
@@ -926,3 +930,4 @@ class TestErrorCleanup:
assert result is None
assert recorder.had_error
+ assert remove_attempted
diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py
index 5762daf..b8801e0 100644
--- a/tests/prowlarr/test_handler.py
+++ b/tests/prowlarr/test_handler.py
@@ -242,7 +242,7 @@ class TestProwlarrHandlerExistingDownload:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
- "shelfmark.download.orchestrator.get_staging_dir",
+ "shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
):
handler = ProwlarrHandler()
@@ -321,7 +321,7 @@ class TestProwlarrHandlerPolling:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
- "shelfmark.download.orchestrator.get_staging_dir",
+ "shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -392,14 +392,14 @@ class TestProwlarrHandlerPolling:
assert result is None
assert recorder.last_status == "error"
- mock_client.remove.assert_called_once()
+ mock_client.remove.assert_not_called()
class TestProwlarrHandlerCancellation:
"""Tests for download cancellation."""
- def test_cancellation_removes_download(self):
- """Test that cancellation removes the download from client."""
+ def test_cancellation_does_not_remove_torrent(self):
+ """Test that torrent cancellation does not remove from client."""
mock_client = MagicMock()
mock_client.name = "test_client"
mock_client.find_existing.return_value = None
@@ -446,7 +446,7 @@ class TestProwlarrHandlerCancellation:
assert result is None
assert "cancelled" in recorder.statuses
- mock_client.remove.assert_called_with("download_id", delete_files=True)
+ mock_client.remove.assert_not_called()
class TestProwlarrHandlerCancel:
@@ -512,7 +512,7 @@ class TestProwlarrHandlerFileStaging:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
- "shelfmark.download.orchestrator.get_staging_dir",
+ "shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -575,7 +575,7 @@ class TestProwlarrHandlerFileStaging:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
- "shelfmark.download.orchestrator.get_staging_dir",
+ "shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -604,7 +604,7 @@ class TestProwlarrHandlerFileStaging:
assert (staged_dir / "cover.jpg").exists()
def test_handles_duplicate_filename(self):
- """Test handling of duplicate filename during staging (usenet only - torrents skip staging)."""
+ """Usenet downloads return the original file path (no staging)."""
with tempfile.TemporaryDirectory() as tmp_dir:
source_file = Path(tmp_dir) / "source" / "book.epub"
source_file.parent.mkdir(parents=True)
@@ -641,7 +641,7 @@ class TestProwlarrHandlerFileStaging:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
- "shelfmark.download.orchestrator.get_staging_dir",
+ "shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -664,8 +664,35 @@ class TestProwlarrHandlerFileStaging:
)
assert result is not None
- # Should have a different name (with counter)
- staged_file = Path(result)
- assert staged_file.exists()
- assert staged_file.name != "book.epub"
- assert staged_file.read_text() == "new content"
+ returned_file = Path(result)
+ assert returned_file == source_file
+ assert returned_file.exists()
+ assert returned_file.read_text() == "new content"
+
+
+class TestProwlarrHandlerPostProcessCleanup:
+ def test_usenet_move_triggers_client_cleanup(self):
+ handler = ProwlarrHandler()
+ task = DownloadTask(task_id="cleanup-test", source="prowlarr", title="Test")
+
+ mock_client = MagicMock()
+ mock_client.name = "nzbget"
+ handler._cleanup_refs[task.task_id] = (mock_client, "123", "usenet")
+
+ with patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value="move"):
+ handler.post_process_cleanup(task, success=True)
+
+ mock_client.remove.assert_called_once_with("123", delete_files=True)
+
+ def test_usenet_copy_does_not_cleanup(self):
+ handler = ProwlarrHandler()
+ task = DownloadTask(task_id="cleanup-test", source="prowlarr", title="Test")
+
+ mock_client = MagicMock()
+ mock_client.name = "nzbget"
+ handler._cleanup_refs[task.task_id] = (mock_client, "123", "usenet")
+
+ with patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value="copy"):
+ handler.post_process_cleanup(task, success=True)
+
+ mock_client.remove.assert_not_called()
diff --git a/tests/prowlarr/test_integration_clients.py b/tests/prowlarr/test_integration_clients.py
index 426f4ae..263cde4 100644
--- a/tests/prowlarr/test_integration_clients.py
+++ b/tests/prowlarr/test_integration_clients.py
@@ -55,9 +55,8 @@ def _setup_deluge_config():
save_config_file("prowlarr_clients", {
"PROWLARR_TORRENT_CLIENT": "deluge",
"DELUGE_HOST": "deluge",
- "DELUGE_PORT": "58846",
- "DELUGE_USERNAME": "admin",
- "DELUGE_PASSWORD": "admin",
+ "DELUGE_PORT": "8112",
+ "DELUGE_PASSWORD": "deluge",
"DELUGE_CATEGORY": "test",
})
config.refresh()
@@ -408,8 +407,8 @@ class TestQBittorrentIntegration:
class TestDelugeIntegration:
"""Integration tests for Deluge client.
- Uses the Docker test stack's Deluge instance (deluge:58846).
- Default credentials from auth file: admin/admin
+ Uses the Docker test stack's Deluge Web UI instance (http://deluge:8112).
+ Default password: deluge
"""
def test_test_connection(self, deluge_client):
diff --git a/tests/prowlarr/test_integration_failures.py b/tests/prowlarr/test_integration_failures.py
index e5f756c..33a131c 100644
--- a/tests/prowlarr/test_integration_failures.py
+++ b/tests/prowlarr/test_integration_failures.py
@@ -59,9 +59,8 @@ def _setup_deluge_config():
save_config_file("prowlarr_clients", {
"PROWLARR_TORRENT_CLIENT": "deluge",
"DELUGE_HOST": "deluge",
- "DELUGE_PORT": "58846",
- "DELUGE_USERNAME": "admin",
- "DELUGE_PASSWORD": "admin",
+ "DELUGE_PORT": "8112",
+ "DELUGE_PASSWORD": "deluge",
"DELUGE_CATEGORY": "test",
})
config.refresh()
diff --git a/tests/prowlarr/test_nzbget_client.py b/tests/prowlarr/test_nzbget_client.py
index 853c0d8..100fb14 100644
--- a/tests/prowlarr/test_nzbget_client.py
+++ b/tests/prowlarr/test_nzbget_client.py
@@ -593,3 +593,40 @@ class TestNZBGetClientRemove:
# Test with delete_files=False
client.remove("456", delete_files=False)
assert calls[-1][1][0] == "GroupDelete"
+
+ def test_remove_falls_back_to_history_delete(self, monkeypatch):
+ """If HistoryFinalDelete is unsupported, fall back to HistoryDelete (Sonarr behavior)."""
+ config_values = {
+ "NZBGET_URL": "http://localhost:6789",
+ "NZBGET_USERNAME": "nzbget",
+ "NZBGET_PASSWORD": "password",
+ "NZBGET_CATEGORY": "Books",
+ }
+ monkeypatch.setattr(
+ "shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
+ lambda key, default="": config_values.get(key, default),
+ )
+
+ calls = []
+
+ def mock_rpc_call(method, params=None):
+ if method == "editqueue":
+ calls.append((method, params))
+ # Succeed only on HistoryDelete.
+ return params is not None and params[0] == "HistoryDelete"
+ return None
+
+ from shelfmark.release_sources.prowlarr.clients.nzbget import NZBGetClient
+
+ with patch.object(NZBGetClient, "__init__", lambda x: None):
+ client = NZBGetClient()
+ client.url = "http://localhost:6789"
+ client.username = "nzbget"
+ client.password = "password"
+ client._category = "Books"
+ client._rpc_call = mock_rpc_call
+
+ result = client.remove("123", delete_files=True)
+
+ assert result is True
+ assert [call[1][0] for call in calls] == ["GroupFinalDelete", "HistoryFinalDelete", "HistoryDelete"]
diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py
index 4df87dc..7291afd 100644
--- a/tests/prowlarr/test_source.py
+++ b/tests/prowlarr/test_source.py
@@ -8,11 +8,13 @@ import pytest
# Import the functions to test
from shelfmark.release_sources.prowlarr.source import (
+ ProwlarrSource,
_parse_size,
_extract_format,
_extract_language,
)
from shelfmark.release_sources.prowlarr.utils import get_protocol_display
+from shelfmark.metadata_providers import BookMetadata
class TestParseSize:
@@ -220,3 +222,85 @@ class TestExtractLanguage:
assert _extract_language("Book [GERMAN]") == "de"
assert _extract_language("Book [german]") == "de"
assert _extract_language("Book [German]") == "de"
+
+
+class TestProwlarrLocalizedQueries:
+ def test_search_uses_localized_titles_when_available(self, monkeypatch):
+ class FakeClient:
+ def __init__(self):
+ self.queries: list[str] = []
+
+ def search(self, query: str, indexer_ids=None, categories=None):
+ self.queries.append(query)
+ return []
+
+ import shelfmark.release_sources.prowlarr.source as prowlarr_source
+
+ def fake_get(key: str, default=None):
+ values = {
+ "PROWLARR_INDEXERS": "",
+ "PROWLARR_AUTO_EXPAND": False,
+ }
+ return values.get(key, default)
+
+ monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
+
+ fake_client = FakeClient()
+ source = ProwlarrSource()
+ monkeypatch.setattr(source, "_get_client", lambda: fake_client)
+
+ book = BookMetadata(
+ provider="hardcover",
+ provider_id="219252",
+ title="The Lightning Thief",
+ authors=["Rick Riordan"],
+ titles_by_language={"hu": "A villámtolvaj"},
+ )
+
+ source.search(book, languages=["en", "hu"], content_type="ebook")
+
+ assert "The Lightning Thief Rick Riordan" in fake_client.queries
+ assert "A villámtolvaj Rick Riordan" in fake_client.queries
+ assert len(fake_client.queries) == 2
+
+ def test_search_does_not_override_search_title_for_english(self, monkeypatch):
+ class FakeClient:
+ def __init__(self):
+ self.queries: list[str] = []
+
+ def search(self, query: str, indexer_ids=None, categories=None):
+ self.queries.append(query)
+ return []
+
+ import shelfmark.release_sources.prowlarr.source as prowlarr_source
+
+ def fake_get(key: str, default=None):
+ values = {
+ "PROWLARR_INDEXERS": "",
+ "PROWLARR_AUTO_EXPAND": False,
+ }
+ return values.get(key, default)
+
+ monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
+
+ fake_client = FakeClient()
+ source = ProwlarrSource()
+ monkeypatch.setattr(source, "_get_client", lambda: fake_client)
+
+ book = BookMetadata(
+ provider="hardcover",
+ provider_id="123",
+ title="Mistborn: The Final Empire",
+ search_title="The Final Empire",
+ authors=["Brandon Sanderson"],
+ titles_by_language={
+ "en": "Mistborn: The Final Empire",
+ "hu": "A végső birodalom",
+ },
+ )
+
+ source.search(book, languages=["en", "hu"], content_type="ebook")
+
+ assert "The Final Empire Brandon Sanderson" in fake_client.queries
+ assert "A végső birodalom Brandon Sanderson" in fake_client.queries
+ assert "Mistborn: The Final Empire Brandon Sanderson" not in fake_client.queries