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)