""" Tests for library processing mode - ebook and audiobook routing with different configurations. These tests verify that the orchestrator correctly routes content based on: - PROCESSING_MODE (books): ingest vs library - PROCESSING_MODE_AUDIOBOOK: ingest vs library - LIBRARY_PATH / LIBRARY_PATH_AUDIOBOOK paths - LIBRARY_TEMPLATE / LIBRARY_TEMPLATE_AUDIOBOOK templates - INGEST_DIR / INGEST_DIR_AUDIOBOOK directories """ import pytest import tempfile import shutil import os from pathlib import Path from unittest.mock import MagicMock, patch from shelfmark.core.models import DownloadTask, SearchMode from shelfmark.core.naming import build_library_path, assign_part_numbers from shelfmark.core.utils import is_audiobook class MockConfig: """Mock config for testing with configurable values.""" def __init__(self, **kwargs): self._values = { # Default values "PROCESSING_MODE": "ingest", "PROCESSING_MODE_AUDIOBOOK": "ingest", "LIBRARY_PATH": "", "LIBRARY_PATH_AUDIOBOOK": "", "LIBRARY_TEMPLATE": "{Author}/{Title}", "LIBRARY_TEMPLATE_AUDIOBOOK": "{Author}/{Title}", "INGEST_DIR_AUDIOBOOK": "", "TORRENT_HARDLINK": True, "USE_BOOK_TITLE": True, "SUPPORTED_FORMATS": ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"], "SUPPORTED_AUDIOBOOK_FORMATS": ["m4b", "mp3"], } self._values.update(kwargs) def get(self, key, default=None): return self._values.get(key, default) def __getattr__(self, name): if name.startswith('_'): raise AttributeError(name) return self._values.get(name) class TestConfigurationScenarios: """Test different configuration combinations for books and audiobooks.""" def test_both_ingest_mode_default(self): """Default config: both books and audiobooks use ingest mode.""" config = MockConfig() assert config.get("PROCESSING_MODE") == "ingest" assert config.get("PROCESSING_MODE_AUDIOBOOK") == "ingest" def test_books_library_audiobooks_ingest(self): """Books use library mode, audiobooks use ingest mode.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH="/books", LIBRARY_TEMPLATE="{Author}/{Series/}{Title}", PROCESSING_MODE_AUDIOBOOK="ingest", INGEST_DIR_AUDIOBOOK="/audiobooks/ingest", ) assert config.get("PROCESSING_MODE") == "library" assert config.get("LIBRARY_PATH") == "/books" assert config.get("PROCESSING_MODE_AUDIOBOOK") == "ingest" assert config.get("INGEST_DIR_AUDIOBOOK") == "/audiobooks/ingest" def test_books_ingest_audiobooks_library(self): """Books use ingest mode, audiobooks use library mode.""" config = MockConfig( PROCESSING_MODE="ingest", PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK="/audiobooks", LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title} - Part {PartNumber}", ) assert config.get("PROCESSING_MODE") == "ingest" assert config.get("PROCESSING_MODE_AUDIOBOOK") == "library" assert config.get("LIBRARY_PATH_AUDIOBOOK") == "/audiobooks" def test_both_library_different_paths(self): """Both books and audiobooks in library mode with different paths.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH="/media/books", LIBRARY_TEMPLATE="{Author}/{Title} ({Year})", PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK="/media/audiobooks", LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Series/}{Title}", ) assert config.get("LIBRARY_PATH") == "/media/books" assert config.get("LIBRARY_PATH_AUDIOBOOK") == "/media/audiobooks" assert config.get("LIBRARY_TEMPLATE") == "{Author}/{Title} ({Year})" assert config.get("LIBRARY_TEMPLATE_AUDIOBOOK") == "{Author}/{Series/}{Title}" class TestContentTypeDetection: """Test content type detection and routing logic.""" def test_detect_audiobook_content_type(self): """Verify audiobook detection from content_type field.""" task = DownloadTask( task_id="test-1", source="prowlarr", title="The Way of Kings", author="Brandon Sanderson", content_type="Audiobook", search_mode=SearchMode.UNIVERSAL, ) assert is_audiobook(task.content_type) def test_detect_book_content_type(self): """Verify ebook detection from content_type field.""" task = DownloadTask( task_id="test-2", source="direct_download", title="Dune", author="Frank Herbert", content_type="book (fiction)", search_mode=SearchMode.UNIVERSAL, ) assert not is_audiobook(task.content_type) def test_empty_content_type_defaults_to_book(self): """Empty content_type should be treated as a book.""" task = DownloadTask( task_id="test-3", source="prowlarr", title="Unknown Book", content_type=None, search_mode=SearchMode.UNIVERSAL, ) assert not is_audiobook(task.content_type) class TestLibraryPathBuilding: """Test library path construction for different content types.""" @pytest.fixture def temp_dirs(self): """Create temporary directories for testing.""" books_dir = tempfile.mkdtemp(prefix="test_books_") audiobooks_dir = tempfile.mkdtemp(prefix="test_audiobooks_") ingest_dir = tempfile.mkdtemp(prefix="test_ingest_") yield { "books": Path(books_dir), "audiobooks": Path(audiobooks_dir), "ingest": Path(ingest_dir), } shutil.rmtree(books_dir, ignore_errors=True) shutil.rmtree(audiobooks_dir, ignore_errors=True) shutil.rmtree(ingest_dir, ignore_errors=True) def test_book_library_path_simple(self, temp_dirs): """Test simple book library path.""" template = "{Author}/{Title}" metadata = { "Author": "Brandon Sanderson", "Title": "Mistborn", } path = build_library_path( str(temp_dirs["books"]), template, metadata, extension="epub" ) expected = (temp_dirs["books"] / "Brandon Sanderson" / "Mistborn.epub").resolve() assert path == expected def test_audiobook_library_path_with_part_number(self, temp_dirs): """Test audiobook library path with part number.""" template = "{Author}/{Title} - Part {PartNumber}" metadata = { "Author": "Brandon Sanderson", "Title": "The Way of Kings", "PartNumber": "01", } path = build_library_path( str(temp_dirs["audiobooks"]), template, metadata, extension="mp3" ) expected = (temp_dirs["audiobooks"] / "Brandon Sanderson" / "The Way of Kings - Part 01.mp3").resolve() assert path == expected def test_book_with_series_folder(self, temp_dirs): """Test book with series creating nested folder.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Brandon Sanderson", "Series": "Stormlight Archive", "Title": "The Way of Kings", } path = build_library_path( str(temp_dirs["books"]), template, metadata, extension="epub" ) expected = (temp_dirs["books"] / "Brandon Sanderson" / "Stormlight Archive" / "The Way of Kings.epub").resolve() assert path == expected def test_audiobook_with_series_and_parts(self, temp_dirs): """Test audiobook with series folder and part numbers.""" template = "{Author}/{Series/}{Title} - Part {PartNumber}" base_metadata = { "Author": "Brandon Sanderson", "Series": "Stormlight Archive", "Title": "The Way of Kings", } # Build paths for multiple parts paths = [] for part_num in ["01", "02", "03"]: metadata = {**base_metadata, "PartNumber": part_num} path = build_library_path( str(temp_dirs["audiobooks"]), template, metadata, extension="mp3" ) paths.append(path) # All paths should be in the same directory assert all(p.parent == paths[0].parent for p in paths) # Check the directory structure assert "Stormlight Archive" in str(paths[0]) assert "Part 01" in str(paths[0]) assert "Part 02" in str(paths[1]) assert "Part 03" in str(paths[2]) def test_different_templates_same_metadata(self, temp_dirs): """Same book metadata produces different paths with different templates.""" metadata = { "Author": "Frank Herbert", "Title": "Dune", "Year": 1965, "Series": "Dune Chronicles", "SeriesPosition": 1, } # Book template (simple) book_path = build_library_path( str(temp_dirs["books"]), "{Author}/{Title}", metadata, extension="epub" ) # Audiobook template (more elaborate) audiobook_path = build_library_path( str(temp_dirs["audiobooks"]), "{Author}/{Series/}{SeriesPosition - }{Title}", metadata, extension="m4b" ) # Verify different structures assert book_path.parent.name == "Frank Herbert" assert audiobook_path.parent.parent.name == "Frank Herbert" assert "Dune Chronicles" in str(audiobook_path) assert "1 - Dune" in str(audiobook_path) class TestMixedModeProcessing: """Test scenarios with different processing modes for books vs audiobooks.""" @pytest.fixture def temp_dirs(self): """Create temporary directories for testing.""" books_lib = tempfile.mkdtemp(prefix="test_books_lib_") audiobooks_lib = tempfile.mkdtemp(prefix="test_audiobooks_lib_") books_ingest = tempfile.mkdtemp(prefix="test_books_ingest_") audiobooks_ingest = tempfile.mkdtemp(prefix="test_audiobooks_ingest_") yield { "books_lib": Path(books_lib), "audiobooks_lib": Path(audiobooks_lib), "books_ingest": Path(books_ingest), "audiobooks_ingest": Path(audiobooks_ingest), } for d in [books_lib, audiobooks_lib, books_ingest, audiobooks_ingest]: shutil.rmtree(d, ignore_errors=True) def test_books_library_audiobooks_ingest_routing(self, temp_dirs): """Books to library, audiobooks to ingest - verify path selection.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH=str(temp_dirs["books_lib"]), LIBRARY_TEMPLATE="{Author}/{Title}", PROCESSING_MODE_AUDIOBOOK="ingest", INGEST_DIR_AUDIOBOOK=str(temp_dirs["audiobooks_ingest"]), ) # Create book task book_task = DownloadTask( task_id="book-1", source="prowlarr", title="Dune", author="Frank Herbert", content_type="book (fiction)", search_mode=SearchMode.UNIVERSAL, ) # Create audiobook task audiobook_task = DownloadTask( task_id="audiobook-1", source="prowlarr", title="Dune", author="Frank Herbert", content_type="Audiobook", search_mode=SearchMode.UNIVERSAL, ) # Determine paths based on content type is_audiobook_book = "audiobook" in (book_task.content_type or "").lower() is_audiobook_audio = "audiobook" in (audiobook_task.content_type or "").lower() assert not is_audiobook_book assert is_audiobook_audio # Simulate path selection if is_audiobook_book: book_processing = config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") else: book_processing = config.get("PROCESSING_MODE", "ingest") if is_audiobook_audio: audio_processing = config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") else: audio_processing = config.get("PROCESSING_MODE", "ingest") assert book_processing == "library" assert audio_processing == "ingest" def test_books_ingest_audiobooks_library_routing(self, temp_dirs): """Books to ingest, audiobooks to library - verify path selection.""" config = MockConfig( PROCESSING_MODE="ingest", PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK=str(temp_dirs["audiobooks_lib"]), LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Series/}{Title}", ) # Create tasks book_task = DownloadTask( task_id="book-2", source="prowlarr", title="Project Hail Mary", author="Andy Weir", content_type="book (fiction)", search_mode=SearchMode.UNIVERSAL, ) audiobook_task = DownloadTask( task_id="audiobook-2", source="prowlarr", title="Project Hail Mary", author="Andy Weir", content_type="Audiobook", search_mode=SearchMode.UNIVERSAL, ) # Determine processing modes is_audiobook_book = "audiobook" in (book_task.content_type or "").lower() is_audiobook_audio = "audiobook" in (audiobook_task.content_type or "").lower() if is_audiobook_book: book_processing = config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") else: book_processing = config.get("PROCESSING_MODE", "ingest") if is_audiobook_audio: audio_processing = config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") else: audio_processing = config.get("PROCESSING_MODE", "ingest") assert book_processing == "ingest" assert audio_processing == "library" # Build audiobook library path audiobook_path = build_library_path( config.get("LIBRARY_PATH_AUDIOBOOK"), config.get("LIBRARY_TEMPLATE_AUDIOBOOK"), {"Author": audiobook_task.author, "Title": audiobook_task.title}, extension="m4b" ) assert "Andy Weir" in str(audiobook_path) assert "Project Hail Mary" in str(audiobook_path) class TestAudiobookPartNumberAssignment: """Test sequential part number assignment for multi-file audiobooks. Uses Readarr's approach: natural sort files then assign sequential numbers. """ def test_assign_part_numbers_sorted(self): """Files should be naturally sorted and assigned sequential numbers.""" files = [ Path("The Way of Kings - Part 03.mp3"), Path("The Way of Kings - Part 01.mp3"), Path("The Way of Kings - Part 02.mp3"), ] result = assign_part_numbers(files) assert result[0] == (Path("The Way of Kings - Part 01.mp3"), "01") assert result[1] == (Path("The Way of Kings - Part 02.mp3"), "02") assert result[2] == (Path("The Way of Kings - Part 03.mp3"), "03") def test_natural_sort_handles_double_digits(self): """Numbers sort naturally (2 before 10).""" files = [ Path("Track 10.mp3"), Path("Track 2.mp3"), Path("Track 1.mp3"), ] result = assign_part_numbers(files) assert result[0][0].name == "Track 1.mp3" assert result[1][0].name == "Track 2.mp3" assert result[2][0].name == "Track 10.mp3" def test_problematic_titles_no_false_positives(self): """Titles with numbers (like Fahrenheit 451) don't cause issues.""" files = [ Path("Fahrenheit 451 - Part 2.mp3"), Path("Fahrenheit 451 - Part 1.mp3"), ] result = assign_part_numbers(files) # Files sorted correctly, get sequential numbers assert result[0] == (Path("Fahrenheit 451 - Part 1.mp3"), "01") assert result[1] == (Path("Fahrenheit 451 - Part 2.mp3"), "02") def test_part_number_in_template(self): """Test PartNumber token in audiobook template.""" template = "{Author}/{Title} - Part {PartNumber}" metadata = { "Author": "Brandon Sanderson", "Title": "Oathbringer", "PartNumber": "01", } path = build_library_path("/audiobooks", template, metadata, extension="mp3") assert "Part 01" in str(path) assert path.name == "Oathbringer - Part 01.mp3" def test_conditional_part_number(self): """Test conditional part number inclusion.""" template = "{Author}/{Title}{ - Part }{PartNumber}" # With part number with_part = build_library_path( "/audiobooks", template, {"Author": "Author", "Title": "Book", "PartNumber": "01"}, extension="mp3" ) # Without part number (single file audiobook) without_part = build_library_path( "/audiobooks", template, {"Author": "Author", "Title": "Book", "PartNumber": None}, extension="m4b" ) # The conditional suffix only appears when PartNumber has a value # Note: The template { - Part } includes the literal text, and {PartNumber} # is separate, so we need to adjust expectations assert "Book.m4b" in str(without_part) or "Book - Part.m4b" not in str(without_part) class TestFilesystemOperations: """Test actual file operations for library mode.""" @pytest.fixture def temp_setup(self): """Create temp directories with test files.""" staging = tempfile.mkdtemp(prefix="test_staging_") books_lib = tempfile.mkdtemp(prefix="test_books_") audiobooks_lib = tempfile.mkdtemp(prefix="test_audiobooks_") # Create a test epub file epub_file = Path(staging) / "test_book.epub" epub_file.write_text("fake epub content") # Create test mp3 files (multi-part audiobook) for i in range(3): mp3_file = Path(staging) / f"Test Audiobook - Part 0{i+1}.mp3" mp3_file.write_text(f"fake mp3 content part {i+1}") yield { "staging": Path(staging), "books_lib": Path(books_lib), "audiobooks_lib": Path(audiobooks_lib), "epub_file": epub_file, } shutil.rmtree(staging, ignore_errors=True) shutil.rmtree(books_lib, ignore_errors=True) shutil.rmtree(audiobooks_lib, ignore_errors=True) def test_move_book_to_library(self, temp_setup): """Test moving a book file to library structure.""" metadata = { "Author": "Brandon Sanderson", "Title": "The Final Empire", "Series": "Mistborn", } template = "{Author}/{Series/}{Title}" dest_path = build_library_path( str(temp_setup["books_lib"]), template, metadata, extension="epub" ) # Create the directory structure dest_path.parent.mkdir(parents=True, exist_ok=True) # Move the file shutil.move(str(temp_setup["epub_file"]), str(dest_path)) # Verify assert dest_path.exists() assert dest_path.name == "The Final Empire.epub" assert "Mistborn" in str(dest_path.parent) assert "Brandon Sanderson" in str(dest_path) def test_move_multipart_audiobook_to_library(self, temp_setup): """Test moving multi-part audiobook to library structure.""" metadata = { "Author": "Brandon Sanderson", "Title": "Words of Radiance", "Series": "Stormlight Archive", } template = "{Author}/{Series/}{Title} - Part {PartNumber}" # Get all mp3 files from staging mp3_files = list(temp_setup["staging"].glob("*.mp3")) assert len(mp3_files) == 3 # Use assign_part_numbers for natural sort + sequential numbering files_with_parts = assign_part_numbers(mp3_files) moved_files = [] for mp3_file, part_num in files_with_parts: file_metadata = {**metadata, "PartNumber": part_num} dest_path = build_library_path( str(temp_setup["audiobooks_lib"]), template, file_metadata, extension="mp3" ) dest_path.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(mp3_file), str(dest_path)) moved_files.append(dest_path) # Verify all files moved assert all(f.exists() for f in moved_files) # All should be in the same parent directory assert len(set(f.parent for f in moved_files)) == 1 # Check naming - files are sorted then assigned sequential numbers assert "Part 01" in str(moved_files[0]) assert "Part 02" in str(moved_files[1]) assert "Part 03" in str(moved_files[2]) class TestFallbackBehavior: """Test fallback behavior when library mode is misconfigured.""" def test_library_mode_no_path_fallback(self): """Library mode without path should fall back to ingest.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH="", # Empty path LIBRARY_TEMPLATE="{Author}/{Title}", ) # Check the condition that triggers fallback library_path = config.get("LIBRARY_PATH") should_fallback = not library_path assert should_fallback def test_audiobook_library_mode_no_path_fallback(self): """Audiobook library mode without path should fall back to ingest.""" config = MockConfig( PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK="", # Empty path LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title}", ) library_path = config.get("LIBRARY_PATH_AUDIOBOOK") should_fallback = not library_path assert should_fallback def test_audiobook_library_path_fallback_to_book_path(self): """Audiobook should fall back to book library path if audiobook path not set.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH="/books", LIBRARY_TEMPLATE="{Author}/{Title}", PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK="", # Empty - should fall back LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title}", ) # Simulate the fallback logic from orchestrator audiobook_path = config.get("LIBRARY_PATH_AUDIOBOOK") or config.get("LIBRARY_PATH") assert audiobook_path == "/books" class TestDirectModeBypass: """Test that Direct mode bypasses library processing.""" def test_direct_mode_ignores_library_settings(self): """Direct mode should use ingest regardless of library settings.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH="/books", LIBRARY_TEMPLATE="{Author}/{Title}", ) task = DownloadTask( task_id="direct-1", source="direct_download", title="Test Book", content_type="book (fiction)", search_mode=SearchMode.DIRECT, # Direct mode ) # In Direct mode, library settings should be ignored is_universal = task.search_mode == SearchMode.UNIVERSAL # The orchestrator only applies library mode for Universal assert not is_universal # Therefore library_mode check would return False in orchestrator class TestSearchModeValidation: """Test search mode validation in download tasks.""" def test_universal_mode_enables_library(self): """Universal mode should enable library processing.""" task = DownloadTask( task_id="universal-1", source="prowlarr", title="Test Book", search_mode=SearchMode.UNIVERSAL, ) is_universal = task.search_mode == SearchMode.UNIVERSAL assert is_universal def test_direct_mode_disables_library(self): """Direct mode should disable library processing.""" task = DownloadTask( task_id="direct-2", source="direct_download", title="Test Book", search_mode=SearchMode.DIRECT, ) is_universal = task.search_mode == SearchMode.UNIVERSAL assert not is_universal def test_none_mode_treated_as_direct(self): """None search mode should be treated as Direct (safe default).""" task = DownloadTask( task_id="none-1", source="direct_download", title="Test Book", search_mode=None, ) # None is not Universal, so library mode should not apply is_universal = task.search_mode == SearchMode.UNIVERSAL assert not is_universal class TestHardlinkSupport: """Test hardlink configuration for torrent downloads.""" def test_hardlink_enabled_for_torrents(self): """Hardlinking should be enabled by default for torrents.""" config = MockConfig() assert config.get("TORRENT_HARDLINK", True) is True def test_hardlink_disabled(self): """Hardlinking can be disabled.""" config = MockConfig(TORRENT_HARDLINK=False) assert config.get("TORRENT_HARDLINK", True) is False def test_task_with_original_path(self): """Task should support original_download_path for hardlinking.""" task = DownloadTask( task_id="torrent-1", source="prowlarr", title="Test Book", original_download_path="/downloads/completed/test-book.epub", search_mode=SearchMode.UNIVERSAL, ) assert task.original_download_path is not None assert "/downloads" in task.original_download_path class TestTemplateFallbacks: """Test template and path fallback behaviors.""" def test_audiobook_template_fallback_to_book_template(self): """When audiobook template is empty, should fall back to book template.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH="/books", LIBRARY_TEMPLATE="{Author}/{Title}", PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK="/audiobooks", LIBRARY_TEMPLATE_AUDIOBOOK="", # Empty - should fallback ) # Simulate fallback logic audiobook_template = config.get("LIBRARY_TEMPLATE_AUDIOBOOK") or config.get("LIBRARY_TEMPLATE", "{Author}/{Title}") assert audiobook_template == "{Author}/{Title}" def test_audiobook_both_fallback_to_book(self): """When both audiobook path and template are empty, fallback to book settings.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH="/media/books", LIBRARY_TEMPLATE="{Author}/{Series/}{Title}", PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK="", # Empty LIBRARY_TEMPLATE_AUDIOBOOK="", # Empty ) # Simulate fallback logic audiobook_path = config.get("LIBRARY_PATH_AUDIOBOOK") or config.get("LIBRARY_PATH") audiobook_template = config.get("LIBRARY_TEMPLATE_AUDIOBOOK") or config.get("LIBRARY_TEMPLATE") assert audiobook_path == "/media/books" assert audiobook_template == "{Author}/{Series/}{Title}" def test_audiobook_custom_template_with_fallback_path(self): """Custom audiobook template but fallback to book path.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH="/media/all_content", LIBRARY_TEMPLATE="{Author}/{Title}", PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK="", # Use book path LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title} - Part {PartNumber}", # Custom ) audiobook_path = config.get("LIBRARY_PATH_AUDIOBOOK") or config.get("LIBRARY_PATH") audiobook_template = config.get("LIBRARY_TEMPLATE_AUDIOBOOK") or config.get("LIBRARY_TEMPLATE") # Same path, different template assert audiobook_path == "/media/all_content" assert audiobook_template == "{Author}/{Title} - Part {PartNumber}" class TestComplexMetadataScenarios: """Test complex metadata scenarios with series and part numbers.""" @pytest.fixture def temp_dirs(self): """Create temporary directories.""" base = tempfile.mkdtemp(prefix="test_complex_") dirs = { "books": Path(base) / "books", "audiobooks": Path(base) / "audiobooks", } for d in dirs.values(): d.mkdir(parents=True) yield dirs shutil.rmtree(base, ignore_errors=True) def test_series_with_position_and_part_numbers(self, temp_dirs): """Test audiobook with series position AND part numbers.""" template = "{Author}/{Series/}{SeriesPosition - }{Title} - Part {PartNumber}" metadata = { "Author": "Brandon Sanderson", "Series": "Stormlight Archive", "SeriesPosition": 2, "Title": "Words of Radiance", "PartNumber": "01", } path = build_library_path( str(temp_dirs["audiobooks"]), template, metadata, extension="mp3" ) # Should produce: Author/Series/2 - Title - Part 01.mp3 assert "Brandon Sanderson" in str(path) assert "Stormlight Archive" in str(path) assert "2 - Words of Radiance - Part 01" in str(path) def test_novella_position_format(self, temp_dirs): """Test novella with fractional series position (e.g., 1.5).""" template = "{Author}/{Series/}{SeriesPosition - }{Title}" metadata = { "Author": "Brandon Sanderson", "Series": "Stormlight Archive", "SeriesPosition": 2.5, # Novella between books 2 and 3 "Title": "Edgedancer", } path = build_library_path( str(temp_dirs["books"]), template, metadata, extension="epub" ) assert "2.5 - Edgedancer" in str(path) def test_series_without_position(self, temp_dirs): """Test series book without position.""" template = "{Author}/{Series/}{SeriesPosition - }{Title}" metadata = { "Author": "Brandon Sanderson", "Series": "Cosmere", "SeriesPosition": None, # Unknown position "Title": "Elantris", } path = build_library_path( str(temp_dirs["books"]), template, metadata, extension="epub" ) # Should omit position: Author/Series/Title.epub assert "Cosmere" in str(path) assert "Elantris.epub" in str(path) assert " - Elantris" not in str(path) # No dangling separator def test_standalone_no_series(self, temp_dirs): """Test standalone book with no series.""" template = "{Author}/{Series/}{SeriesPosition - }{Title}" metadata = { "Author": "Andy Weir", "Series": None, "SeriesPosition": None, "Title": "Project Hail Mary", } path = build_library_path( str(temp_dirs["books"]), template, metadata, extension="epub" ) # Should be: Author/Title.epub (no series folder) assert path.parent.name == "Andy Weir" assert path.name == "Project Hail Mary.epub" class TestConcurrentContentProcessing: """Test processing multiple content types simultaneously.""" @pytest.fixture def temp_setup(self): """Create a realistic test environment.""" base = tempfile.mkdtemp(prefix="test_concurrent_") dirs = { "staging": Path(base) / "staging", "books_lib": Path(base) / "books_lib", "audiobooks_lib": Path(base) / "audiobooks_lib", "ingest": Path(base) / "ingest", } for d in dirs.values(): d.mkdir(parents=True) # Create test files (dirs["staging"] / "test.epub").write_text("epub") (dirs["staging"] / "test.m4b").write_text("m4b") (dirs["staging"] / "audiobook_part_01.mp3").write_text("mp3-1") (dirs["staging"] / "audiobook_part_02.mp3").write_text("mp3-2") yield dirs shutil.rmtree(base, ignore_errors=True) def test_process_book_and_audiobook_simultaneously(self, temp_setup): """Process an ebook and audiobook at the same time with different modes.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH=str(temp_setup["books_lib"]), LIBRARY_TEMPLATE="{Author}/{Title}", PROCESSING_MODE_AUDIOBOOK="ingest", INGEST_DIR_AUDIOBOOK=str(temp_setup["ingest"]), ) # Book task (library mode) book = DownloadTask( task_id="book-1", source="prowlarr", title="Foundation", author="Isaac Asimov", content_type="book (fiction)", search_mode=SearchMode.UNIVERSAL, ) # Audiobook task (ingest mode) audiobook = DownloadTask( task_id="audio-1", source="prowlarr", title="Foundation", author="Isaac Asimov", content_type="Audiobook", search_mode=SearchMode.UNIVERSAL, ) # Determine processing for each def get_processing_mode(task): is_audiobook = "audiobook" in (task.content_type or "").lower() if is_audiobook: return config.get("PROCESSING_MODE_AUDIOBOOK", "ingest") return config.get("PROCESSING_MODE", "ingest") book_mode = get_processing_mode(book) audiobook_mode = get_processing_mode(audiobook) assert book_mode == "library" assert audiobook_mode == "ingest" # Process book to library book_path = build_library_path( config.get("LIBRARY_PATH"), config.get("LIBRARY_TEMPLATE"), {"Author": book.author, "Title": book.title}, extension="epub" ) book_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy(str(temp_setup["staging"] / "test.epub"), str(book_path)) # Process audiobook to ingest audiobook_dest = Path(config.get("INGEST_DIR_AUDIOBOOK")) / "test.m4b" shutil.copy(str(temp_setup["staging"] / "test.m4b"), str(audiobook_dest)) # Verify both processed correctly assert book_path.exists() assert "Isaac Asimov" in str(book_path) assert audiobook_dest.exists() assert audiobook_dest.parent == Path(config.get("INGEST_DIR_AUDIOBOOK")) def test_same_title_different_formats_different_locations(self, temp_setup): """Same book as ebook and audiobook going to different locations.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH=str(temp_setup["books_lib"]), LIBRARY_TEMPLATE="{Author}/{Title}", PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK=str(temp_setup["audiobooks_lib"]), LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Title}", ) metadata = { "Author": "Isaac Asimov", "Title": "Foundation", } # Same book as ebook book_path = build_library_path( config.get("LIBRARY_PATH"), config.get("LIBRARY_TEMPLATE"), metadata, extension="epub" ) # Same book as audiobook audiobook_path = build_library_path( config.get("LIBRARY_PATH_AUDIOBOOK"), config.get("LIBRARY_TEMPLATE_AUDIOBOOK"), metadata, extension="m4b" ) # Different base paths, same structure assert str(temp_setup["books_lib"]) in str(book_path) assert str(temp_setup["audiobooks_lib"]) in str(audiobook_path) assert book_path.name == "Foundation.epub" assert audiobook_path.name == "Foundation.m4b" class TestEmptyFieldHandling: """Test template behavior when fields are empty, None, or missing.""" @pytest.fixture def temp_dir(self): """Create temporary directory.""" d = tempfile.mkdtemp(prefix="test_empty_") yield Path(d) shutil.rmtree(d, ignore_errors=True) # === SERIES FIELD EMPTY === def test_series_folder_not_created_when_series_none(self, temp_dir): """Series folder should NOT be created when Series is None.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Brandon Sanderson", "Series": None, "Title": "Warbreaker", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should be Author/Title.epub (no series folder) assert path.parent.name == "Brandon Sanderson" assert path.name == "Warbreaker.epub" assert "Series" not in str(path) def test_series_folder_not_created_when_series_empty_string(self, temp_dir): """Series folder should NOT be created when Series is empty string.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Brandon Sanderson", "Series": "", "Title": "Warbreaker", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.parent.name == "Brandon Sanderson" assert path.name == "Warbreaker.epub" def test_series_folder_not_created_when_series_whitespace(self, temp_dir): """Series folder should NOT be created when Series is whitespace.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Brandon Sanderson", "Series": " ", "Title": "Warbreaker", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.parent.name == "Brandon Sanderson" assert path.name == "Warbreaker.epub" def test_series_folder_not_created_when_series_missing(self, temp_dir): """Series folder should NOT be created when Series key is missing.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Brandon Sanderson", "Title": "Warbreaker", # Series key not present } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.parent.name == "Brandon Sanderson" assert path.name == "Warbreaker.epub" def test_series_position_omitted_when_series_empty(self, temp_dir): """Series position should be omitted when series is empty.""" template = "{Author}/{Series/}{SeriesPosition - }{Title}" metadata = { "Author": "Andy Weir", "Series": None, "SeriesPosition": None, "Title": "Project Hail Mary", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # No series folder, no position prefix assert path.parent.name == "Andy Weir" assert path.name == "Project Hail Mary.epub" assert " - " not in path.name def test_series_with_position_but_no_series_name(self, temp_dir): """When position exists but series name is empty, omit both.""" template = "{Author}/{Series/}{SeriesPosition - }{Title}" metadata = { "Author": "Andy Weir", "Series": "", # Empty series "SeriesPosition": 1, # But has position "Title": "The Martian", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Series folder should NOT be created even though position exists # Position prefix might still appear (debatable behavior) assert "The Martian" in path.name # === AUTHOR FIELD EMPTY === def test_author_empty_falls_back_to_unknown(self, temp_dir): """When author is empty, should use 'Unknown Author' or skip.""" template = "{Author}/{Title}" metadata = { "Author": None, "Title": "Mystery Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should still create a valid path assert path.name == "Mystery Book.epub" # The parent might be the base dir if Author is omitted entirely # or might be "Unknown" - depends on implementation def test_author_empty_string(self, temp_dir): """When author is empty string.""" template = "{Author}/{Title}" metadata = { "Author": "", "Title": "Mystery Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "Mystery Book.epub" in str(path) def test_author_whitespace_only(self, temp_dir): """When author is whitespace only.""" template = "{Author}/{Title}" metadata = { "Author": " ", "Title": "Mystery Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "Mystery Book.epub" in str(path) # === TITLE FIELD EMPTY === def test_title_empty_uses_fallback(self, temp_dir): """When title is empty, path should still be valid.""" template = "{Author}/{Title}" metadata = { "Author": "Test Author", "Title": None, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should create some valid path assert path.suffix == ".epub" def test_title_empty_string(self, temp_dir): """When title is empty string.""" template = "{Author}/{Title}" metadata = { "Author": "Test Author", "Title": "", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.suffix == ".epub" # === YEAR FIELD EMPTY === def test_year_empty_omits_parentheses(self, temp_dir): """Year empty should not leave dangling parentheses.""" template = "{Author}/{Title} ({Year})" metadata = { "Author": "Test Author", "Title": "Test Book", "Year": None, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should not have empty parentheses assert "()" not in path.name assert "( )" not in path.name def test_year_zero_handled(self, temp_dir): """Year of 0 should be treated as missing.""" template = "{Author}/{Title} ({Year})" metadata = { "Author": "Test Author", "Title": "Test Book", "Year": 0, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # 0 might be treated as falsy and omitted # or might appear as "(0)" - depends on implementation def test_year_as_string(self, temp_dir): """Year as string should work.""" template = "{Author}/{Title} ({Year})" metadata = { "Author": "Test Author", "Title": "Test Book", "Year": "2024", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "(2024)" in path.name # === SUBTITLE FIELD EMPTY === def test_subtitle_empty_no_separator(self, temp_dir): """Empty subtitle should not leave dangling separator.""" template = "{Author}/{Title}{ - Subtitle}" metadata = { "Author": "Test Author", "Title": "Main Title", "Subtitle": None, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.name == "Main Title.epub" assert " - " not in path.name def test_subtitle_empty_string_no_separator(self, temp_dir): """Empty string subtitle should not leave dangling separator.""" template = "{Author}/{Title}{ - Subtitle}" metadata = { "Author": "Test Author", "Title": "Main Title", "Subtitle": "", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert " - " not in path.name # No dangling " - " def test_subtitle_with_value(self, temp_dir): """Subtitle with value should include separator.""" template = "{Author}/{Title}{ - Subtitle}" metadata = { "Author": "Test Author", "Title": "Main Title", "Subtitle": "A Subtitle", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "Main Title - A Subtitle.epub" == path.name # === PART NUMBER FIELD EMPTY === def test_part_number_empty_no_part_text(self, temp_dir): """Empty part number should not show 'Part' text.""" template = "{Author}/{Title}{ - Part }{PartNumber}" metadata = { "Author": "Test Author", "Title": "Audiobook", "PartNumber": None, } path = build_library_path(str(temp_dir), template, metadata, extension="m4b") assert "Part" not in path.name assert path.name == "Audiobook.m4b" def test_part_number_zero(self, temp_dir): """Part number of 0 - might be valid or treated as missing.""" template = "{Author}/{Title} - Part {PartNumber}" metadata = { "Author": "Test Author", "Title": "Audiobook", "PartNumber": "0", } path = build_library_path(str(temp_dir), template, metadata, extension="m4b") # "0" is a valid part number assert "Part 0" in path.name or "Part" not in path.name # === MULTIPLE EMPTY FIELDS === def test_all_optional_fields_empty(self, temp_dir): """All optional fields empty - only required fields present.""" template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle} ({Year})" metadata = { "Author": "Test Author", "Title": "Test Book", "Series": None, "SeriesPosition": None, "Subtitle": None, "Year": None, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should just be Author/Title.epub assert path.parent.name == "Test Author" assert path.name == "Test Book.epub" assert "Series" not in str(path) assert " - " not in path.name assert "()" not in path.name def test_only_title_present(self, temp_dir): """Only title present, everything else empty.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": None, "Series": None, "Title": "Orphan Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "Orphan Book.epub" in str(path) def test_complex_template_all_empty_except_required(self, temp_dir): """Complex template with all optional fields empty.""" # Note: Parentheses around Year are NOT conditional - they always appear # To make them conditional, use the suffix syntax: {Year )} won't work either # Best approach: just use {Year} and accept parentheses are always there, or # use a simpler template template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle}{ - Part }{PartNumber}" metadata = { "Author": "Author Name", "Title": "Book Title", "Series": None, "SeriesPosition": None, "Subtitle": None, "Year": None, "PartNumber": None, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should be clean: Author Name/Book Title.epub assert path.name == "Book Title.epub" assert path.parent.name == "Author Name" class TestFolderCreationEdgeCases: """Test folder creation with various edge cases.""" @pytest.fixture def temp_dir(self): """Create temporary directory.""" d = tempfile.mkdtemp(prefix="test_folders_") yield Path(d) shutil.rmtree(d, ignore_errors=True) def test_nested_series_creates_all_folders(self, temp_dir): """Creating nested folder structure.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Brandon Sanderson", "Series": "Cosmere/Stormlight Archive", # Nested! "Title": "The Way of Kings", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") path.parent.mkdir(parents=True, exist_ok=True) # Note: slash in series name might be sanitized to underscore # or might create actual nested folders - depends on implementation assert path.parent.exists() or True # Check what actually happens def test_author_with_special_chars_in_folder(self, temp_dir): """Author name with special characters creates valid folder.""" template = "{Author}/{Title}" metadata = { "Author": "Author: The Great?", # Has invalid chars "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") path.parent.mkdir(parents=True, exist_ok=True) assert path.parent.exists() # Folder name should be sanitized assert ":" not in path.parent.name assert "?" not in path.parent.name def test_series_with_special_chars_in_folder(self, temp_dir): """Series name with special characters creates valid folder.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Author", "Series": "Series: Volume 1?", "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") path.parent.mkdir(parents=True, exist_ok=True) assert path.parent.exists() def test_very_long_author_name_truncated(self, temp_dir): """Very long author name should be truncated for folder.""" template = "{Author}/{Title}" metadata = { "Author": "A" * 300, # 300 char author name "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Folder name should be truncated to filesystem limit assert len(path.parent.name) <= 255 def test_very_long_series_name_truncated(self, temp_dir): """Very long series name should be truncated for folder.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Author", "Series": "S" * 300, "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # All path components should be valid lengths def test_unicode_author_creates_folder(self, temp_dir): """Unicode author name creates valid folder.""" template = "{Author}/{Title}" metadata = { "Author": "村上春樹", # Haruki Murakami in Japanese "Title": "Norwegian Wood", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") path.parent.mkdir(parents=True, exist_ok=True) assert path.parent.exists() assert "村上春樹" in str(path) def test_unicode_series_creates_folder(self, temp_dir): """Unicode series name creates valid folder.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Author", "Series": "Série Française", # French with accent "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") path.parent.mkdir(parents=True, exist_ok=True) assert path.parent.exists() def test_mixed_empty_and_present_folder_levels(self, temp_dir): """Some folder levels present, some empty.""" template = "{Author}/{Series/}{Subseries/}{Title}" metadata = { "Author": "Author", "Series": "Main Series", "Subseries": None, # Empty middle level "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should skip empty Subseries folder assert "Main Series" in str(path) def test_dots_in_folder_names(self, temp_dir): """Folder names with dots should work.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Dr. Author Ph.D.", "Series": "Vol. 1", "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") path.parent.mkdir(parents=True, exist_ok=True) assert path.parent.exists() def test_leading_dots_in_folder_stripped(self, temp_dir): """Leading dots in folder names might be stripped (hidden files).""" template = "{Author}/{Series/}{Title}" metadata = { "Author": ".Hidden Author", "Series": "..Series", "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Leading dots might be stripped to avoid hidden folders # or might be preserved - depends on implementation def test_trailing_dots_in_folder_stripped(self, temp_dir): """Trailing dots in folder names should be stripped (Windows issue).""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Author...", "Series": "Series.", "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Trailing dots can cause issues on Windows def test_reserved_windows_names_handled(self, temp_dir): """Reserved Windows names (CON, PRN, etc.) should be handled.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "CON", # Reserved on Windows "Series": "PRN", # Reserved on Windows "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should handle reserved names somehow class TestConditionalTemplateTokens: """Test conditional token syntax behavior.""" @pytest.fixture def temp_dir(self): """Create temporary directory.""" d = tempfile.mkdtemp(prefix="test_conditional_") yield Path(d) shutil.rmtree(d, ignore_errors=True) # === CONDITIONAL PREFIX SYNTAX {prefix Token} === def test_conditional_prefix_with_value(self, temp_dir): """Conditional prefix appears when value present.""" template = "{Author}/{SeriesPosition - }{Title}" metadata = { "Author": "Author", "SeriesPosition": 1, "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "1 - Book" in path.name def test_conditional_prefix_without_value(self, temp_dir): """Conditional prefix hidden when value empty.""" template = "{Author}/{SeriesPosition - }{Title}" metadata = { "Author": "Author", "SeriesPosition": None, "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.name == "Book.epub" assert " - " not in path.name # === CONDITIONAL SUFFIX SYNTAX {Token suffix} === def test_conditional_suffix_with_value(self, temp_dir): """Conditional suffix appears when value present.""" template = "{Author}/{Title}{ - Subtitle}" metadata = { "Author": "Author", "Title": "Main", "Subtitle": "Sub", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.name == "Main - Sub.epub" def test_conditional_suffix_without_value(self, temp_dir): """Conditional suffix hidden when value empty.""" template = "{Author}/{Title}{ - Subtitle}" metadata = { "Author": "Author", "Title": "Main", "Subtitle": None, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.name == "Main.epub" # === FOLDER CONDITIONAL SYNTAX {Token/} === def test_folder_conditional_with_value(self, temp_dir): """Folder created when value present.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Author", "Series": "My Series", "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.parent.name == "My Series" assert path.parent.parent.name == "Author" def test_folder_conditional_without_value(self, temp_dir): """Folder NOT created when value empty.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Author", "Series": None, "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.parent.name == "Author" # === PARENTHETICAL CONDITIONALS === def test_year_in_parentheses_with_value(self, temp_dir): """Year in parentheses shown when present.""" template = "{Author}/{Title} ({Year})" metadata = { "Author": "Author", "Title": "Book", "Year": 2024, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "(2024)" in path.name def test_year_in_parentheses_without_value(self, temp_dir): """Year in parentheses - empty parentheses should not appear.""" template = "{Author}/{Title} ({Year})" metadata = { "Author": "Author", "Title": "Book", "Year": None, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should NOT have empty parentheses assert "()" not in path.name # But might have " ()" or just "Book.epub" # === COMBINED CONDITIONALS === def test_multiple_conditionals_all_present(self, temp_dir): """Multiple conditional tokens, all have values.""" template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle} ({Year})" metadata = { "Author": "Author", "Series": "Series", "SeriesPosition": 1, "Title": "Title", "Subtitle": "Subtitle", "Year": 2024, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "Series" in str(path.parent) assert "1 - Title - Subtitle (2024).epub" == path.name def test_multiple_conditionals_none_present(self, temp_dir): """Multiple conditional tokens, none have values.""" template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle} ({Year})" metadata = { "Author": "Author", "Series": None, "SeriesPosition": None, "Title": "Title", "Subtitle": None, "Year": None, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.parent.name == "Author" assert path.name == "Title.epub" def test_multiple_conditionals_mixed(self, temp_dir): """Multiple conditional tokens, some present some not.""" template = "{Author}/{Series/}{SeriesPosition - }{Title}{ - Subtitle} ({Year})" metadata = { "Author": "Author", "Series": "Series", # Present "SeriesPosition": None, # Missing "Title": "Title", "Subtitle": "Subtitle", # Present "Year": None, # Missing } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "Series" in str(path.parent) assert path.name == "Title - Subtitle.epub" assert " - Title" not in path.name # No SeriesPosition prefix class TestAudiobookSpecificScenarios: """Test audiobook-specific scenarios.""" @pytest.fixture def temp_dir(self): """Create temporary directory.""" d = tempfile.mkdtemp(prefix="test_audiobook_") yield Path(d) shutil.rmtree(d, ignore_errors=True) def test_single_file_audiobook_no_part(self, temp_dir): """Single file audiobook should not have part number.""" template = "{Author}/{Title}{ - Part }{PartNumber}" metadata = { "Author": "Author", "Title": "Short Audiobook", "PartNumber": None, # Single file, no part } path = build_library_path(str(temp_dir), template, metadata, extension="m4b") assert path.name == "Short Audiobook.m4b" assert "Part" not in path.name def test_multi_part_audiobook_consistent_paths(self, temp_dir): """All parts of audiobook should go to same folder.""" template = "{Author}/{Series/}{Title} - Part {PartNumber}" base_metadata = { "Author": "Brandon Sanderson", "Series": "Stormlight Archive", "Title": "The Way of Kings", } paths = [] for part in ["01", "02", "03", "04", "05"]: metadata = {**base_metadata, "PartNumber": part} path = build_library_path(str(temp_dir), template, metadata, extension="mp3") paths.append(path) # All parts should be in the same directory parents = set(p.parent for p in paths) assert len(parents) == 1 # Each part should have correct name assert "Part 01" in str(paths[0]) assert "Part 05" in str(paths[4]) def test_audiobook_with_series_no_position(self, temp_dir): """Audiobook in series but position unknown.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Patrick Rothfuss", "Series": "Kingkiller Chronicle", "SeriesPosition": None, "Title": "The Name of the Wind", } path = build_library_path(str(temp_dir), template, metadata, extension="m4b") assert "Kingkiller Chronicle" in str(path) assert path.name == "The Name of the Wind.m4b" def test_audiobook_standalone_no_series(self, temp_dir): """Standalone audiobook with no series.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Andy Weir", "Series": None, "Title": "The Martian", } path = build_library_path(str(temp_dir), template, metadata, extension="m4b") assert path.parent.name == "Andy Weir" assert path.name == "The Martian.m4b" def test_audiobook_narrator_in_path(self, temp_dir): """Audiobook with narrator in template (if supported).""" template = "{Author}/{Title} (narrated by {Narrator})" metadata = { "Author": "Andy Weir", "Title": "The Martian", "Narrator": "R.C. Bray", } path = build_library_path(str(temp_dir), template, metadata, extension="m4b") # If Narrator token is supported if "Narrator" in str(path): assert "narrated by R.C. Bray" in path.name class TestRealWorldNamingScenarios: """Test real-world naming scenarios users would encounter.""" @pytest.fixture def temp_dir(self): """Create temporary directory.""" d = tempfile.mkdtemp(prefix="test_realworld_") yield Path(d) shutil.rmtree(d, ignore_errors=True) def test_plex_audiobook_naming(self, temp_dir): """Plex-style audiobook naming: Author/Book/Book.m4b""" template = "{Author}/{Title}/{Title}" metadata = { "Author": "Brandon Sanderson", "Title": "Mistborn", } path = build_library_path(str(temp_dir), template, metadata, extension="m4b") assert "Brandon Sanderson/Mistborn/Mistborn.m4b" in str(path).replace("\\", "/") def test_audiobookshelf_naming(self, temp_dir): """Audiobookshelf-style: Author/Series/Book""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "Brandon Sanderson", "Series": "Mistborn", "Title": "The Final Empire", } path = build_library_path(str(temp_dir), template, metadata, extension="m4b") parts = str(path).replace("\\", "/").split("/") assert "Brandon Sanderson" in parts assert "Mistborn" in parts assert "The Final Empire.m4b" in parts[-1] def test_calibre_style_naming(self, temp_dir): """Calibre-style: Author/Title (ID)/Title.epub""" # This requires an ID field which may not be supported template = "{Author}/{Title}/{Title}" metadata = { "Author": "Frank Herbert", "Title": "Dune", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "Frank Herbert/Dune/Dune.epub" in str(path).replace("\\", "/") def test_simple_flat_naming(self, temp_dir): """Simple flat structure: Author - Title.epub""" template = "{Author} - {Title}" metadata = { "Author": "Frank Herbert", "Title": "Dune", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.name == "Frank Herbert - Dune.epub" assert path.parent == temp_dir.resolve() def test_year_based_organization(self, temp_dir): """Year-based: Year/Author/Title.epub""" template = "{Year}/{Author}/{Title}" metadata = { "Year": 1965, "Author": "Frank Herbert", "Title": "Dune", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "1965/Frank Herbert/Dune.epub" in str(path).replace("\\", "/") def test_series_position_with_leading_zero(self, temp_dir): """Series position with leading zero: 01 - Title""" template = "{Author}/{Series/}{SeriesPosition - }{Title}" metadata = { "Author": "Author", "Series": "Series", "SeriesPosition": 1, "Title": "First Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Position might be "1 -" or "01 -" depending on implementation assert "First Book" in path.name def test_multiauthor_book(self, temp_dir): """Book with multiple authors.""" template = "{Author}/{Title}" metadata = { "Author": "Neil Gaiman & Terry Pratchett", "Title": "Good Omens", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Ampersand might be preserved or sanitized assert "Good Omens.epub" == path.name def test_book_with_colon_in_title(self, temp_dir): """Book with colon in title (common in subtitles).""" template = "{Author}/{Title}" metadata = { "Author": "Author", "Title": "Main Title: The Subtitle", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Colon should be sanitized (invalid on Windows) assert ":" not in path.name def test_book_with_numbers_in_title(self, temp_dir): """Book with numbers in title.""" template = "{Author}/{Title}" metadata = { "Author": "George Orwell", "Title": "1984", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.name == "1984.epub" def test_anthology_naming(self, temp_dir): """Anthology with editor instead of author.""" template = "{Author}/{Title}" metadata = { "Author": "Various Authors (Ed. John Smith)", "Title": "Best SF Stories 2024", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert "Best SF Stories 2024.epub" == path.name class TestEdgeCasesAndBoundaries: """Test edge cases and boundary conditions.""" @pytest.fixture def temp_dir(self): """Create temporary directory.""" d = tempfile.mkdtemp(prefix="test_edge_") yield Path(d) shutil.rmtree(d, ignore_errors=True) def test_all_fields_none(self, temp_dir): """All metadata fields are None.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": None, "Series": None, "Title": None, } # Should handle gracefully, not crash try: path = build_library_path(str(temp_dir), template, metadata, extension="epub") # If it succeeds, should have some valid path assert path.suffix == ".epub" except ValueError: # Or might raise an error for completely empty metadata pass def test_all_fields_empty_string(self, temp_dir): """All metadata fields are empty strings.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "", "Series": "", "Title": "", } try: path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.suffix == ".epub" except ValueError: pass def test_metadata_with_extra_fields(self, temp_dir): """Metadata with extra fields not in template.""" template = "{Author}/{Title}" metadata = { "Author": "Author", "Title": "Book", "ISBN": "1234567890", "Publisher": "Big Publisher", "RandomField": "Random Value", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Extra fields should be ignored assert path.name == "Book.epub" assert "ISBN" not in str(path) def test_template_with_unknown_token(self, temp_dir): """Template with token not in metadata.""" template = "{Author}/{UnknownToken}/{Title}" metadata = { "Author": "Author", "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Unknown token should be handled (skipped or empty) def test_template_with_literal_braces(self, temp_dir): """Template with literal curly braces (escaped).""" # This tests if there's a way to escape braces template = "{Author}/{{Not A Token}}/{Title}" metadata = { "Author": "Author", "Title": "Book", } try: path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Behavior depends on implementation except Exception: pass # Might not support escaped braces def test_extremely_nested_path(self, temp_dir): """Very deeply nested folder structure.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "A/B/C/D", # Slashes in author name "Series": "Series", "Title": "Book", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Slashes in field values should be sanitized def test_path_component_exactly_255_chars(self, temp_dir): """Path component at exactly filesystem limit.""" template = "{Title}" metadata = { "Title": "A" * 255, # Exactly 255 chars } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Might need to truncate to make room for extension def test_total_path_very_long(self, temp_dir): """Total path approaching filesystem limits.""" template = "{Author}/{Series/}{Title}" metadata = { "Author": "A" * 200, "Series": "S" * 200, "Title": "T" * 200, } path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should handle gracefully def test_numeric_string_values(self, temp_dir): """Metadata values that are numeric strings.""" template = "{Author}/{Title}" metadata = { "Author": "123", "Title": "456", } path = build_library_path(str(temp_dir), template, metadata, extension="epub") assert path.name == "456.epub" assert path.parent.name == "123" def test_boolean_metadata_values(self, temp_dir): """Metadata values that are booleans (unusual but possible).""" template = "{Author}/{Title}" metadata = { "Author": True, # Boolean value "Title": "Book", } try: path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should convert to string except (TypeError, ValueError): pass # Or might fail def test_list_metadata_values(self, temp_dir): """Metadata values that are lists (e.g., multiple authors).""" template = "{Author}/{Title}" metadata = { "Author": ["Author 1", "Author 2"], # List value "Title": "Book", } try: path = build_library_path(str(temp_dir), template, metadata, extension="epub") # Should convert to string somehow except (TypeError, ValueError): pass # Or might fail class TestIntegration: """Integration tests combining multiple scenarios.""" @pytest.fixture def full_setup(self): """Create a complete test environment.""" base = tempfile.mkdtemp(prefix="test_integration_") dirs = { "books_lib": Path(base) / "books_library", "audiobooks_lib": Path(base) / "audiobooks_library", "books_ingest": Path(base) / "books_ingest", "audiobooks_ingest": Path(base) / "audiobooks_ingest", "staging": Path(base) / "staging", } for d in dirs.values(): d.mkdir(parents=True) yield dirs shutil.rmtree(base, ignore_errors=True) def test_full_workflow_books_library_audiobooks_ingest(self, full_setup): """Complete workflow: books to library, audiobooks to ingest.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH=str(full_setup["books_lib"]), LIBRARY_TEMPLATE="{Author}/{Series/}{SeriesPosition - }{Title}", PROCESSING_MODE_AUDIOBOOK="ingest", INGEST_DIR_AUDIOBOOK=str(full_setup["audiobooks_ingest"]), ) # Create test files book_file = full_setup["staging"] / "test_book.epub" book_file.write_text("epub content") audiobook_file = full_setup["staging"] / "test_audiobook.m4b" audiobook_file.write_text("m4b content") # Book task book_task = DownloadTask( task_id="book-int-1", source="prowlarr", title="The Final Empire", author="Brandon Sanderson", series_name="Mistborn", series_position=1, content_type="book (fiction)", search_mode=SearchMode.UNIVERSAL, ) # Audiobook task audiobook_task = DownloadTask( task_id="audiobook-int-1", source="prowlarr", title="Words of Radiance", author="Brandon Sanderson", content_type="Audiobook", search_mode=SearchMode.UNIVERSAL, ) # Determine processing for book is_book_audiobook = "audiobook" in (book_task.content_type or "").lower() book_mode = config.get("PROCESSING_MODE_AUDIOBOOK") if is_book_audiobook else config.get("PROCESSING_MODE") assert book_mode == "library" # Build book destination book_metadata = { "Author": book_task.author, "Title": book_task.title, "Series": book_task.series_name, "SeriesPosition": book_task.series_position, } book_dest = build_library_path( config.get("LIBRARY_PATH"), config.get("LIBRARY_TEMPLATE"), book_metadata, extension="epub" ) # Move book to library book_dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(book_file), str(book_dest)) # Determine processing for audiobook is_audio_audiobook = "audiobook" in (audiobook_task.content_type or "").lower() audio_mode = config.get("PROCESSING_MODE_AUDIOBOOK") if is_audio_audiobook else config.get("PROCESSING_MODE") assert audio_mode == "ingest" # Move audiobook to ingest ingest_dir = Path(config.get("INGEST_DIR_AUDIOBOOK")) audiobook_dest = ingest_dir / audiobook_file.name shutil.move(str(audiobook_file), str(audiobook_dest)) # Verify results assert book_dest.exists() assert audiobook_dest.exists() # Book should be in organized structure assert "Mistborn" in str(book_dest) assert "1 - The Final Empire" in str(book_dest) # Audiobook should be in flat ingest directory assert audiobook_dest.parent == ingest_dir def test_full_workflow_both_library_mode(self, full_setup): """Complete workflow: both books and audiobooks in library mode.""" config = MockConfig( PROCESSING_MODE="library", LIBRARY_PATH=str(full_setup["books_lib"]), LIBRARY_TEMPLATE="{Author}/{Title}", PROCESSING_MODE_AUDIOBOOK="library", LIBRARY_PATH_AUDIOBOOK=str(full_setup["audiobooks_lib"]), LIBRARY_TEMPLATE_AUDIOBOOK="{Author}/{Series/}{Title}", ) # Create test files book_file = full_setup["staging"] / "test_book.epub" book_file.write_text("epub content") audiobook_file = full_setup["staging"] / "test_audiobook.m4b" audiobook_file.write_text("m4b content") # Book task book_task = DownloadTask( task_id="book-int-2", source="prowlarr", title="Dune", author="Frank Herbert", content_type="book (fiction)", search_mode=SearchMode.UNIVERSAL, ) # Audiobook task with series audiobook_task = DownloadTask( task_id="audiobook-int-2", source="prowlarr", title="Dune", author="Frank Herbert", series_name="Dune Chronicles", content_type="Audiobook", search_mode=SearchMode.UNIVERSAL, ) # Process book book_metadata = {"Author": book_task.author, "Title": book_task.title} book_dest = build_library_path( config.get("LIBRARY_PATH"), config.get("LIBRARY_TEMPLATE"), book_metadata, extension="epub" ) book_dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(book_file), str(book_dest)) # Process audiobook audiobook_metadata = { "Author": audiobook_task.author, "Title": audiobook_task.title, "Series": audiobook_task.series_name, } audiobook_dest = build_library_path( config.get("LIBRARY_PATH_AUDIOBOOK"), config.get("LIBRARY_TEMPLATE_AUDIOBOOK"), audiobook_metadata, extension="m4b" ) audiobook_dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(audiobook_file), str(audiobook_dest)) # Verify results assert book_dest.exists() assert audiobook_dest.exists() # Book: /books_lib/Frank Herbert/Dune.epub assert book_dest.parent.name == "Frank Herbert" # Audiobook: /audiobooks_lib/Frank Herbert/Dune Chronicles/Dune.m4b assert "Dune Chronicles" in str(audiobook_dest) assert audiobook_dest.parent.name == "Dune Chronicles"