Rework Curator into a Jellyfin-first library manager

This commit is contained in:
2026-08-31 18:34:03 +02:00
parent 1c78ca9013
commit 6a2baf243f
25 changed files with 2420 additions and 1502 deletions
-455
View File
@@ -1,455 +0,0 @@
"""Tests for CSFD.cz scraper module."""
import pytest
from unittest.mock import patch, MagicMock
from src.core.csfd import (
CSFDMovie,
fetch_movie,
search_movies,
fetch_movie_by_id,
_extract_csfd_id,
_parse_duration,
_extract_json_ld,
_extract_rating,
_extract_poster,
_extract_plot,
_extract_genres,
_extract_origin_info,
_check_dependencies,
_solve_anubis_pow,
_split_countries,
rating_band,
clean_filename_to_query,
find_csfd_url,
)
def _mock_session(mock_requests):
"""Wire ``mock_requests`` so ``requests.Session()`` (also as a context
manager) yields a single configurable session mock and return it."""
session = MagicMock()
session.__enter__.return_value = session
mock_requests.Session.return_value = session
return session
# Sample HTML for testing
SAMPLE_JSON_LD = """
{
"@type": "Movie",
"name": "Test Movie",
"director": [{"@type": "Person", "name": "Test Director"}],
"actor": [{"@type": "Person", "name": "Actor 1"}, {"@type": "Person", "name": "Actor 2"}],
"aggregateRating": {"ratingValue": 85.5, "ratingCount": 1000},
"duration": "PT120M",
"description": "A test movie description."
}
"""
SAMPLE_HTML = """
<html>
<head>
<script type="application/ld+json">%s</script>
</head>
<body>
<div class="film-rating-average">85%%</div>
<div class="genres">
<a href="/zanry/1/">Drama</a> /
<a href="/zanry/2/">Thriller</a>
</div>
<div class="origin">Česko, 2020, 120 min</div>
<div class="film-poster">
<img src="//image.example.com/poster.jpg">
</div>
<div class="plot-full"><p>Full plot description.</p></div>
</body>
</html>
""" % SAMPLE_JSON_LD
class TestCSFDMovie:
"""Tests for CSFDMovie dataclass."""
def test_csfd_movie_basic(self):
"""Test basic CSFDMovie creation."""
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
assert movie.title == "Test"
assert movie.url == "https://csfd.cz/film/123/"
assert movie.year is None
assert movie.genres == []
assert movie.rating is None
def test_csfd_movie_full(self):
"""Test CSFDMovie with all fields."""
movie = CSFDMovie(
title="Test Movie",
url="https://csfd.cz/film/123/",
year=2020,
genres=["Drama", "Thriller"],
directors=["Director 1"],
actors=["Actor 1", "Actor 2"],
rating=85,
rating_count=1000,
duration=120,
countries=["Česko"],
poster_url="https://image.example.com/poster.jpg",
plot="A test movie.",
csfd_id=123
)
assert movie.year == 2020
assert movie.genres == ["Drama", "Thriller"]
assert movie.rating == 85
assert movie.duration == 120
assert movie.countries == ["Česko"]
assert movie.csfd_id == 123
def test_csfd_movie_str(self):
"""Test CSFDMovie string representation."""
movie = CSFDMovie(
title="Test Movie",
url="https://csfd.cz/film/123/",
year=2020,
genres=["Drama"],
directors=["Director 1"],
rating=85
)
s = str(movie)
assert "Test Movie (2020)" in s
assert "85%" in s
assert "Drama" in s
assert "Director 1" in s
def test_csfd_movie_str_minimal(self):
"""Test CSFDMovie string with minimal data."""
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
s = str(movie)
assert "Test" in s
class TestHelperFunctions:
"""Tests for helper functions."""
def test_extract_csfd_id_valid(self):
"""Test extracting CSFD ID from valid URL."""
assert _extract_csfd_id("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/") == 9423
assert _extract_csfd_id("https://www.csfd.cz/film/123456/") == 123456
assert _extract_csfd_id("/film/999/prehled/") == 999
def test_extract_csfd_id_invalid(self):
"""Test extracting CSFD ID from invalid URL."""
assert _extract_csfd_id("https://www.csfd.cz/") is None
assert _extract_csfd_id("not-a-url") is None
def test_parse_duration_valid(self):
"""Test parsing ISO 8601 duration."""
assert _parse_duration("PT97M") == 97
assert _parse_duration("PT120M") == 120
assert _parse_duration("PT60M") == 60
def test_parse_duration_invalid(self):
"""Test parsing invalid duration."""
assert _parse_duration("") is None
assert _parse_duration("invalid") is None
def test_split_countries_single(self):
"""A single country yields a one-item list."""
assert _split_countries("USA") == ["USA"]
def test_split_countries_multiple(self):
"""Slash-separated co-production countries are split and trimmed."""
assert _split_countries("USA / Velká Británie") == ["USA", "Velká Británie"]
assert _split_countries("Japonsko/USA") == ["Japonsko", "USA"]
def test_split_countries_empty(self):
"""None/empty yields an empty list."""
assert _split_countries(None) == []
assert _split_countries("") == []
def test_from_dict_migrates_legacy_country(self):
"""Legacy cache with a single 'country' string maps to countries list."""
movie = CSFDMovie.from_dict({"title": "X", "country": "USA / Kanada"})
assert movie.countries == ["USA", "Kanada"]
def test_from_dict_uses_countries_when_present(self):
"""New cache with 'countries' is used verbatim."""
movie = CSFDMovie.from_dict({"title": "X", "countries": ["Japonsko", "USA"]})
assert movie.countries == ["Japonsko", "USA"]
def test_rating_band_buckets(self):
"""Rating is bucketed into ten-point bands, top band spans 90–100 %."""
assert rating_band(0) == "0–9 %"
assert rating_band(86) == "80–89 %"
assert rating_band(90) == "90–100 %"
assert rating_band(95) == "90–100 %"
assert rating_band(100) == "90–100 %"
def test_csfd_field_values_are_exact_no_transform(self):
from src.core.csfd import csfd_field_values
movie = CSFDMovie(title="X", url="u", year=1999, rating=86,
genres=["Akční", "Sci-Fi"], countries=["USA", "Kanada"])
assert csfd_field_values(movie, "genres") == ["Akční", "Sci-Fi"]
assert csfd_field_values(movie, "countries") == ["USA", "Kanada"]
assert csfd_field_values(movie, "year") == ["1999"]
# rating tag carries the EXACT value (transform happens only for folders)
assert csfd_field_values(movie, "rating") == ["86"]
# missing field / value → empty
assert csfd_field_values(CSFDMovie(title="X", url="u"), "rating") == []
assert csfd_field_values(movie, "genres") == csfd_field_values(movie, "genres")
def test_apply_transform_decade_band(self):
from src.core.csfd import apply_transform
assert apply_transform("86", "decade_band") == "80–89 %"
assert apply_transform("90", "decade_band") == "90–100 %"
assert apply_transform("Akční", None) == "Akční" # identity for non-rating
assert apply_transform("USA", "identity") == "USA"
assert _parse_duration("PT") is None
class TestHTMLExtraction:
"""Tests for HTML extraction functions."""
@pytest.fixture
def soup(self):
"""Create BeautifulSoup object from sample HTML."""
from bs4 import BeautifulSoup
return BeautifulSoup(SAMPLE_HTML, "html.parser")
def test_extract_json_ld(self, soup):
"""Test extracting data from JSON-LD."""
data = _extract_json_ld(soup)
assert data["title"] == "Test Movie"
assert data["directors"] == ["Test Director"]
assert data["actors"] == ["Actor 1", "Actor 2"]
assert data["rating"] == 86 # Rounded from 85.5
assert data["rating_count"] == 1000
assert data["duration"] == 120
def test_extract_rating(self, soup):
"""Test extracting rating from HTML."""
rating = _extract_rating(soup)
assert rating == 85
def test_extract_genres(self, soup):
"""Test extracting genres from HTML."""
genres = _extract_genres(soup)
assert "Drama" in genres
assert "Thriller" in genres
def test_extract_poster(self, soup):
"""Test extracting poster URL."""
poster = _extract_poster(soup)
assert poster == "https://image.example.com/poster.jpg"
def test_extract_plot(self, soup):
"""Test extracting plot."""
plot = _extract_plot(soup)
assert plot == "Full plot description."
def test_extract_origin_info(self, soup):
"""Test extracting origin info (comma-separated legacy format)."""
info = _extract_origin_info(soup)
assert info["countries"] == ["Česko"]
assert info["year"] == 2020
assert info["duration"] == 120
def test_extract_origin_info_bullet_format(self):
"""Test current CSFD format with inline bullet spans (no commas)."""
from bs4 import BeautifulSoup
html = (
'<div class="origin">USA <span class="bullet"></span>'
'<span>1999 <span class="bullet"></span> </span>'
'136 min (Alternativní 131 min)</div>'
)
info = _extract_origin_info(BeautifulSoup(html, "html.parser"))
assert info["countries"] == ["USA"]
assert info["year"] == 1999
assert info["duration"] == 136
def test_extract_origin_info_multiple_countries(self):
"""A co-production lists several slash-separated countries."""
from bs4 import BeautifulSoup
html = (
'<div class="origin">USA / Velká Británie '
'<span class="bullet"></span><span>2009 </span>'
'<span class="bullet"></span> 166 min</div>'
)
info = _extract_origin_info(BeautifulSoup(html, "html.parser"))
assert info["countries"] == ["USA", "Velká Británie"]
assert info["year"] == 2009
assert info["duration"] == 166
def test_extract_json_ld_year_from_date_created(self):
"""Year is taken from JSON-LD dateCreated when present."""
from bs4 import BeautifulSoup
html = (
'<script type="application/ld+json">'
'{"@type": "Movie", "name": "Matrix", "dateCreated": 1999}'
'</script>'
)
data = _extract_json_ld(BeautifulSoup(html, "html.parser"))
assert data["year"] == 1999
class TestCleanFilenameToQuery:
"""Tests for turning a filename into a ČSFD search query."""
def test_strips_release_tags_and_keeps_year(self):
assert clean_filename_to_query(
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv") == "Matrix 1999"
def test_handles_spaces_and_parens_year(self):
assert clean_filename_to_query(
"Forrest Gump (1994) 2160p HDR.mkv") == "Forrest Gump 1994"
def test_no_year_no_markers(self):
assert clean_filename_to_query("Amelie.mkv") == "Amelie"
def test_underscores_and_resolution(self):
assert clean_filename_to_query("Sam_doma_720p.mkv") == "Sam doma"
def test_falls_back_to_stem_when_starting_with_marker(self):
# No real title words before the marker → fall back to the cleaned stem
assert clean_filename_to_query("1080p.mkv") == "1080p"
class TestFindCsfdUrl:
"""Tests for find_csfd_url (search is mocked)."""
def test_returns_first_result_url(self):
from unittest.mock import patch
movies = [
CSFDMovie(title="Matrix", url="https://www.csfd.cz/film/9499-matrix/"),
CSFDMovie(title="Matrix Reloaded", url="https://www.csfd.cz/film/9497-x/"),
]
with patch("src.core.csfd.search_movies", return_value=movies):
assert find_csfd_url("Matrix 1999") == "https://www.csfd.cz/film/9499-matrix/"
def test_returns_none_for_empty_query(self):
assert find_csfd_url(" ") is None
def test_returns_none_when_no_results(self):
from unittest.mock import patch
with patch("src.core.csfd.search_movies", return_value=[]):
assert find_csfd_url("nonexistent film") is None
class TestFetchMovie:
"""Tests for fetch_movie function."""
@patch("src.core.csfd.requests")
def test_fetch_movie_success(self, mock_requests):
"""Test successful movie fetch."""
mock_response = MagicMock()
mock_response.text = SAMPLE_HTML
mock_response.raise_for_status = MagicMock()
session = _mock_session(mock_requests)
session.get.return_value = mock_response
movie = fetch_movie("https://www.csfd.cz/film/123-test/")
assert movie.title == "Test Movie"
assert movie.csfd_id == 123
assert movie.rating == 86
assert "Drama" in movie.genres
session.get.assert_called_once()
@patch("src.core.csfd.requests")
def test_fetch_movie_caps_actors_at_ten(self, mock_requests):
"""Only the first MAX_ACTORS (10) of a long cast are kept."""
import json as _json
actors = [{"@type": "Person", "name": f"Actor {i}"} for i in range(25)]
json_ld = _json.dumps({
"@type": "Movie", "name": "Crowded", "actor": actors,
"director": [{"@type": "Person", "name": "Dir"}],
"aggregateRating": {"ratingValue": 70, "ratingCount": 5},
})
html = f'<html><head><script type="application/ld+json">{json_ld}</script></head></html>'
mock_response = MagicMock()
mock_response.text = html
mock_response.raise_for_status = MagicMock()
session = _mock_session(mock_requests)
session.get.return_value = mock_response
movie = fetch_movie("https://www.csfd.cz/film/1-crowded/")
assert movie.directors == ["Dir"]
assert movie.rating == 70
assert len(movie.actors) == 10
assert movie.actors[0] == "Actor 0"
assert movie.actors[-1] == "Actor 9"
@patch("src.core.csfd.requests")
def test_fetch_movie_network_error(self, mock_requests):
"""Test network error handling."""
import requests as real_requests
session = _mock_session(mock_requests)
session.get.side_effect = real_requests.RequestException("Network error")
with pytest.raises(real_requests.RequestException):
fetch_movie("https://www.csfd.cz/film/123/")
class TestSearchMovies:
"""Tests for search_movies function."""
@patch("src.core.csfd.requests")
def test_search_movies(self, mock_requests):
"""Test movie search."""
search_html = """
<html><body>
<a href="/film/123-test/" class="film-title-name">Test Movie</a>
<a href="/film/456-another/" class="film-title-name">Another Movie</a>
</body></html>
"""
mock_response = MagicMock()
mock_response.text = search_html
mock_response.raise_for_status = MagicMock()
session = _mock_session(mock_requests)
session.get.return_value = mock_response
mock_requests.utils.quote = lambda x: x
results = search_movies("test", limit=10)
assert len(results) >= 1
assert any(m.csfd_id == 123 for m in results)
class TestFetchMovieById:
"""Tests for fetch_movie_by_id function."""
@patch("src.core.csfd.fetch_movie")
def test_fetch_by_id(self, mock_fetch):
"""Test fetching movie by ID."""
mock_fetch.return_value = CSFDMovie(title="Test", url="https://csfd.cz/film/9423/")
movie = fetch_movie_by_id(9423)
mock_fetch.assert_called_once_with("https://www.csfd.cz/film/9423/")
assert movie.title == "Test"
class TestAnubisPoW:
"""Tests for the Anubis proof-of-work solver."""
def test_solve_pow_difficulty_one(self):
"""Difficulty 1 requires a single leading zero nibble in the hash."""
import hashlib
random_data = "abc123"
hash_hex, nonce, _ = _solve_anubis_pow(random_data, difficulty=1)
assert hash_hex[0] == "0"
assert hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest() == hash_hex
def test_solve_pow_difficulty_two(self):
"""Difficulty 2 requires two leading zero nibbles (one zero byte)."""
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
assert hash_hex[:2] == "00"
class TestDependencyCheck:
"""Tests for dependency checking."""
def test_dependencies_available(self):
"""Test that dependencies are available (they should be in test env)."""
# Should not raise
_check_dependencies()
+24 -53
View File
@@ -207,8 +207,8 @@ class TestFile:
assert file_obj2.date == "2025-01-01"
class TestApplyCsfdTags:
"""Tests for File.apply_csfd_tags tag assignment (CSFD fetch is mocked)."""
class TestApplyNfoTags:
"""Tests for File.apply_nfo tag assignment (NFO parsed from disk)."""
@pytest.fixture
def tag_manager(self):
@@ -222,22 +222,14 @@ class TestApplyCsfdTags:
def movie_file(self, tmp_path, tag_manager, index):
path = tmp_path / "Matrix.mkv"
path.write_text("x")
f = File(path, tag_manager, index=index)
f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/")
return f
return File(path, tag_manager, index=index)
def test_apply_csfd_tags_assigns_expected_categories(self, movie_file):
from unittest.mock import patch
from src.core.csfd import CSFDMovie
def test_apply_nfo_assigns_expected_categories(self, movie_file):
from src.core.nfo import NfoData
movie = CSFDMovie(
title="Matrix", url="u", year=1999, genres=["Akční", "Sci-Fi"],
directors=["Lana Wachowski", "Lilly Wachowski"],
actors=["Keanu Reeves", "Laurence Fishburne"],
rating=90, countries=["USA"],
)
with patch("src.core.csfd.fetch_movie", return_value=movie):
result = movie_file.apply_csfd_tags()
nfo = NfoData(title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
countries=["USA"], rating=90)
result = movie_file.apply_nfo(nfo)
assert result["success"]
paths = {t.full_path for t in movie_file.tags}
@@ -247,28 +239,14 @@ class TestApplyCsfdTags:
assert "Země původu/USA" in paths
assert "Hodnocení/90" in paths # exact value; folder grouping happens later
def test_apply_csfd_tags_does_not_tag_directors_or_actors(self, movie_file):
"""Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc)."""
from unittest.mock import patch
from src.core.csfd import CSFDMovie
movie = CSFDMovie(
title="Matrix", url="u", directors=["Lana Wachowski"],
actors=["Keanu Reeves", "Laurence Fishburne"], genres=["Drama"],
)
with patch("src.core.csfd.fetch_movie", return_value=movie):
movie_file.apply_csfd_tags()
paths = {t.full_path for t in movie_file.tags}
assert not any(p.startswith("Režie/") for p in paths)
assert not any(p.startswith("Herec/") for p in paths)
cached = movie_file.get_cached_movie()
assert cached.directors == ["Lana Wachowski"]
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
def test_apply_nfo_none_is_noop(self, movie_file):
result = movie_file.apply_nfo(None)
assert not result["success"]
assert movie_file.tags == []
def test_name_context_fields(self, movie_file):
movie_file.title = "Dr. No"
movie_file.csfd_cache = {"year": 1962, "rating": 75}
movie_file.meta = {"year": 1962, "rating": 75}
ctx = movie_file.name_context()
assert ctx["title"] == "Dr. No"
assert ctx["year"] == "1962" # coerced to str for uniform template fields
@@ -278,7 +256,7 @@ class TestApplyCsfdTags:
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
def test_name_context_year_from_tag_when_no_cache(self, movie_file):
movie_file.csfd_cache = None
movie_file.meta = None
movie_file.add_tag("Rok/1999")
assert movie_file.name_context()["year"] == "1999"
@@ -315,37 +293,30 @@ class TestApplyCsfdTags:
assert "{collection_sort} - {title}{ext}".format(**ctx) == \
f"01 - Dr. No{ctx['ext']}"
def test_apply_csfd_tags_honors_custom_schema(self, movie_file):
def test_apply_nfo_honors_custom_schema(self, movie_file):
"""A schema without the rating entry produces no Hodnocení tags."""
from unittest.mock import patch
from src.core.csfd import CSFDMovie
from src.core.nfo import NfoData
schema = [{"category": "Žánr", "csfd_field": "genres",
"transform": None, "filmoteka_root": ""}]
movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"])
with patch("src.core.csfd.fetch_movie", return_value=movie):
movie_file.apply_csfd_tags(schema)
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
paths = {t.full_path for t in movie_file.tags}
assert "Žánr/Drama" in paths
assert not any(p.startswith("Hodnocení/") for p in paths)
def test_apply_csfd_tags_preserves_user_tags_on_refetch(self, movie_file):
"""Re-fetching regenerates only ČSFD tags; user-added tags survive."""
from unittest.mock import patch
from src.core.csfd import CSFDMovie
def test_apply_nfo_preserves_user_tags_on_refresh(self, movie_file):
"""Re-reading regenerates only auto (NFO) tags; user-added tags survive."""
from src.core.nfo import NfoData
movie_file.add_tag("Sbírka/Oblíbené") # user tag
first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"])
with patch("src.core.csfd.fetch_movie", return_value=first):
movie_file.apply_csfd_tags()
second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"])
with patch("src.core.csfd.fetch_movie", return_value=second):
movie_file.apply_csfd_tags()
movie_file.apply_nfo(NfoData(year="1999", genres=["Akční"]))
movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"]))
paths = {t.full_path for t in movie_file.tags}
assert "Sbírka/Oblíbené" in paths # user tag kept
assert "Žánr/Drama" in paths # new ČSFD tag
assert "Žánr/Drama" in paths # new NFO tag
assert "Rok/2009" in paths
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
assert "Rok/1999" not in paths
+93 -10
View File
@@ -203,22 +203,21 @@ class TestPoolManagement:
source = tmp_path / "raw.mkv"
source.write_bytes(b"x" * 10)
movie = file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1")
movie = file_manager.import_movie(source, "Matrix")
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
assert source.exists() # non-destructive copy
assert movie.title == "Matrix"
assert movie.csfd_link == "https://csfd.cz/film/1"
assert file_manager.index.get(movie.file_path) is not None
def test_statistics_aggregates_pool(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x" * 100)
(tmp_path / "b.mkv").write_bytes(b"y" * 50)
a = file_manager.import_movie(tmp_path / "a.mkv", "A", "https://csfd/1")
a = file_manager.import_movie(tmp_path / "a.mkv", "A")
a.add_tag("Žánr/Akční")
a.add_tag("Žánr/Sci-Fi")
a.csfd_cache = {"rating": 90}
a.meta = {"rating": 90}
a.save_metadata()
b = file_manager.import_movie(tmp_path / "b.mkv", "B")
b.add_tag("Žánr/Akční")
@@ -226,7 +225,7 @@ class TestPoolManagement:
s = file_manager.statistics()
assert s["count"] == 2
assert s["total_size"] == 150
assert s["with_csfd"] == 1 and s["without_csfd"] == 1
assert s["with_nfo"] == 1 and s["without_nfo"] == 1
assert s["untagged"] == 0
assert s["avg_rating"] == 90
# Akční on both movies → count 2, sorted first
@@ -319,7 +318,7 @@ class TestPoolManagement:
old_path = first.file_path
second = file_manager.import_movie(
tmp_path / "b.mkv", "Matrix", csfd_link="x", on_conflict="replace")
tmp_path / "b.mkv", "Matrix", on_conflict="replace")
assert second.file_path.name == "Matrix.mkv"
assert second.file_path == old_path # same name reused
@@ -328,7 +327,6 @@ class TestPoolManagement:
# index record reflects the new import (fresh metadata, old tags dropped)
record = file_manager.index.get(second.file_path)
assert record is not None
assert record["csfd_link"] == "x"
assert record["tags"] == []
assert second.tags == []
@@ -419,6 +417,91 @@ class TestPoolManagement:
assert movie.file_path.name == "Matrix (1999).mkv"
assert movie.title == "Matrix" # clean title, no year
def test_import_movie_with_tmdb_id_names_for_jellyfin(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603)
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
assert movie.title == "Matrix" # clean title, no year/id
def test_import_into_named_library(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "a.mkv", "Auta", year=2006, tmdb_id=920, library="Kids")
assert movie.file_path == tmp_path / "pool" / "Kids" / "Auta (2006) [tmdbid-920].mkv"
assert file_manager.library_of(movie) == "Kids"
def test_move_to_library_reindexes(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006)
old_key = movie.file_path.relative_to(tmp_path / "pool").as_posix()
file_manager.move_to_library(movie, "Kids")
assert file_manager.library_of(movie) == "Kids"
assert movie.file_path.exists()
assert file_manager.index.records.get(old_key) is None # old key dropped
new_key = movie.file_path.relative_to(tmp_path / "pool").as_posix()
assert file_manager.index.records.get(new_key) is not None
def test_move_to_unknown_library_rejected(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006)
with pytest.raises(ValueError):
file_manager.move_to_library(movie, "Neexistuje")
def test_load_scans_all_movie_libraries(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "a.mkv").write_bytes(b"x")
(tmp_path / "b.mkv").write_bytes(b"y")
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
file_manager.import_movie(tmp_path / "b.mkv", "Auta", year=2006, library="Kids")
reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool")
reloaded.load_pool_movies()
libs = sorted(reloaded.library_of(f) for f in reloaded.filelist)
assert libs == ["Filmy", "Kids"]
def test_load_skips_jellyfin_sidecars(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
# Jellyfin writes these next to the movie — must not count as movies
movie.file_path.with_suffix(".nfo").write_text("<movie/>")
(movie.file_path.parent / "poster.jpg").write_bytes(b"img")
reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool")
reloaded.load_pool_movies()
assert len(reloaded.filelist) == 1
assert reloaded.filelist[0].file_path.suffix == ".mkv"
def test_rename_to_canonical_preserves_tmdb_id(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603)
movie.title = "Matrix Reloaded" # metadata title changed
renamed = file_manager.rename_to_canonical(movie)
assert renamed is not None
assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv"
def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"a")
@@ -441,7 +524,7 @@ class TestPoolManagement:
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix") # plain
assert movie.file_path.name == "Matrix.mkv"
movie.csfd_cache = {"year": 1999}
movie.meta = {"year": 1999}
renamed = file_manager.rename_to_canonical(movie)
assert renamed is not None
@@ -461,7 +544,7 @@ class TestPoolManagement:
for n in ("a.mkv", "b.mkv", "c.mkv"):
(tmp_path / n).write_bytes(b"x")
with_year = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
with_year.csfd_cache = {"year": 1999}
with_year.meta = {"year": 1999}
already = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=1972)
_no_year = file_manager.import_movie(tmp_path / "c.mkv", "Mystery")
@@ -476,7 +559,7 @@ class TestPoolManagement:
file_manager.set_pool_dir(tmp_path / "pool")
source = tmp_path / "raw.mkv"
source.write_bytes(b"x" * 10)
file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1")
file_manager.import_movie(source, "Matrix")
reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool")
+19 -4
View File
@@ -227,6 +227,21 @@ class TestHardlinkManager:
assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped
assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
def test_link_name_strips_year_and_tmdbid(self, tmp_path, tag_manager):
"""A pooled 'Title (YYYY) [tmdbid-N].ext' links as clean 'Title.ext'."""
source = tmp_path / "source"
source.mkdir()
(source / "Matrix (1999) [tmdbid-603].mkv").write_text("x")
f = File(source / "Matrix (1999) [tmdbid-603].mkv", tag_manager)
f.tags.clear()
f.add_tag(Tag("žánr", "Sci-Fi"))
output = tmp_path / "output"
manager = HardlinkManager(output)
manager.create_structure_for_files([f], category_roots={"žánr": ""})
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
def test_link_name_keeps_year_on_collision(
self, tmp_path, tag_manager
):
@@ -238,13 +253,13 @@ class TestHardlinkManager:
f1 = File(source / "Solaris (1972).mkv", tag_manager)
f1.tags.clear()
f1.title = "Solaris"
f1.csfd_cache = {"year": 1972}
f1.meta = {"year": 1972}
f1.add_tag(Tag("žánr", "Sci-Fi"))
f1.add_tag(Tag("rok", "1972"))
f2 = File(source / "Solaris (2002).mkv", tag_manager)
f2.tags.clear()
f2.title = "Solaris"
f2.csfd_cache = {"year": 2002}
f2.meta = {"year": 2002}
f2.add_tag(Tag("žánr", "Sci-Fi"))
f2.add_tag(Tag("rok", "2002"))
@@ -323,7 +338,7 @@ class TestHardlinkManager:
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.title = "Dr. No"
f.csfd_cache = {"year": 1962}
f.meta = {"year": 1962}
f.add_tag(Tag("Kolekce", "James Bond"))
f.add_tag(Tag("žánr", "Akční"))
manager = HardlinkManager(temp_output_dir)
@@ -345,7 +360,7 @@ class TestHardlinkManager:
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.title = "Dr. No"
f.csfd_cache = {"year": 1962}
f.meta = {"year": 1962}
f.add_tag(Tag("Kolekce", "James Bond"))
roots = {"Kolekce": "Dle kolekce"}
templates = {"Kolekce": "{year} - {title}{ext}"}
+107
View File
@@ -0,0 +1,107 @@
"""Tests for the Jellyfin client + pool verification. Network is monkeypatched."""
from types import SimpleNamespace
import pytest
from src.core import jellyfin
from src.core.jellyfin import JellyfinError, JellyfinMovie
@pytest.fixture(autouse=True)
def _configured(monkeypatch):
monkeypatch.setenv("JELLYFIN_URL", "http://jf:8096/")
monkeypatch.setenv("JELLYFIN_API_KEY", "key")
def _resp(status=200, payload=None):
return SimpleNamespace(status_code=status, ok=(200 <= status < 300),
json=lambda: (payload or {}))
def test_base_url_strips_trailing_slash():
assert jellyfin.base_url() == "http://jf:8096"
def test_api_available(monkeypatch):
assert jellyfin.api_available()
monkeypatch.delenv("JELLYFIN_API_KEY", raising=False)
assert not jellyfin.api_available()
def test_server_version(monkeypatch):
monkeypatch.setattr(jellyfin.requests, "request",
lambda *a, **k: _resp(200, {"Version": "10.9.11"}))
assert jellyfin.server_version() == "10.9.11"
def test_scan_all_posts_refresh(monkeypatch):
seen = {}
def fake(method, url, **k):
seen["method"], seen["url"] = method, url
return _resp(200, {})
monkeypatch.setattr(jellyfin.requests, "request", fake)
jellyfin.scan_all()
assert seen["method"] == "POST" and seen["url"].endswith("/Library/Refresh")
def test_fetch_movies_maps_tmdb_id(monkeypatch):
payload = {"Items": [
{"Id": "a1", "Name": "Matrix", "ProductionYear": 1999,
"ProviderIds": {"Tmdb": "603"}, "Path": "/m/Matrix (1999) [tmdbid-603].mkv"},
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
]}
monkeypatch.setattr(jellyfin.requests, "request", lambda *a, **k: _resp(200, payload))
movies = jellyfin.fetch_movies()
assert movies[0] == JellyfinMovie("a1", "Matrix", 1999, 603,
"/m/Matrix (1999) [tmdbid-603].mkv")
assert movies[1].tmdb_id is None
def test_missing_config_raises(monkeypatch):
monkeypatch.delenv("JELLYFIN_URL", raising=False)
with pytest.raises(JellyfinError):
jellyfin.server_version()
def test_bad_auth_raises(monkeypatch):
monkeypatch.setattr(jellyfin.requests, "request", lambda *a, **k: _resp(401))
with pytest.raises(JellyfinError):
jellyfin.scan_all()
class TestVerifyAgainstJellyfin:
@pytest.fixture
def file_manager(self, tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
def test_match_by_tmdb_id_and_report_gaps(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"x")
(tmp_path / "b.mkv").write_bytes(b"y")
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
file_manager.import_movie(tmp_path / "b.mkv", "Neznámý", year=2020) # no id
jf = [
JellyfinMovie("j1", "Matrix", 1999, 603, None), # matches a by tmdb
JellyfinMovie("j2", "Cizí film", 2011, 999, None), # jellyfin-only
]
result = file_manager.verify_against_jellyfin(jf)
assert result["matched"] == 1
assert result["pool_only"] == ["Neznámý (2020).mkv"]
assert result["jellyfin_only"] == ["Cizí film"]
assert result["pool_total"] == 2 and result["jellyfin_total"] == 2
def test_match_by_title_year_fallback(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"x")
file_manager.import_movie(tmp_path / "a.mkv", "Kolja", year=1996) # no id
jf = [JellyfinMovie("j1", "Kolja", 1996, None, None)]
result = file_manager.verify_against_jellyfin(jf)
assert result["matched"] == 1 and result["pool_only"] == []
+28
View File
@@ -6,6 +6,7 @@ from src.core.naming import (
is_canonical_pool_stem,
is_valid_year,
parse_pool_stem,
parse_tmdb_id,
year_from_filename,
)
@@ -46,6 +47,33 @@ def test_parse_pool_stem_non_canonical():
assert parse_pool_stem("Matrix 1999") is None
def test_canonical_pool_stem_with_tmdb_id():
assert canonical_pool_stem("Matrix", 1999, 603) == "Matrix (1999) [tmdbid-603]"
# empty/None id → plain stem, no brackets
assert canonical_pool_stem("Matrix", 1999, None) == "Matrix (1999)"
assert canonical_pool_stem("Matrix", 1999, "") == "Matrix (1999)"
def test_canonical_pool_stem_rejects_bad_id():
with pytest.raises(ValueError):
canonical_pool_stem("Matrix", 1999, "tt0133093")
def test_parse_pool_stem_strips_trailing_tmdb_id():
# title stays clean; the id is dropped from parse_pool_stem
assert parse_pool_stem("Matrix (1999) [tmdbid-603]") == ("Matrix", 1999)
def test_parse_tmdb_id():
assert parse_tmdb_id("Matrix (1999) [tmdbid-603]") == 603
assert parse_tmdb_id("Matrix (1999)") is None
assert parse_tmdb_id("Matrix") is None
def test_is_canonical_accepts_tmdb_id_form():
assert is_canonical_pool_stem("Matrix (1999) [tmdbid-603]")
def test_is_canonical_pool_stem():
assert is_canonical_pool_stem("Matrix (1999)")
assert not is_canonical_pool_stem("Matrix")
+103
View File
@@ -0,0 +1,103 @@
"""Tests for the NFO reader (src.core.nfo)."""
from src.core.nfo import NfoData, find_nfo, read_nfo
MOVIE_NFO = """<?xml version="1.0" encoding="utf-8"?>
<movie>
<title>Matrix</title>
<year>1999</year>
<genre>Akční</genre>
<genre>Sci-Fi</genre>
<country>USA</country>
<rating>8.7</rating>
<uniqueid type="tmdb">603</uniqueid>
</movie>
"""
def _movie(tmp_path):
v = tmp_path / "Matrix (1999) [tmdbid-603].mkv"
v.write_bytes(b"x")
return v
def test_read_nfo_sibling(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(MOVIE_NFO, encoding="utf-8")
nfo = read_nfo(video)
assert nfo == NfoData(
title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
countries=["USA"], rating=87, tmdb_id=603)
def test_rating_scaled_to_percent(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><rating>7.3</rating></movie>", encoding="utf-8")
assert read_nfo(video).rating == 73
def test_rating_from_ratings_block(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><ratings><rating name='tmdb'><value>9.1</value></rating>"
"</ratings></movie>", encoding="utf-8")
assert read_nfo(video).rating == 91
def test_tmdbid_from_tmdbid_tag(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><tmdbid>603</tmdbid></movie>", encoding="utf-8")
assert read_nfo(video).tmdb_id == 603
def test_find_prefers_sibling_over_movie_nfo(tmp_path):
folder = tmp_path / "Matrix (1999)"
folder.mkdir()
video = folder / "Matrix (1999).mkv"
video.write_bytes(b"x")
(folder / "movie.nfo").write_text(MOVIE_NFO, encoding="utf-8")
# no sibling → movie.nfo is used
assert find_nfo(video).name == "movie.nfo"
# sibling present → it wins
video.with_suffix(".nfo").write_text(MOVIE_NFO, encoding="utf-8")
assert find_nfo(video).name == "Matrix (1999).nfo"
def test_missing_nfo_returns_none(tmp_path):
assert read_nfo(_movie(tmp_path)) is None
def test_broken_nfo_returns_none(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text("<movie><genre>unclosed", encoding="utf-8")
assert read_nfo(video) is None
def test_field_values():
nfo = NfoData(year="1999", genres=["A", "B"], countries=["USA"], rating=87)
assert nfo.field_values("genres") == ["A", "B"]
assert nfo.field_values("countries") == ["USA"]
assert nfo.field_values("year") == ["1999"]
assert nfo.field_values("rating") == ["87"]
assert nfo.field_values("directors") == []
def test_refresh_tags_from_nfo(tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = fm.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603)
movie.file_path.with_suffix(".nfo").write_text(MOVIE_NFO, encoding="utf-8")
result = fm.refresh_tags_from_nfo()
assert result == {"updated": 1, "without_nfo": 0}
paths = {t.full_path for t in movie.tags}
assert {"Žánr/Akční", "Žánr/Sci-Fi", "Rok/1999", "Země původu/USA"} <= paths
assert movie.meta == {"year": "1999", "rating": 87}
+2 -2
View File
@@ -74,7 +74,7 @@ class TestFileWithIndex:
tm = TagManager()
f = File(movie, tm, index=index)
f.title = "Matrix"
f.csfd_link = "https://csfd.cz/film/1"
f.meta = {"year": "1999", "rating": 87}
f.add_tag("Žánr/Akční")
f.set_date("1999-03-31")
@@ -82,7 +82,7 @@ class TestFileWithIndex:
index2 = PoolIndex(tmp_path)
f2 = File(movie, TagManager(), index=index2)
assert f2.title == "Matrix"
assert f2.csfd_link == "https://csfd.cz/film/1"
assert f2.meta == {"year": "1999", "rating": 87}
assert f2.date == "1999-03-31"
assert "Žánr/Akční" in {t.full_path for t in f2.tags}
+114
View File
@@ -0,0 +1,114 @@
"""Tests for series naming/scanning (src.core.series) + FileManager episodes."""
import pytest
from src.core.series import (
episode_filename,
is_canonical_episode,
parse_episode,
scan_series,
season_folder_name,
series_folder_stem,
)
@pytest.mark.parametrize("name,expected", [
("Breaking Bad S01E02.mkv", (1, 2)),
("bb.s1e2.mkv", (1, 2)),
("Show 01x02 x264.mkv", (1, 2)),
("Series.S01E02E03.mkv", (1, 2)), # multi-ep → first
("Series S12E145.mkv", (12, 145)),
("No code here.mkv", None),
])
def test_parse_episode(name, expected):
assert parse_episode(name) == expected
def test_season_folder_name():
assert season_folder_name(0) == "Specials"
assert season_folder_name(1) == "Season 01"
assert season_folder_name(12) == "Season 12"
def test_episode_filename():
assert episode_filename("Breaking Bad", 1, 2, ".mkv") == "Breaking Bad S01E02.mkv"
def test_series_folder_stem():
assert series_folder_stem("Breaking Bad", 2008, 1396) == "Breaking Bad (2008) [tmdbid-1396]"
assert series_folder_stem("Neznámý", None, 42) == "Neznámý [tmdbid-42]"
assert series_folder_stem("Jen název", None, None) == "Jen název"
def test_is_canonical_episode(tmp_path):
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
folder.mkdir(parents=True)
good = folder / "Breaking Bad S01E02.mkv"
good.write_bytes(b"x")
bad = folder / "bb.s01e02.mkv"
bad.write_bytes(b"x")
assert is_canonical_episode(good, "Breaking Bad")
assert not is_canonical_episode(bad, "Breaking Bad")
def test_scan_series(tmp_path):
lib = tmp_path / "Seriály"
s1 = lib / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
s1.mkdir(parents=True)
(s1 / "Breaking Bad S01E01.mkv").write_bytes(b"x")
(s1 / "Breaking Bad S01E02.mkv").write_bytes(b"x")
(s1 / "poster.jpg").write_bytes(b"img") # sidecar → skipped
found = scan_series(lib, "Seriály")
assert len(found) == 1
ser = found[0]
assert ser.title == "Breaking Bad" and ser.year == "2008" and ser.tmdb_id == 1396
assert ser.library == "Seriály"
assert len(ser.episodes) == 2
seasons = ser.seasons()
assert list(seasons.keys()) == [1] and len(seasons[1]) == 2
class TestFileManagerSeries:
@pytest.fixture
def file_manager(self, tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
def test_import_episodes_places_them(self, file_manager, tmp_path):
for n in ("bb.S01E01.mkv", "bb.S01E02.mkv", "notes.txt_S00.dummy"):
(tmp_path / n).write_bytes(b"x")
result = file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S01E02.mkv"],
"Breaking Bad", year=2008, tmdb_id=1396)
assert result["imported"] == 2
base = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
assert (base / "Breaking Bad S01E01.mkv").exists()
assert (base / "Breaking Bad S01E02.mkv").exists()
def test_import_episodes_skips_uncoded(self, file_manager, tmp_path):
(tmp_path / "random.mkv").write_bytes(b"x")
result = file_manager.import_episodes(
[tmp_path / "random.mkv"], "Breaking Bad", year=2008)
assert result["imported"] == 0
assert result["skipped_no_code"] == ["random.mkv"]
def test_rename_episodes_to_canonical(self, file_manager, tmp_path):
# a mis-named episode sitting in the wrong place
folder = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]")
folder.mkdir(parents=True)
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
series_list = file_manager.load_series()
assert len(series_list) == 1
result = file_manager.rename_episodes_to_canonical(series_list[0])
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "Breaking Bad S01E03.mkv")]
assert (folder / "Season 01" / "Breaking Bad S01E03.mkv").exists()
+89
View File
@@ -0,0 +1,89 @@
"""Tests for the TMDb client (src.core.tmdb). Network is monkeypatched."""
from types import SimpleNamespace
import pytest
from src.core import tmdb
from src.core.tmdb import TmdbError, TmdbResult
@pytest.fixture(autouse=True)
def _with_key(monkeypatch):
monkeypatch.setenv("TMDB_API_KEY", "testkey")
def _resp(status=200, payload=None):
return SimpleNamespace(status_code=status, ok=(200 <= status < 300),
json=lambda: (payload or {}))
def test_api_available_reflects_key(monkeypatch):
assert tmdb.api_available()
monkeypatch.delenv("TMDB_API_KEY", raising=False)
assert not tmdb.api_available()
def test_lookup_movie_returns_title_and_year(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 603, "title": "Matrix", "release_date": "1999-03-31"}))
result = tmdb.lookup_movie(603)
assert result == TmdbResult(603, "Matrix", "1999", "movie")
def test_lookup_movie_falls_back_to_original_title(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 1, "original_title": "Solyaris", "release_date": "1972-01-01"}))
assert tmdb.lookup_movie(1).title == "Solyaris"
def test_lookup_movie_missing_date_gives_no_year(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 2, "title": "Bez data", "release_date": ""}))
assert tmdb.lookup_movie(2).year is None
def test_lookup_tv_uses_name_and_first_air(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 1396, "name": "Breaking Bad", "first_air_date": "2008-01-20"}))
result = tmdb.lookup(1396, kind="tv")
assert result == TmdbResult(1396, "Breaking Bad", "2008", "tv")
def test_search_movie_maps_results(monkeypatch):
payload = {"results": [
{"id": 603, "title": "Matrix", "release_date": "1999-03-31"},
{"id": 605, "title": "Matrix Revolutions", "release_date": "2003-11-05"},
]}
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
results = tmdb.search_movie("Matrix", year="1999")
assert [r.tmdb_id for r in results] == [603, 605]
assert results[0].year == "1999"
def test_search_movie_empty_query_skips_network(monkeypatch):
def boom(*a, **k):
raise AssertionError("should not hit network")
monkeypatch.setattr(tmdb.requests, "get", boom)
assert tmdb.search_movie(" ") == []
def test_missing_key_raises(monkeypatch):
monkeypatch.delenv("TMDB_API_KEY", raising=False)
with pytest.raises(TmdbError):
tmdb.lookup_movie(603)
def test_bad_status_raises(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(401, {}))
with pytest.raises(TmdbError):
tmdb.lookup_movie(603)
def test_network_error_raises(monkeypatch):
import requests
def boom(*a, **k):
raise requests.ConnectionError("down")
monkeypatch.setattr(tmdb.requests, "get", boom)
with pytest.raises(TmdbError):
tmdb.lookup_movie(603)