From e32098fb94f4f6dd5c352f7cdc5937cc727ddc07 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 11 Jan 2026 21:10:44 +0100 Subject: [PATCH] feat: Implement NFOService.update_tvshow_nfo() - Parse existing NFO to extract TMDB ID from uniqueid or tmdbid element - Fetch fresh metadata from TMDB API - Regenerate NFO with updated data - Optionally re-download media files - Add comprehensive error handling (missing NFO, no TMDB ID, invalid XML) - Add unit tests for XML parsing logic (4 tests, all passing) - Add integration test script (requires TMDB API key) --- docs/task3_status.md | 2 +- scripts/test_nfo_update.py | 183 ++++++++++++++++++++++++++ src/core/services/nfo_service.py | 75 +++++++++-- tests/unit/test_nfo_update_parsing.py | 178 +++++++++++++++++++++++++ 4 files changed, 428 insertions(+), 10 deletions(-) create mode 100644 scripts/test_nfo_update.py create mode 100644 tests/unit/test_nfo_update_parsing.py diff --git a/docs/task3_status.md b/docs/task3_status.md index d7368ee..a45cf6b 100644 --- a/docs/task3_status.md +++ b/docs/task3_status.md @@ -258,7 +258,7 @@ Task 3 is **95% Complete** and **Production Ready**. **⚠️ Documentation Remaining (5%):** - 📝 TMDB API setup guide (10 min) -- 📝 Configuration examples for README (10 min) +- 📝 Configuration examples for README (10 min) - 📝 ARCHITECTURE.md component diagram (10 min) **Optional Future Work (Not blocking):** diff --git a/scripts/test_nfo_update.py b/scripts/test_nfo_update.py new file mode 100644 index 0000000..af798db --- /dev/null +++ b/scripts/test_nfo_update.py @@ -0,0 +1,183 @@ +"""Test script for NFOService.update_tvshow_nfo() functionality. + +This script tests the update functionality by: +1. Creating a test NFO file with TMDB ID +2. Updating it with fresh data from TMDB +3. Verifying the update worked correctly +""" + +import asyncio +import logging +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.config.settings import settings +from src.core.services.nfo_service import NFOService +from src.core.services.tmdb_client import TMDBAPIError + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +async def test_update_nfo(): + """Test NFO update functionality.""" + + # Check if TMDB API key is configured + if not settings.tmdb_api_key: + logger.error("TMDB_API_KEY not configured in environment") + logger.error("Set TMDB_API_KEY in .env file or environment variables") + return False + + # Test series: Attack on Titan (TMDB ID: 1429) + test_serie_name = "Attack on Titan" + test_serie_folder = "test_update_nfo" + test_tmdb_id = 1429 + + # Create test folder + test_folder = Path(settings.anime_directory) / test_serie_folder + test_folder.mkdir(parents=True, exist_ok=True) + logger.info(f"Created test folder: {test_folder}") + + # Initialize NFO service + nfo_service = NFOService( + tmdb_api_key=settings.tmdb_api_key, + anime_directory=settings.anime_directory, + image_size=settings.nfo_image_size + ) + + try: + # Step 1: Create initial NFO + logger.info("=" * 60) + logger.info("STEP 1: Creating initial NFO") + logger.info("=" * 60) + + nfo_path = await nfo_service.create_tvshow_nfo( + serie_name=test_serie_name, + serie_folder=test_serie_folder, + year=2013, + download_poster=False, # Skip downloads for faster testing + download_logo=False, + download_fanart=False + ) + + logger.info(f"✓ Initial NFO created: {nfo_path}") + + # Read initial NFO content + initial_content = nfo_path.read_text(encoding="utf-8") + logger.info(f"Initial NFO size: {len(initial_content)} bytes") + + # Verify TMDB ID is in the file + if str(test_tmdb_id) not in initial_content: + logger.error(f"TMDB ID {test_tmdb_id} not found in NFO!") + return False + logger.info(f"✓ TMDB ID {test_tmdb_id} found in NFO") + + # Step 2: Update the NFO + logger.info("") + logger.info("=" * 60) + logger.info("STEP 2: Updating NFO with fresh data") + logger.info("=" * 60) + + updated_path = await nfo_service.update_tvshow_nfo( + serie_folder=test_serie_folder, + download_media=False # Skip downloads + ) + + logger.info(f"✓ NFO updated: {updated_path}") + + # Read updated content + updated_content = updated_path.read_text(encoding="utf-8") + logger.info(f"Updated NFO size: {len(updated_content)} bytes") + + # Verify TMDB ID is still in the file + if str(test_tmdb_id) not in updated_content: + logger.error(f"TMDB ID {test_tmdb_id} not found after update!") + return False + logger.info(f"✓ TMDB ID {test_tmdb_id} still present after update") + + # Step 3: Test update on non-existent NFO (should fail) + logger.info("") + logger.info("=" * 60) + logger.info("STEP 3: Testing error handling") + logger.info("=" * 60) + + try: + await nfo_service.update_tvshow_nfo( + serie_folder="non_existent_folder", + download_media=False + ) + logger.error("✗ Should have raised FileNotFoundError!") + return False + except FileNotFoundError as e: + logger.info(f"✓ Correctly raised FileNotFoundError: {e}") + + # Step 4: Test update on NFO without TMDB ID + logger.info("") + logger.info("STEP 4: Testing NFO without TMDB ID") + + # Create a minimal NFO without TMDB ID + no_id_folder = test_folder.parent / "test_no_tmdb_id" + no_id_folder.mkdir(parents=True, exist_ok=True) + no_id_nfo = no_id_folder / "tvshow.nfo" + no_id_nfo.write_text( + '\n' + '\n' + ' Test Show\n' + '', + encoding="utf-8" + ) + + try: + await nfo_service.update_tvshow_nfo( + serie_folder="test_no_tmdb_id", + download_media=False + ) + logger.error("✗ Should have raised TMDBAPIError!") + return False + except TMDBAPIError as e: + logger.info(f"✓ Correctly raised TMDBAPIError: {e}") + + logger.info("") + logger.info("=" * 60) + logger.info("✓ ALL TESTS PASSED") + logger.info("=" * 60) + return True + + except Exception as e: + logger.error(f"✗ Test failed with error: {e}", exc_info=True) + return False + + finally: + await nfo_service.close() + + # Cleanup test folders + logger.info("\nCleaning up test folders...") + import shutil + try: + if test_folder.exists(): + shutil.rmtree(test_folder) + logger.info(f"Removed: {test_folder}") + + no_id_folder = test_folder.parent / "test_no_tmdb_id" + if no_id_folder.exists(): + shutil.rmtree(no_id_folder) + logger.info(f"Removed: {no_id_folder}") + except Exception as e: + logger.warning(f"Cleanup error: {e}") + + +async def main(): + """Main entry point.""" + success = await test_update_nfo() + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py index 42501af..9d8173b 100644 --- a/src/core/services/nfo_service.py +++ b/src/core/services/nfo_service.py @@ -12,6 +12,8 @@ import logging from pathlib import Path from typing import Any, Dict, List, Optional +from lxml import etree + from src.core.entities.nfo_models import ( ActorInfo, ImageInfo, @@ -158,21 +160,76 @@ class NFOService: Raises: FileNotFoundError: If NFO file doesn't exist - TMDBAPIError: If TMDB API fails + TMDBAPIError: If TMDB API fails or no TMDB ID found in NFO """ - nfo_path = self.anime_directory / serie_folder / "tvshow.nfo" + folder_path = self.anime_directory / serie_folder + nfo_path = folder_path / "tvshow.nfo" if not nfo_path.exists(): raise FileNotFoundError(f"NFO file not found: {nfo_path}") - # Parse existing NFO to get TMDB ID - # For simplicity, we'll recreate from scratch - # In production, you'd parse the XML to extract the ID - logger.info(f"Updating NFO for {serie_folder}") - # Implementation would extract serie name and call create_tvshow_nfo - # This is a simplified version - raise NotImplementedError("Update NFO not yet implemented") + + # Parse existing NFO to extract TMDB ID + try: + tree = etree.parse(str(nfo_path)) + root = tree.getroot() + + # Try to find TMDB ID from uniqueid elements + tmdb_id = None + for uniqueid in root.findall(".//uniqueid"): + if uniqueid.get("type") == "tmdb": + tmdb_id = int(uniqueid.text) + break + + # Fallback: check for tmdbid element + if tmdb_id is None: + tmdbid_elem = root.find(".//tmdbid") + if tmdbid_elem is not None and tmdbid_elem.text: + tmdb_id = int(tmdbid_elem.text) + + if tmdb_id is None: + raise TMDBAPIError( + f"No TMDB ID found in existing NFO. " + f"Delete the NFO and create a new one instead." + ) + + logger.debug(f"Found TMDB ID: {tmdb_id}") + + except etree.XMLSyntaxError as e: + raise TMDBAPIError(f"Invalid XML in NFO file: {e}") + except ValueError as e: + raise TMDBAPIError(f"Invalid TMDB ID format in NFO: {e}") + + # Fetch fresh data from TMDB + async with self.tmdb_client: + logger.debug(f"Fetching fresh data for TMDB ID: {tmdb_id}") + details = await self.tmdb_client.get_tv_show_details( + tmdb_id, + append_to_response="credits,external_ids,images" + ) + + # Convert TMDB data to TVShowNFO model + nfo_model = self._tmdb_to_nfo_model(details) + + # Generate XML + nfo_xml = generate_tvshow_nfo(nfo_model) + + # Save updated NFO file + nfo_path.write_text(nfo_xml, encoding="utf-8") + logger.info(f"Updated NFO: {nfo_path}") + + # Re-download media files if requested + if download_media: + await self._download_media_files( + details, + folder_path, + download_poster=True, + download_logo=True, + download_fanart=True + ) + + return nfo_path def _find_best_match( self, diff --git a/tests/unit/test_nfo_update_parsing.py b/tests/unit/test_nfo_update_parsing.py new file mode 100644 index 0000000..d68fde2 --- /dev/null +++ b/tests/unit/test_nfo_update_parsing.py @@ -0,0 +1,178 @@ +"""Unit test for NFOService.update_tvshow_nfo() - tests XML parsing logic.""" + +import asyncio +from pathlib import Path +import tempfile +import shutil + +from lxml import etree +import pytest + +from src.core.services.nfo_service import NFOService +from src.core.services.tmdb_client import TMDBAPIError + + +def create_sample_nfo(tmdb_id: int = 1429) -> str: + """Create a sample NFO XML with TMDB ID.""" + return f''' + + Attack on Titan + Shingeki no Kyojin + 2013 + Several hundred years ago, humans were nearly exterminated by Titans. + {tmdb_id} + 267440 + {tmdb_id} + 267440 +''' + + +def test_parse_nfo_with_uniqueid(): + """Test parsing NFO with uniqueid elements.""" + # Create temporary directory structure + temp_dir = Path(tempfile.mkdtemp()) + serie_folder = temp_dir / "test_series" + serie_folder.mkdir() + nfo_path = serie_folder / "tvshow.nfo" + + try: + # Write sample NFO + nfo_path.write_text(create_sample_nfo(1429), encoding="utf-8") + + # Parse it (same logic as in update_tvshow_nfo) + tree = etree.parse(str(nfo_path)) + root = tree.getroot() + + # Extract TMDB ID + tmdb_id = None + for uniqueid in root.findall(".//uniqueid"): + if uniqueid.get("type") == "tmdb": + tmdb_id = int(uniqueid.text) + break + + assert tmdb_id == 1429, f"Expected TMDB ID 1429, got {tmdb_id}" + print(f"✓ Successfully parsed TMDB ID from uniqueid: {tmdb_id}") + + finally: + shutil.rmtree(temp_dir) + + +def test_parse_nfo_with_tmdbid_element(): + """Test parsing NFO with tmdbid element (fallback).""" + # Create NFO without uniqueid but with tmdbid element + nfo_content = ''' + + Test Show + 12345 +''' + + temp_dir = Path(tempfile.mkdtemp()) + serie_folder = temp_dir / "test_series" + serie_folder.mkdir() + nfo_path = serie_folder / "tvshow.nfo" + + try: + nfo_path.write_text(nfo_content, encoding="utf-8") + + # Parse it + tree = etree.parse(str(nfo_path)) + root = tree.getroot() + + # Try uniqueid first (should fail) + tmdb_id = None + for uniqueid in root.findall(".//uniqueid"): + if uniqueid.get("type") == "tmdb": + tmdb_id = int(uniqueid.text) + break + + # Fallback to tmdbid element + if tmdb_id is None: + tmdbid_elem = root.find(".//tmdbid") + if tmdbid_elem is not None and tmdbid_elem.text: + tmdb_id = int(tmdbid_elem.text) + + assert tmdb_id == 12345, f"Expected TMDB ID 12345, got {tmdb_id}" + print(f"✓ Successfully parsed TMDB ID from tmdbid element: {tmdb_id}") + + finally: + shutil.rmtree(temp_dir) + + +def test_parse_nfo_without_tmdb_id(): + """Test parsing NFO without TMDB ID raises appropriate error.""" + # Create NFO without any TMDB ID + nfo_content = ''' + + Test Show +''' + + temp_dir = Path(tempfile.mkdtemp()) + serie_folder = temp_dir / "test_series" + serie_folder.mkdir() + nfo_path = serie_folder / "tvshow.nfo" + + try: + nfo_path.write_text(nfo_content, encoding="utf-8") + + # Parse it + tree = etree.parse(str(nfo_path)) + root = tree.getroot() + + # Try to extract TMDB ID + tmdb_id = None + for uniqueid in root.findall(".//uniqueid"): + if uniqueid.get("type") == "tmdb": + tmdb_id = int(uniqueid.text) + break + + if tmdb_id is None: + tmdbid_elem = root.find(".//tmdbid") + if tmdbid_elem is not None and tmdbid_elem.text: + tmdb_id = int(tmdbid_elem.text) + + assert tmdb_id is None, "Should not have found TMDB ID" + print("✓ Correctly identified NFO without TMDB ID") + + finally: + shutil.rmtree(temp_dir) + + +def test_parse_invalid_xml(): + """Test parsing invalid XML raises appropriate error.""" + nfo_content = ''' + + Unclosed tag +</tvshow>''' + + temp_dir = Path(tempfile.mkdtemp()) + serie_folder = temp_dir / "test_series" + serie_folder.mkdir() + nfo_path = serie_folder / "tvshow.nfo" + + try: + nfo_path.write_text(nfo_content, encoding="utf-8") + + # Try to parse - should raise XMLSyntaxError + try: + tree = etree.parse(str(nfo_path)) + assert False, "Should have raised XMLSyntaxError" + except etree.XMLSyntaxError: + print("✓ Correctly raised XMLSyntaxError for invalid XML") + + finally: + shutil.rmtree(temp_dir) + + +if __name__ == "__main__": + print("Testing NFO XML parsing logic...") + print() + + test_parse_nfo_with_uniqueid() + test_parse_nfo_with_tmdbid_element() + test_parse_nfo_without_tmdb_id() + test_parse_invalid_xml() + + print() + print("=" * 60) + print("✓ ALL TESTS PASSED") + print("=" * 60)