Auto-fill ČSFD links on import, rename in pool, multi-country tags, Filmotéka layout
This commit is contained in:
+121
-3
@@ -17,6 +17,10 @@ from src.core.csfd import (
|
||||
_extract_origin_info,
|
||||
_check_dependencies,
|
||||
_solve_anubis_pow,
|
||||
_split_countries,
|
||||
rating_band,
|
||||
clean_filename_to_query,
|
||||
find_csfd_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -87,7 +91,7 @@ class TestCSFDMovie:
|
||||
rating=85,
|
||||
rating_count=1000,
|
||||
duration=120,
|
||||
country="Česko",
|
||||
countries=["Česko"],
|
||||
poster_url="https://image.example.com/poster.jpg",
|
||||
plot="A test movie.",
|
||||
csfd_id=123
|
||||
@@ -96,6 +100,7 @@ class TestCSFDMovie:
|
||||
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):
|
||||
@@ -145,6 +150,38 @@ class TestHelperFunctions:
|
||||
"""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 %"
|
||||
assert _parse_duration("PT") is None
|
||||
|
||||
|
||||
@@ -191,7 +228,7 @@ class TestHTMLExtraction:
|
||||
def test_extract_origin_info(self, soup):
|
||||
"""Test extracting origin info (comma-separated legacy format)."""
|
||||
info = _extract_origin_info(soup)
|
||||
assert info["country"] == "Česko"
|
||||
assert info["countries"] == ["Česko"]
|
||||
assert info["year"] == 2020
|
||||
assert info["duration"] == 120
|
||||
|
||||
@@ -204,10 +241,23 @@ class TestHTMLExtraction:
|
||||
'136 min (Alternativní 131 min)</div>'
|
||||
)
|
||||
info = _extract_origin_info(BeautifulSoup(html, "html.parser"))
|
||||
assert info["country"] == "USA"
|
||||
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
|
||||
@@ -220,6 +270,49 @@ class TestHTMLExtraction:
|
||||
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."""
|
||||
|
||||
@@ -240,6 +333,31 @@ class TestFetchMovie:
|
||||
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."""
|
||||
|
||||
@@ -261,3 +261,72 @@ class TestFile:
|
||||
tag_paths2 = {tag.full_path for tag in file_obj2.tags}
|
||||
assert tag_paths == tag_paths2
|
||||
assert file_obj2.date == "2025-01-01"
|
||||
|
||||
|
||||
class TestApplyCsfdTags:
|
||||
"""Tests for File.apply_csfd_tags tag assignment (CSFD fetch is mocked)."""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def movie_file(self, tmp_path, tag_manager):
|
||||
path = tmp_path / "Matrix.mkv"
|
||||
path.write_text("x")
|
||||
f = File(path, tag_manager)
|
||||
f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/")
|
||||
return f
|
||||
|
||||
def test_apply_csfd_tags_assigns_expected_categories(self, movie_file):
|
||||
from unittest.mock import patch
|
||||
from src.core.csfd import CSFDMovie
|
||||
|
||||
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()
|
||||
|
||||
assert result["success"]
|
||||
paths = {t.full_path for t in movie_file.tags}
|
||||
assert "Žánr/Akční" in paths
|
||||
assert "Žánr/Sci-Fi" in paths
|
||||
assert "Rok/1999" in paths
|
||||
assert "Země původu/USA" in paths
|
||||
assert "Hodnocení/90–100 %" in paths
|
||||
|
||||
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)
|
||||
# …but the data is kept in the cache
|
||||
cached = movie_file.get_cached_movie()
|
||||
assert cached.directors == ["Lana Wachowski"]
|
||||
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
|
||||
|
||||
def test_apply_csfd_tags_can_skip_rating(self, movie_file):
|
||||
from unittest.mock import patch
|
||||
from src.core.csfd import CSFDMovie
|
||||
|
||||
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(add_rating=False)
|
||||
|
||||
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)
|
||||
|
||||
@@ -592,6 +592,73 @@ class TestPoolManagement:
|
||||
assert movie.csfd_link == "https://csfd.cz/film/1"
|
||||
assert file_manager.index.get(movie.file_path) is not None
|
||||
|
||||
def test_import_movie_move_removes_source(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
source = tmp_path / "raw.mkv"
|
||||
source.write_bytes(b"x" * 10)
|
||||
|
||||
movie = file_manager.import_movie(source, "Matrix", move=True)
|
||||
|
||||
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
|
||||
assert movie.file_path.exists()
|
||||
assert not source.exists() # moved, not copied
|
||||
|
||||
def test_rename_movie_renames_file_and_reindexes(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
source = tmp_path / "raw.mkv"
|
||||
source.write_bytes(b"x" * 10)
|
||||
movie = file_manager.import_movie(source, "Matrix")
|
||||
movie.add_tag("Žánr/Sci-Fi")
|
||||
old_path = movie.file_path
|
||||
|
||||
file_manager.rename_movie(movie, "Matrix Reloaded")
|
||||
|
||||
new_path = tmp_path / "pool" / "Filmy" / "Matrix Reloaded.mkv"
|
||||
assert movie.file_path == new_path
|
||||
assert new_path.exists()
|
||||
assert not old_path.exists()
|
||||
assert movie.title == "Matrix Reloaded"
|
||||
# metadata moved to the new key, old key gone, tags preserved
|
||||
assert file_manager.index.get(new_path) is not None
|
||||
assert file_manager.index.get(old_path) is None
|
||||
# a fresh manager reading the index sees the renamed file with its tags
|
||||
reloaded = FileManager(TagManager())
|
||||
reloaded.set_pool_dir(tmp_path / "pool")
|
||||
reloaded.load_pool_movies()
|
||||
assert [f.filename for f in reloaded.filelist] == ["Matrix Reloaded.mkv"]
|
||||
assert "Žánr/Sci-Fi" in {t.full_path for t in reloaded.filelist[0].tags}
|
||||
|
||||
def test_rename_movie_preserves_extension(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
source = tmp_path / "raw.mp4"
|
||||
source.write_bytes(b"x")
|
||||
movie = file_manager.import_movie(source, "Film")
|
||||
|
||||
file_manager.rename_movie(movie, "Jiný název")
|
||||
|
||||
assert movie.file_path.name == "Jiný název.mp4"
|
||||
|
||||
def test_rename_movie_rejects_existing_name(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||
(tmp_path / "b.mkv").write_bytes(b"b")
|
||||
first = file_manager.import_movie(tmp_path / "a.mkv", "Already")
|
||||
second = file_manager.import_movie(tmp_path / "b.mkv", "Other")
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
file_manager.rename_movie(second, "Already")
|
||||
# second movie is left untouched
|
||||
assert second.file_path.name == "Other.mkv"
|
||||
assert first.file_path.exists()
|
||||
|
||||
def test_rename_movie_rejects_empty_name(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Name")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
file_manager.rename_movie(movie, " ")
|
||||
|
||||
def test_load_pool_movies_reads_from_index(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
source = tmp_path / "raw.mkv"
|
||||
|
||||
@@ -107,6 +107,40 @@ class TestHardlinkManager:
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
assert not (temp_output_dir / "rok").exists()
|
||||
|
||||
def test_create_structure_with_category_roots(self, files_with_tags, temp_output_dir):
|
||||
"""category_roots: genres sit at the output root, rok under 'Dle roku'."""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
roots = {"žánr": "", "rok": "Dle roku"}
|
||||
manager.create_structure_for_files(files_with_tags, category_roots=roots)
|
||||
|
||||
# Genres directly at the output root (no "žánr" wrapper folder)
|
||||
assert (temp_output_dir / "Komedie" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "Akční" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "Drama" / "file2.txt").exists()
|
||||
assert not (temp_output_dir / "žánr").exists()
|
||||
|
||||
# Rok grouped under its own "Dle roku" folder
|
||||
assert (temp_output_dir / "Dle roku" / "1988" / "file1.txt").exists()
|
||||
|
||||
def test_sync_with_roots_leaves_unmanaged_mirror_untouched(
|
||||
self, files_with_tags, temp_source_dir, temp_output_dir
|
||||
):
|
||||
"""Cleanup must not delete links in a copy-as-is mirror (e.g. Seriály)."""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
roots = {"žánr": "", "rok": "Dle roku"}
|
||||
manager.create_structure_for_files(files_with_tags, category_roots=roots)
|
||||
|
||||
# Simulate a copy-as-is mirror holding a hardlink to a source file
|
||||
mirror = temp_output_dir / "Seriály"
|
||||
mirror.mkdir()
|
||||
mirror_link = mirror / "file1.txt"
|
||||
os.link(temp_source_dir / "file1.txt", mirror_link)
|
||||
|
||||
manager.sync_structure(files_with_tags, category_roots=roots)
|
||||
|
||||
# The mirror (not a managed tag folder) is left alone
|
||||
assert mirror_link.exists()
|
||||
|
||||
def test_dry_run(self, files_with_tags, temp_output_dir):
|
||||
"""Test dry run (bez skutečného vytváření)"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
|
||||
Reference in New Issue
Block a user