"""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("\\")