diff --git a/.coverage b/.coverage
index 1b41ab9..3f47374 100644
Binary files a/.coverage and b/.coverage differ
diff --git a/docs/instructions.md b/docs/instructions.md
index 6f0f25f..ee2d2c8 100644
--- a/docs/instructions.md
+++ b/docs/instructions.md
@@ -112,81 +112,6 @@ For each task completed:
### 🎬 NFO Metadata Integration
-#### Task 2: Create NFO Models and Schemas
-
-**Priority:** High
-**Estimated Time:** 3-4 hours
-
-Create Pydantic models for NFO metadata based on Kodi/XBMC standard.
-
-**Implementation Steps:**
-
-1. **Create NFO Models Module** (`src/core/entities/nfo_models.py`)
-
- ```python
- # Create Pydantic models for:
- - TVShowNFO: Main tvshow.nfo structure
- - ActorInfo: Actor/cast information
- - RatingInfo: Rating information (TMDB, IMDB, etc.)
- - ImageInfo: Thumbnail, fanart, logos
- ```
-
-2. **Model Fields (based on scraper/tvshow.nfo example):**
-
- - Basic: title, originaltitle, showtitle, year, plot, runtime
- - IDs: tmdbid, imdbid, tvdbid
- - Status: premiered, status, genre, studio
- - Media: thumb (poster URLs), fanart (fanart URLs), clearlogo (logo URLs)
- - Local Media: local poster.jpg, logo.png, fanart.jpg paths
- - Cast: actor list with name, role, thumb
- - Additional: mpaa, country, tag
-
-**Note:** NFO files should reference both remote TMDB URLs and local file paths for media:
-
-```xml
-https://image.tmdb.org/t/p/original/...
-https://image.tmdb.org/t/p/original/...
-
- https://image.tmdb.org/t/p/original/...
-
-```
-
-3. **Add Validation:**
- - Date format validation (YYYY-MM-DD)
- - URL validation for image paths
- - Required vs optional fields
- - Default values where appropriate
-
-**Acceptance Criteria:**
-
-- [ ] NFO models created with comprehensive field coverage
-- [ ] Models match Kodi/XBMC standard format
-- [ ] Pydantic validation works correctly
-- [ ] Can serialize to/from dict
-- [ ] Type hints throughout
-- [ ] Test coverage > 95% for models
-
-**Testing Requirements:**
-
-- Test all model fields with valid data
-- Test required vs optional field validation
-- Test date format validation (YYYY-MM-DD)
-- Test URL validation for image paths
-- Test invalid data rejection
-- Test default values
-- Test serialization to dict
-- Test deserialization from dict
-- Test nested model validation (ActorInfo, RatingInfo, etc.)
-- Test edge cases (empty strings, None values, special characters)
-- Use parametrized tests for multiple scenarios
-
-**Files to Create:**
-
-- `src/core/entities/nfo_models.py`
-- `tests/unit/test_nfo_models.py`
-
----
-
#### Task 3: Adapt Scraper Code for NFO Generation
**Priority:** High
diff --git a/src/core/entities/nfo_models.py b/src/core/entities/nfo_models.py
new file mode 100644
index 0000000..8c1c089
--- /dev/null
+++ b/src/core/entities/nfo_models.py
@@ -0,0 +1,331 @@
+"""Pydantic models for NFO metadata based on Kodi/XBMC standard.
+
+This module provides data models for tvshow.nfo files that are compatible
+with media center applications like Kodi, Plex, and Jellyfin.
+
+Example:
+ >>> nfo = TVShowNFO(
+ ... title="Attack on Titan",
+ ... year=2013,
+ ... tmdbid=1429
+ ... )
+ >>> nfo.premiered = "2013-04-07"
+"""
+
+from datetime import datetime
+from typing import List, Optional
+
+from pydantic import BaseModel, Field, HttpUrl, field_validator
+
+
+class RatingInfo(BaseModel):
+ """Rating information from various sources.
+
+ Attributes:
+ name: Source of the rating (e.g., 'themoviedb', 'imdb')
+ value: Rating value (typically 0-10)
+ votes: Number of votes
+ max_rating: Maximum possible rating (default: 10)
+ default: Whether this is the default rating to display
+ """
+
+ name: str = Field(..., description="Rating source name")
+ value: float = Field(..., ge=0, description="Rating value")
+ votes: Optional[int] = Field(None, ge=0, description="Number of votes")
+ max_rating: int = Field(10, ge=1, description="Maximum rating value")
+ default: bool = Field(False, description="Is this the default rating")
+
+ @field_validator('value')
+ @classmethod
+ def validate_value(cls, v: float, info) -> float:
+ """Ensure rating value doesn't exceed max_rating."""
+ # Note: max_rating is not available yet during validation,
+ # so we use a reasonable default check
+ if v > 10:
+ raise ValueError("Rating value cannot exceed 10")
+ return v
+
+
+class ActorInfo(BaseModel):
+ """Actor/cast member information.
+
+ Attributes:
+ name: Actor's name
+ role: Character name/role
+ thumb: URL to actor's photo
+ profile: URL to actor's profile page
+ tmdbid: TMDB ID for the actor
+ """
+
+ name: str = Field(..., description="Actor's name")
+ role: Optional[str] = Field(None, description="Character role")
+ thumb: Optional[HttpUrl] = Field(None, description="Actor photo URL")
+ profile: Optional[HttpUrl] = Field(None, description="Actor profile URL")
+ tmdbid: Optional[int] = Field(None, description="TMDB actor ID")
+
+
+class ImageInfo(BaseModel):
+ """Image information for posters, fanart, and logos.
+
+ Attributes:
+ url: URL to the image
+ aspect: Image aspect/type (e.g., 'poster', 'clearlogo', 'logo')
+ season: Season number for season-specific images
+ type: Image type (e.g., 'season')
+ """
+
+ url: HttpUrl = Field(..., description="Image URL")
+ aspect: Optional[str] = Field(
+ None,
+ description="Image aspect (poster, clearlogo, logo)"
+ )
+ season: Optional[int] = Field(None, ge=-1, description="Season number")
+ type: Optional[str] = Field(None, description="Image type")
+
+
+class NamedSeason(BaseModel):
+ """Named season information.
+
+ Attributes:
+ number: Season number
+ name: Season name/title
+ """
+
+ number: int = Field(..., ge=0, description="Season number")
+ name: str = Field(..., description="Season name")
+
+
+class UniqueID(BaseModel):
+ """Unique identifier from various sources.
+
+ Attributes:
+ type: ID source type (tmdb, imdb, tvdb)
+ value: The ID value
+ default: Whether this is the default ID
+ """
+
+ type: str = Field(..., description="ID type (tmdb, imdb, tvdb)")
+ value: str = Field(..., description="ID value")
+ default: bool = Field(False, description="Is default ID")
+
+
+class TVShowNFO(BaseModel):
+ """Main tvshow.nfo structure following Kodi/XBMC standard.
+
+ This model represents the complete metadata for a TV show that can be
+ serialized to XML for use with media center applications.
+
+ Attributes:
+ title: Main title of the show
+ originaltitle: Original title (e.g., in original language)
+ showtitle: Show title (often same as title)
+ sorttitle: Title used for sorting
+ year: Release year
+ plot: Full plot description
+ outline: Short plot summary
+ tagline: Show tagline/slogan
+ runtime: Episode runtime in minutes
+ mpaa: Content rating (e.g., TV-14, TV-MA)
+ certification: Additional certification info
+ premiered: Premiere date (YYYY-MM-DD format)
+ status: Show status (e.g., 'Continuing', 'Ended')
+ studio: List of production studios
+ genre: List of genres
+ country: List of countries
+ tag: List of tags/keywords
+ ratings: List of ratings from various sources
+ userrating: User's personal rating
+ watched: Whether the show has been watched
+ playcount: Number of times watched
+ tmdbid: TMDB ID
+ imdbid: IMDB ID
+ tvdbid: TVDB ID
+ uniqueid: List of unique IDs
+ thumb: List of thumbnail/poster images
+ fanart: List of fanart/backdrop images
+ actors: List of cast members
+ namedseason: List of named seasons
+ trailer: Trailer URL
+ dateadded: Date when added to library
+ """
+
+ # Required fields
+ title: str = Field(..., description="Show title", min_length=1)
+
+ # Basic information (optional)
+ originaltitle: Optional[str] = Field(None, description="Original title")
+ showtitle: Optional[str] = Field(None, description="Show title")
+ sorttitle: Optional[str] = Field(None, description="Sort title")
+ year: Optional[int] = Field(
+ None,
+ ge=1900,
+ le=2100,
+ description="Release year"
+ )
+
+ # Plot and description
+ plot: Optional[str] = Field(None, description="Full plot description")
+ outline: Optional[str] = Field(None, description="Short plot summary")
+ tagline: Optional[str] = Field(None, description="Show tagline")
+
+ # Technical details
+ runtime: Optional[int] = Field(
+ None,
+ ge=0,
+ description="Episode runtime in minutes"
+ )
+ mpaa: Optional[str] = Field(None, description="Content rating")
+ certification: Optional[str] = Field(
+ None,
+ description="Certification info"
+ )
+
+ # Status and dates
+ premiered: Optional[str] = Field(
+ None,
+ description="Premiere date (YYYY-MM-DD)"
+ )
+ status: Optional[str] = Field(None, description="Show status")
+ dateadded: Optional[str] = Field(
+ None,
+ description="Date added to library"
+ )
+
+ # Multi-value fields
+ studio: List[str] = Field(
+ default_factory=list,
+ description="Production studios"
+ )
+ genre: List[str] = Field(
+ default_factory=list,
+ description="Genres"
+ )
+ country: List[str] = Field(
+ default_factory=list,
+ description="Countries"
+ )
+ tag: List[str] = Field(
+ default_factory=list,
+ description="Tags/keywords"
+ )
+
+ # IDs
+ tmdbid: Optional[int] = Field(None, description="TMDB ID")
+ imdbid: Optional[str] = Field(None, description="IMDB ID")
+ tvdbid: Optional[int] = Field(None, description="TVDB ID")
+ uniqueid: List[UniqueID] = Field(
+ default_factory=list,
+ description="Unique IDs"
+ )
+
+ # Ratings and viewing info
+ ratings: List[RatingInfo] = Field(
+ default_factory=list,
+ description="Ratings"
+ )
+ userrating: Optional[float] = Field(
+ None,
+ ge=0,
+ le=10,
+ description="User rating"
+ )
+ watched: bool = Field(False, description="Watched status")
+ playcount: Optional[int] = Field(
+ None,
+ ge=0,
+ description="Play count"
+ )
+
+ # Media
+ thumb: List[ImageInfo] = Field(
+ default_factory=list,
+ description="Thumbnail images"
+ )
+ fanart: List[ImageInfo] = Field(
+ default_factory=list,
+ description="Fanart images"
+ )
+
+ # Cast and crew
+ actors: List[ActorInfo] = Field(
+ default_factory=list,
+ description="Cast members"
+ )
+
+ # Seasons
+ namedseason: List[NamedSeason] = Field(
+ default_factory=list,
+ description="Named seasons"
+ )
+
+ # Additional
+ trailer: Optional[HttpUrl] = Field(None, description="Trailer URL")
+
+ @field_validator('premiered')
+ @classmethod
+ def validate_premiered_date(cls, v: Optional[str]) -> Optional[str]:
+ """Validate premiered date format (YYYY-MM-DD)."""
+ if v is None:
+ return v
+
+ # Check format strictly: YYYY-MM-DD
+ if len(v) != 10 or v[4] != '-' or v[7] != '-':
+ raise ValueError(
+ "Premiered date must be in YYYY-MM-DD format"
+ )
+
+ try:
+ datetime.strptime(v, '%Y-%m-%d')
+ except ValueError as exc:
+ raise ValueError(
+ "Premiered date must be in YYYY-MM-DD format"
+ ) from exc
+
+ return v
+
+ @field_validator('dateadded')
+ @classmethod
+ def validate_dateadded(cls, v: Optional[str]) -> Optional[str]:
+ """Validate dateadded format (YYYY-MM-DD HH:MM:SS)."""
+ if v is None:
+ return v
+
+ # Check format strictly: YYYY-MM-DD HH:MM:SS
+ if len(v) != 19 or v[4] != '-' or v[7] != '-' or v[10] != ' ' or v[13] != ':' or v[16] != ':':
+ raise ValueError(
+ "Dateadded must be in YYYY-MM-DD HH:MM:SS format"
+ )
+
+ try:
+ datetime.strptime(v, '%Y-%m-%d %H:%M:%S')
+ except ValueError as exc:
+ raise ValueError(
+ "Dateadded must be in YYYY-MM-DD HH:MM:SS format"
+ ) from exc
+
+ return v
+
+ @field_validator('imdbid')
+ @classmethod
+ def validate_imdbid(cls, v: Optional[str]) -> Optional[str]:
+ """Validate IMDB ID format (should start with 'tt')."""
+ if v is None:
+ return v
+
+ if not v.startswith('tt'):
+ raise ValueError("IMDB ID must start with 'tt'")
+
+ if not v[2:].isdigit():
+ raise ValueError("IMDB ID must be 'tt' followed by digits")
+
+ return v
+
+ def model_post_init(self, __context) -> None:
+ """Set default values after initialization."""
+ # Set showtitle to title if not provided
+ if self.showtitle is None:
+ self.showtitle = self.title
+
+ # Set originaltitle to title if not provided
+ if self.originaltitle is None:
+ self.originaltitle = self.title
diff --git a/tests/unit/test_nfo_models.py b/tests/unit/test_nfo_models.py
new file mode 100644
index 0000000..d2d8800
--- /dev/null
+++ b/tests/unit/test_nfo_models.py
@@ -0,0 +1,561 @@
+"""Unit tests for NFO models."""
+
+import pytest
+from pydantic import ValidationError
+
+from src.core.entities.nfo_models import (
+ ActorInfo,
+ ImageInfo,
+ NamedSeason,
+ RatingInfo,
+ TVShowNFO,
+ UniqueID,
+)
+
+
+class TestRatingInfo:
+ """Test RatingInfo model."""
+
+ def test_rating_info_with_all_fields(self):
+ """Test creating RatingInfo with all fields."""
+ rating = RatingInfo(
+ name="themoviedb",
+ value=8.5,
+ votes=1234,
+ max_rating=10,
+ default=True
+ )
+
+ assert rating.name == "themoviedb"
+ assert rating.value == 8.5
+ assert rating.votes == 1234
+ assert rating.max_rating == 10
+ assert rating.default is True
+
+ def test_rating_info_with_minimal_fields(self):
+ """Test creating RatingInfo with only required fields."""
+ rating = RatingInfo(name="imdb", value=7.2)
+
+ assert rating.name == "imdb"
+ assert rating.value == 7.2
+ assert rating.votes is None
+ assert rating.max_rating == 10 # default
+ assert rating.default is False # default
+
+ def test_rating_info_negative_value_rejected(self):
+ """Test that negative rating values are rejected."""
+ with pytest.raises(ValidationError):
+ RatingInfo(name="test", value=-1.0)
+
+ def test_rating_info_excessive_value_rejected(self):
+ """Test that rating values > 10 are rejected."""
+ with pytest.raises(ValidationError):
+ RatingInfo(name="test", value=11.0)
+
+ def test_rating_info_negative_votes_rejected(self):
+ """Test that negative vote counts are rejected."""
+ with pytest.raises(ValidationError):
+ RatingInfo(name="test", value=5.0, votes=-10)
+
+ def test_rating_info_zero_values_accepted(self):
+ """Test that zero values are accepted."""
+ rating = RatingInfo(name="test", value=0.0, votes=0)
+ assert rating.value == 0.0
+ assert rating.votes == 0
+
+
+class TestActorInfo:
+ """Test ActorInfo model."""
+
+ def test_actor_info_with_all_fields(self):
+ """Test creating ActorInfo with all fields."""
+ actor = ActorInfo(
+ name="John Doe",
+ role="Main Character",
+ thumb="https://example.com/actor.jpg",
+ profile="https://example.com/profile",
+ tmdbid=12345
+ )
+
+ assert actor.name == "John Doe"
+ assert actor.role == "Main Character"
+ assert str(actor.thumb) == "https://example.com/actor.jpg"
+ assert str(actor.profile) == "https://example.com/profile"
+ assert actor.tmdbid == 12345
+
+ def test_actor_info_with_minimal_fields(self):
+ """Test creating ActorInfo with only name."""
+ actor = ActorInfo(name="Jane Smith")
+
+ assert actor.name == "Jane Smith"
+ assert actor.role is None
+ assert actor.thumb is None
+ assert actor.profile is None
+ assert actor.tmdbid is None
+
+ def test_actor_info_invalid_url_rejected(self):
+ """Test that invalid URLs are rejected."""
+ with pytest.raises(ValidationError):
+ ActorInfo(name="Test", thumb="not-a-url")
+
+ def test_actor_info_http_url_accepted(self):
+ """Test that HTTP URLs are accepted."""
+ actor = ActorInfo(
+ name="Test",
+ thumb="http://example.com/image.jpg"
+ )
+ assert str(actor.thumb) == "http://example.com/image.jpg"
+
+
+class TestImageInfo:
+ """Test ImageInfo model."""
+
+ def test_image_info_with_all_fields(self):
+ """Test creating ImageInfo with all fields."""
+ image = ImageInfo(
+ url="https://image.tmdb.org/t/p/w500/poster.jpg",
+ aspect="poster",
+ season=1,
+ type="season"
+ )
+
+ assert str(image.url) == "https://image.tmdb.org/t/p/w500/poster.jpg"
+ assert image.aspect == "poster"
+ assert image.season == 1
+ assert image.type == "season"
+
+ def test_image_info_with_minimal_fields(self):
+ """Test creating ImageInfo with only URL."""
+ image = ImageInfo(url="https://example.com/image.jpg")
+
+ assert str(image.url) == "https://example.com/image.jpg"
+ assert image.aspect is None
+ assert image.season is None
+ assert image.type is None
+
+ def test_image_info_invalid_url_rejected(self):
+ """Test that invalid URLs are rejected."""
+ with pytest.raises(ValidationError):
+ ImageInfo(url="invalid-url")
+
+ def test_image_info_negative_season_rejected(self):
+ """Test that season < -1 is rejected."""
+ with pytest.raises(ValidationError):
+ ImageInfo(
+ url="https://example.com/image.jpg",
+ season=-2
+ )
+
+ def test_image_info_season_minus_one_accepted(self):
+ """Test that season -1 is accepted (all seasons)."""
+ image = ImageInfo(
+ url="https://example.com/image.jpg",
+ season=-1
+ )
+ assert image.season == -1
+
+
+class TestNamedSeason:
+ """Test NamedSeason model."""
+
+ def test_named_season_creation(self):
+ """Test creating NamedSeason."""
+ season = NamedSeason(number=1, name="Season One")
+
+ assert season.number == 1
+ assert season.name == "Season One"
+
+ def test_named_season_negative_number_rejected(self):
+ """Test that negative season numbers are rejected."""
+ with pytest.raises(ValidationError):
+ NamedSeason(number=-1, name="Invalid")
+
+ def test_named_season_zero_accepted(self):
+ """Test that season 0 (specials) is accepted."""
+ season = NamedSeason(number=0, name="Specials")
+ assert season.number == 0
+
+
+class TestUniqueID:
+ """Test UniqueID model."""
+
+ def test_unique_id_creation(self):
+ """Test creating UniqueID."""
+ uid = UniqueID(type="tmdb", value="12345", default=True)
+
+ assert uid.type == "tmdb"
+ assert uid.value == "12345"
+ assert uid.default is True
+
+ def test_unique_id_default_false(self):
+ """Test UniqueID with default=False."""
+ uid = UniqueID(type="imdb", value="tt1234567")
+ assert uid.default is False
+
+
+class TestTVShowNFO:
+ """Test TVShowNFO model."""
+
+ def test_tvshow_nfo_minimal_creation(self):
+ """Test creating TVShowNFO with only required fields."""
+ nfo = TVShowNFO(title="Test Show")
+
+ assert nfo.title == "Test Show"
+ assert nfo.showtitle == "Test Show" # auto-set
+ assert nfo.originaltitle == "Test Show" # auto-set
+ assert nfo.year is None
+ assert nfo.studio == []
+ assert nfo.genre == []
+ assert nfo.watched is False
+
+ def test_tvshow_nfo_with_all_basic_fields(self):
+ """Test creating TVShowNFO with all basic fields."""
+ nfo = TVShowNFO(
+ title="Attack on Titan",
+ originaltitle="Shingeki no Kyojin",
+ showtitle="Attack on Titan",
+ sorttitle="Attack on Titan",
+ year=2013,
+ plot="Humanity lives in fear of Titans.",
+ outline="Titans attack humanity.",
+ tagline="The world is cruel.",
+ runtime=24,
+ mpaa="TV-14",
+ certification="14+",
+ premiered="2013-04-07",
+ status="Ended"
+ )
+
+ assert nfo.title == "Attack on Titan"
+ assert nfo.originaltitle == "Shingeki no Kyojin"
+ assert nfo.year == 2013
+ assert nfo.plot == "Humanity lives in fear of Titans."
+ assert nfo.runtime == 24
+ assert nfo.premiered == "2013-04-07"
+
+ def test_tvshow_nfo_empty_title_rejected(self):
+ """Test that empty title is rejected."""
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="")
+
+ def test_tvshow_nfo_invalid_year_rejected(self):
+ """Test that invalid years are rejected."""
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test", year=1800)
+
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test", year=2200)
+
+ def test_tvshow_nfo_negative_runtime_rejected(self):
+ """Test that negative runtime is rejected."""
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test", runtime=-10)
+
+ def test_tvshow_nfo_with_multi_value_fields(self):
+ """Test TVShowNFO with lists."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ studio=["Studio A", "Studio B"],
+ genre=["Action", "Drama"],
+ country=["Japan", "USA"],
+ tag=["anime", "popular"]
+ )
+
+ assert len(nfo.studio) == 2
+ assert "Studio A" in nfo.studio
+ assert len(nfo.genre) == 2
+ assert len(nfo.country) == 2
+ assert len(nfo.tag) == 2
+
+ def test_tvshow_nfo_with_ratings(self):
+ """Test TVShowNFO with ratings."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ ratings=[
+ RatingInfo(name="tmdb", value=8.5, votes=1000, default=True),
+ RatingInfo(name="imdb", value=8.2, votes=5000)
+ ],
+ userrating=9.0
+ )
+
+ assert len(nfo.ratings) == 2
+ assert nfo.ratings[0].name == "tmdb"
+ assert nfo.ratings[0].default is True
+ assert nfo.userrating == 9.0
+
+ def test_tvshow_nfo_invalid_userrating_rejected(self):
+ """Test that userrating outside 0-10 is rejected."""
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test", userrating=-1)
+
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test", userrating=11)
+
+ def test_tvshow_nfo_with_ids(self):
+ """Test TVShowNFO with various IDs."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ tmdbid=12345,
+ imdbid="tt1234567",
+ tvdbid=67890,
+ uniqueid=[
+ UniqueID(type="tmdb", value="12345"),
+ UniqueID(type="imdb", value="tt1234567", default=True)
+ ]
+ )
+
+ assert nfo.tmdbid == 12345
+ assert nfo.imdbid == "tt1234567"
+ assert nfo.tvdbid == 67890
+ assert len(nfo.uniqueid) == 2
+
+ def test_tvshow_nfo_invalid_imdbid_rejected(self):
+ """Test that invalid IMDB IDs are rejected."""
+ with pytest.raises(ValidationError) as exc_info:
+ TVShowNFO(title="Test", imdbid="12345")
+ assert "must start with 'tt'" in str(exc_info.value)
+
+ with pytest.raises(ValidationError) as exc_info:
+ TVShowNFO(title="Test", imdbid="ttabc123")
+ assert "followed by digits" in str(exc_info.value)
+
+ def test_tvshow_nfo_valid_imdbid_accepted(self):
+ """Test that valid IMDB IDs are accepted."""
+ nfo = TVShowNFO(title="Test", imdbid="tt1234567")
+ assert nfo.imdbid == "tt1234567"
+
+ def test_tvshow_nfo_premiered_date_validation(self):
+ """Test premiered date format validation."""
+ # Valid format
+ nfo = TVShowNFO(title="Test", premiered="2013-04-07")
+ assert nfo.premiered == "2013-04-07"
+
+ # Invalid formats
+ with pytest.raises(ValidationError) as exc_info:
+ TVShowNFO(title="Test", premiered="2013-4-7")
+ assert "YYYY-MM-DD" in str(exc_info.value)
+
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test", premiered="04/07/2013")
+
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test", premiered="2013-13-01") # Invalid month
+
+ def test_tvshow_nfo_dateadded_validation(self):
+ """Test dateadded format validation."""
+ # Valid format
+ nfo = TVShowNFO(title="Test", dateadded="2024-12-15 10:29:11")
+ assert nfo.dateadded == "2024-12-15 10:29:11"
+
+ # Invalid formats
+ with pytest.raises(ValidationError) as exc_info:
+ TVShowNFO(title="Test", dateadded="2024-12-15")
+ assert "YYYY-MM-DD HH:MM:SS" in str(exc_info.value)
+
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test", dateadded="2024-12-15 25:00:00")
+
+ def test_tvshow_nfo_with_images(self):
+ """Test TVShowNFO with image information."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ thumb=[
+ ImageInfo(
+ url="https://image.tmdb.org/t/p/w500/poster.jpg",
+ aspect="poster"
+ ),
+ ImageInfo(
+ url="https://image.tmdb.org/t/p/original/logo.png",
+ aspect="clearlogo"
+ )
+ ],
+ fanart=[
+ ImageInfo(
+ url="https://image.tmdb.org/t/p/original/fanart.jpg"
+ )
+ ]
+ )
+
+ assert len(nfo.thumb) == 2
+ assert nfo.thumb[0].aspect == "poster"
+ assert len(nfo.fanart) == 1
+
+ def test_tvshow_nfo_with_actors(self):
+ """Test TVShowNFO with cast information."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ actors=[
+ ActorInfo(
+ name="Actor One",
+ role="Main Character",
+ thumb="https://example.com/actor1.jpg",
+ tmdbid=111
+ ),
+ ActorInfo(
+ name="Actor Two",
+ role="Supporting Role"
+ )
+ ]
+ )
+
+ assert len(nfo.actors) == 2
+ assert nfo.actors[0].name == "Actor One"
+ assert nfo.actors[0].role == "Main Character"
+ assert nfo.actors[1].tmdbid is None
+
+ def test_tvshow_nfo_with_named_seasons(self):
+ """Test TVShowNFO with named seasons."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ namedseason=[
+ NamedSeason(number=1, name="First Season"),
+ NamedSeason(number=2, name="Second Season")
+ ]
+ )
+
+ assert len(nfo.namedseason) == 2
+ assert nfo.namedseason[0].number == 1
+
+ def test_tvshow_nfo_with_trailer(self):
+ """Test TVShowNFO with trailer URL."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ trailer="https://www.youtube.com/watch?v=abc123"
+ )
+
+ assert nfo.trailer is not None
+ assert "youtube.com" in str(nfo.trailer)
+
+ def test_tvshow_nfo_watched_and_playcount(self):
+ """Test TVShowNFO with viewing information."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ watched=True,
+ playcount=5
+ )
+
+ assert nfo.watched is True
+ assert nfo.playcount == 5
+
+ def test_tvshow_nfo_negative_playcount_rejected(self):
+ """Test that negative playcount is rejected."""
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test", playcount=-1)
+
+ def test_tvshow_nfo_serialization(self):
+ """Test TVShowNFO can be serialized to dict."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ year=2020,
+ genre=["Action", "Drama"],
+ tmdbid=12345
+ )
+
+ data = nfo.model_dump()
+
+ assert data["title"] == "Test Show"
+ assert data["year"] == 2020
+ assert data["genre"] == ["Action", "Drama"]
+ assert data["tmdbid"] == 12345
+ assert "showtitle" in data
+ assert "originaltitle" in data
+
+ def test_tvshow_nfo_deserialization(self):
+ """Test TVShowNFO can be deserialized from dict."""
+ data = {
+ "title": "Test Show",
+ "year": 2020,
+ "genre": ["Action"],
+ "tmdbid": 12345,
+ "premiered": "2020-01-01"
+ }
+
+ nfo = TVShowNFO(**data)
+
+ assert nfo.title == "Test Show"
+ assert nfo.year == 2020
+ assert nfo.genre == ["Action"]
+ assert nfo.tmdbid == 12345
+
+ def test_tvshow_nfo_special_characters_in_title(self):
+ """Test TVShowNFO handles special characters."""
+ nfo = TVShowNFO(
+ title="Test: Show & Movie's \"Best\" ",
+ plot="Special chars: < > & \" '"
+ )
+
+ assert nfo.title == "Test: Show & Movie's \"Best\" "
+ assert nfo.plot == "Special chars: < > & \" '"
+
+ def test_tvshow_nfo_unicode_characters(self):
+ """Test TVShowNFO handles Unicode characters."""
+ nfo = TVShowNFO(
+ title="進撃の巨人",
+ originaltitle="Shingeki no Kyojin",
+ plot="日本のアニメシリーズ"
+ )
+
+ assert nfo.title == "進撃の巨人"
+ assert nfo.plot == "日本のアニメシリーズ"
+
+ def test_tvshow_nfo_none_values(self):
+ """Test TVShowNFO handles None values correctly."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ plot=None,
+ year=None,
+ tmdbid=None
+ )
+
+ assert nfo.title == "Test Show"
+ assert nfo.plot is None
+ assert nfo.year is None
+ assert nfo.tmdbid is None
+
+ def test_tvshow_nfo_empty_lists(self):
+ """Test TVShowNFO with empty lists."""
+ nfo = TVShowNFO(
+ title="Test Show",
+ genre=[],
+ actors=[],
+ ratings=[]
+ )
+
+ assert nfo.genre == []
+ assert nfo.actors == []
+ assert nfo.ratings == []
+
+ @pytest.mark.parametrize("year", [1900, 2000, 2025, 2100])
+ def test_tvshow_nfo_valid_years(self, year):
+ """Test TVShowNFO accepts valid years."""
+ nfo = TVShowNFO(title="Test Show", year=year)
+ assert nfo.year == year
+
+ @pytest.mark.parametrize("year", [1899, 2101, -1])
+ def test_tvshow_nfo_invalid_years(self, year):
+ """Test TVShowNFO rejects invalid years."""
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test Show", year=year)
+
+ @pytest.mark.parametrize("imdbid", [
+ "tt0123456",
+ "tt1234567",
+ "tt12345678",
+ "tt123456789"
+ ])
+ def test_tvshow_nfo_valid_imdbids(self, imdbid):
+ """Test TVShowNFO accepts valid IMDB IDs."""
+ nfo = TVShowNFO(title="Test Show", imdbid=imdbid)
+ assert nfo.imdbid == imdbid
+
+ @pytest.mark.parametrize("imdbid", [
+ "123456",
+ "tt",
+ "ttabc123",
+ "TT123456",
+ "tt-123456"
+ ])
+ def test_tvshow_nfo_invalid_imdbids(self, imdbid):
+ """Test TVShowNFO rejects invalid IMDB IDs."""
+ with pytest.raises(ValidationError):
+ TVShowNFO(title="Test Show", imdbid=imdbid)