Add NFO Pydantic models with comprehensive validation

- Create TVShowNFO, ActorInfo, RatingInfo, ImageInfo models
- Add validation for dates (YYYY-MM-DD), URLs, IMDB IDs
- Support all Kodi/XBMC standard fields
- Include nested models for ratings, actors, images
- Comprehensive unit tests with 61 tests
- Test coverage: 95.16% (exceeds 95% requirement)
- All tests passing
This commit is contained in:
2026-01-11 20:17:18 +01:00
parent 65b116c39f
commit 5e8815d143
4 changed files with 892 additions and 75 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -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
<thumb aspect="poster">https://image.tmdb.org/t/p/original/...</thumb>
<thumb aspect="logo">https://image.tmdb.org/t/p/original/...</thumb>
<fanart>
<thumb>https://image.tmdb.org/t/p/original/...</thumb>
</fanart>
```
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

View File

@@ -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

View File

@@ -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\" <Episode>",
plot="Special chars: < > & \" '"
)
assert nfo.title == "Test: Show & Movie's \"Best\" <Episode>"
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)