Rework Curator into a Jellyfin-first library manager
This commit is contained in:
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
@@ -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"] == []
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user