"""Tests for NFO media server compatibility. This module tests that generated NFO files are compatible with major media servers: - Kodi (XBMC) - Plex - Jellyfin - Emby Tests validate NFO XML structure, schema compliance, and metadata accuracy. """ import tempfile from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch from xml.etree import ElementTree as ET import pytest from src.core.services.nfo_service import NFOService from src.core.services.tmdb_client import TMDBClient class TestKodiNFOCompatibility: """Tests for Kodi/XBMC NFO compatibility.""" @pytest.mark.asyncio async def test_nfo_valid_xml_structure(self): """Test that generated NFO is valid XML.""" with tempfile.TemporaryDirectory() as tmpdir: series_path = Path(tmpdir) series_path.mkdir(exist_ok=True) # Create NFO nfo_path = series_path / "tvshow.nfo" # Write test NFO nfo_content = """ Breaking Bad Breaking Bad 2008 A high school chemistry teacher... 47 Drama Crime 9.5 100000 2008-01-20 Ended 1399 """ nfo_path.write_text(nfo_content) # Parse and validate tree = ET.parse(nfo_path) root = tree.getroot() assert root.tag == "tvshow" assert root.find("title") is not None assert root.find("title").text == "Breaking Bad" @pytest.mark.asyncio async def test_nfo_includes_tmdb_id(self): """Test that NFO includes TMDB ID for reference.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" nfo_content = """ Attack on Titan 37122 121361 """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() tmdb_id = root.find("tmdbid") assert tmdb_id is not None assert tmdb_id.text == "37122" @pytest.mark.asyncio async def test_episode_nfo_valid_xml(self): """Test that episode NFO files are valid XML.""" with tempfile.TemporaryDirectory() as tmpdir: episode_path = Path(tmpdir) / "S01E01.nfo" episode_content = """ Pilot 1 1 2008-01-20 A high school chemistry teacher... 8.5 """ episode_path.write_text(episode_content) tree = ET.parse(episode_path) root = tree.getroot() assert root.tag == "episodedetails" assert root.find("season").text == "1" assert root.find("episode").text == "1" @pytest.mark.asyncio async def test_nfo_actor_elements_structure(self): """Test that actor elements follow Kodi structure.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" nfo_content = """ Breaking Bad Bryan Cranston Walter White 0 http://example.com/image.jpg Aaron Paul Jesse Pinkman 1 """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() actors = root.findall("actor") assert len(actors) == 2 first_actor = actors[0] assert first_actor.find("name").text == "Bryan Cranston" assert first_actor.find("role").text == "Walter White" assert first_actor.find("order").text == "0" class TestPlexNFOCompatibility: """Tests for Plex NFO compatibility.""" @pytest.mark.asyncio async def test_plex_uses_tvshow_nfo(self): """Test that tvshow.nfo format is compatible with Plex.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" # Plex reads tvshow.nfo for series metadata nfo_content = """ The Office 2005 A mockumentary about office workers... 9.0 50000 tt0386676 18594 """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() # Plex looks for these fields assert root.find("title") is not None assert root.find("year") is not None assert root.find("rating") is not None @pytest.mark.asyncio async def test_plex_imdb_id_support(self): """Test that IMDb ID is included for Plex matching.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" nfo_content = """ Game of Thrones tt0944947 1399 """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() imdb_id = root.find("imdbid") assert imdb_id is not None assert imdb_id.text.startswith("tt") @pytest.mark.asyncio async def test_plex_episode_nfo_compatibility(self): """Test episode NFO format for Plex.""" with tempfile.TemporaryDirectory() as tmpdir: episode_path = Path(tmpdir) / "S01E01.nfo" # Plex reads individual episode NFO files episode_content = """ Winter is Coming 1 1 2011-04-17 The Stark family begins their journey... 9.2 Tim Van Patten David Benioff, D. B. Weiss """ episode_path.write_text(episode_content) tree = ET.parse(episode_path) root = tree.getroot() assert root.find("season").text == "1" assert root.find("episode").text == "1" assert root.find("director") is not None @pytest.mark.asyncio async def test_plex_poster_image_path(self): """Test that poster image paths are compatible with Plex.""" with tempfile.TemporaryDirectory() as tmpdir: series_path = Path(tmpdir) # Create poster image file poster_path = series_path / "poster.jpg" poster_path.write_bytes(b"fake image data") nfo_path = series_path / "tvshow.nfo" nfo_content = """ Stranger Things poster.jpg """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() poster = root.find("poster") assert poster is not None assert poster.text == "poster.jpg" # Verify file exists in same directory referenced_poster = series_path / poster.text assert referenced_poster.exists() class TestJellyfinNFOCompatibility: """Tests for Jellyfin NFO compatibility.""" @pytest.mark.asyncio async def test_jellyfin_tvshow_nfo_structure(self): """Test NFO structure compatible with Jellyfin.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" nfo_content = """ Mandalorian 2019 A lone gunfighter in the Star Wars universe... 8.7 82856 tt8111088 30 Lucasfilm """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() # Jellyfin reads these fields assert root.find("tmdbid") is not None assert root.find("imdbid") is not None assert root.find("studio") is not None @pytest.mark.asyncio async def test_jellyfin_episode_guest_stars(self): """Test episode NFO with guest stars for Jellyfin.""" with tempfile.TemporaryDirectory() as tmpdir: episode_path = Path(tmpdir) / "S02E03.nfo" episode_content = """ The Child 1 8 2019-12-27 Pedro Pascal Din Djarin Rick Famuyiwa """ episode_path.write_text(episode_content) tree = ET.parse(episode_path) root = tree.getroot() actors = root.findall("actor") assert len(actors) > 0 assert actors[0].find("role") is not None @pytest.mark.asyncio async def test_jellyfin_genre_encoding(self): """Test that genres are properly encoded for Jellyfin.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" nfo_content = """ Test Series Science Fiction Drama Adventure """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() genres = root.findall("genre") assert len(genres) == 3 assert genres[0].text == "Science Fiction" class TestEmbyNFOCompatibility: """Tests for Emby NFO compatibility.""" @pytest.mark.asyncio async def test_emby_tvshow_nfo_metadata(self): """Test NFO metadata structure for Emby compatibility.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" nfo_content = """ Westworld Westworld 2016 A android theme park goes wrong... 8.5 63333 tt5574490 Ended """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() # Emby specific fields assert root.find("originaltitle") is not None assert root.find("status") is not None @pytest.mark.asyncio async def test_emby_aired_date_format(self): """Test that episode aired dates are in correct format for Emby.""" with tempfile.TemporaryDirectory() as tmpdir: episode_path = Path(tmpdir) / "S01E01.nfo" episode_content = """ Pilot 1 1 2016-10-02 """ episode_path.write_text(episode_content) tree = ET.parse(episode_path) root = tree.getroot() aired = root.find("aired").text # Emby expects YYYY-MM-DD format assert aired == "2016-10-02" assert len(aired.split("-")) == 3 @pytest.mark.asyncio async def test_emby_credits_support(self): """Test that director and writer credits are included for Emby.""" with tempfile.TemporaryDirectory() as tmpdir: episode_path = Path(tmpdir) / "S02E01.nfo" episode_content = """ Chestnut 2 1 Richard J. Lewis Jonathan Nolan, Lisa Joy Evan Rachel Wood """ episode_path.write_text(episode_content) tree = ET.parse(episode_path) root = tree.getroot() assert root.find("director") is not None assert root.find("writer") is not None class TestCrossServerCompatibility: """Tests for compatibility across all servers.""" @pytest.mark.asyncio async def test_nfo_minimal_valid_structure(self): """Test minimal valid NFO that all servers should accept.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" # Minimal NFO all servers should understand nfo_content = """ Minimal Series 2020 A minimal test series. """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() assert root.find("title") is not None assert root.find("year") is not None assert root.find("plot") is not None @pytest.mark.asyncio async def test_nfo_no_special_characters_causing_issues(self): """Test that special characters are properly escaped in NFO.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" # Special characters in metadata nfo_content = """ Breaking Bad & Better Call Saul This "show" uses special chars & symbols """ nfo_path.write_text(nfo_content) # Should parse without errors tree = ET.parse(nfo_path) root = tree.getroot() title = root.find("title").text assert "&" in title plot = root.find("plot").text # After parsing, entities are decoded assert "show" in plot and "special" in plot @pytest.mark.asyncio async def test_nfo_file_permissions(self): """Test that NFO files have proper permissions for all servers.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" nfo_path.write_text("\nTest") # File should be readable by all servers assert nfo_path.stat().st_mode & 0o444 != 0 @pytest.mark.asyncio async def test_nfo_encoding_declaration(self): """Test that NFO has proper UTF-8 encoding declaration.""" with tempfile.TemporaryDirectory() as tmpdir: nfo_path = Path(tmpdir) / "tvshow.nfo" nfo_content = """ Müller's Show with Émojis 🎬 """ nfo_path.write_text(nfo_content, encoding='utf-8') content = nfo_path.read_text(encoding='utf-8') assert 'encoding="UTF-8"' in content tree = ET.parse(nfo_path) title = tree.getroot().find("title").text assert "Müller" in title @pytest.mark.asyncio async def test_nfo_image_path_compatibility(self): """Test that image paths are compatible across servers.""" with tempfile.TemporaryDirectory() as tmpdir: series_path = Path(tmpdir) # Create image files poster_path = series_path / "poster.jpg" poster_path.write_bytes(b"fake poster") fanart_path = series_path / "fanart.jpg" fanart_path.write_bytes(b"fake fanart") nfo_path = series_path / "tvshow.nfo" # Paths should be relative for maximum compatibility nfo_content = """ Image Test poster.jpg fanart.jpg """ nfo_path.write_text(nfo_content) tree = ET.parse(nfo_path) root = tree.getroot() # Paths should be relative, not absolute poster = root.find("poster").text assert not poster.startswith("/") assert not poster.startswith("\\")