Add movie-keyed pool index with multi-version movies, ČSFD title bridge, subtitles and miniDLNA library scoping
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
"""Tests for the lean ČSFD title-bridge scraper (`src.core.csfd`)."""
|
||||
import hashlib
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core import csfd
|
||||
from src.core.csfd import (
|
||||
CsfdError,
|
||||
CsfdFilm,
|
||||
_extract_csfd_id,
|
||||
_is_film_href,
|
||||
_parse_origin,
|
||||
_parse_original_title,
|
||||
_solve_anubis_pow,
|
||||
clean_filename_to_query,
|
||||
fetch_film,
|
||||
find_csfd_url,
|
||||
search_films,
|
||||
)
|
||||
|
||||
# A trimmed ČSFD film page: Czech title in JSON-LD, USA origin, and a
|
||||
# ``.film-names`` list whose USA-flagged entry is the original title.
|
||||
FILM_HTML = """
|
||||
<html><head>
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org/","@type":"Movie","name":"Vykoupení z věznice Shawshank",
|
||||
"dateCreated":"1994","duration":"PT142M"}
|
||||
</script>
|
||||
</head><body>
|
||||
<div class="film-header-name">
|
||||
<h1>Vykoupení z věznice Shawshank</h1>
|
||||
<ul class="film-names">
|
||||
<li><img src="//x/us.svg" class="flag" title="USA" alt="USA"/> The Shawshank Redemption
|
||||
<span class="normal more-name-link"><a class="more">více</a></span></li>
|
||||
<li class="more-names hidden"><img src="//x/sk.svg" class="flag" title="Slovensko"/>
|
||||
Vykúpenie z väznice Shawshank</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="origin">USA <span class="bullet"></span>
|
||||
<span>1994 <span class="bullet"></span> </span>142 min</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
# A Czech film: ČSFD omits the Czech title from ``.film-names`` (it equals the
|
||||
# <h1>) and lists only a foreign festival title — the original title must fall
|
||||
# back to the <h1>, not "Cosy Dens".
|
||||
CZECH_FILM_HTML = """
|
||||
<html><head>
|
||||
<script type="application/ld+json">
|
||||
{"@type":"Movie","name":"Pelíšky","datePublished":"1999-04-01"}
|
||||
</script>
|
||||
</head><body>
|
||||
<div class="film-header-name"><h1>Pelíšky</h1>
|
||||
<ul class="film-names">
|
||||
<li><img class="flag" title="angličtina"/> Cosy Dens</li>
|
||||
<li><img class="flag" title="Slovensko"/> Pelíšky</li>
|
||||
</ul></div>
|
||||
<div class="origin">Česko <span class="bullet"></span><span>1999</span></div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
# The real search page groups hits into <section> blocks; a.film-title-name also
|
||||
# matches series episodes, so search_films must scope to "Filmy" and drop the
|
||||
# two-segment episode hrefs.
|
||||
SEARCH_HTML = """
|
||||
<html><body>
|
||||
<section><div class="box-header"><h2>Filmy</h2></div>
|
||||
<a href="/film/2294-vykoupeni-z-veznice-shawshank/prehled/" class="film-title-name">Vykoupení z věznice Shawshank</a>
|
||||
<a href="/film/248803-film-o-filmu/prehled/" class="film-title-name">Film o filmu</a>
|
||||
</section>
|
||||
<section><div class="box-header"><h2>Seriály</h2></div>
|
||||
<a href="/film/318290-arrow/620181-x/prehled/" class="film-title-name">Arrow - Vykoupení</a>
|
||||
</section>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class TestCleanFilenameToQuery:
|
||||
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):
|
||||
assert clean_filename_to_query("1080p.mkv") == "1080p"
|
||||
|
||||
|
||||
class TestExtractCsfdId:
|
||||
def test_extract_from_various_urls(self):
|
||||
assert _extract_csfd_id(
|
||||
"https://www.csfd.cz/film/2294-vykoupeni/prehled/") == 2294
|
||||
assert _extract_csfd_id("/film/999/") == 999
|
||||
|
||||
def test_none_when_absent(self):
|
||||
assert _extract_csfd_id("https://www.csfd.cz/") is None
|
||||
|
||||
|
||||
class TestAnubisPoW:
|
||||
def test_solve_difficulty_one_gives_one_leading_zero_nibble(self):
|
||||
hash_hex, nonce, _ = _solve_anubis_pow("abc123", difficulty=1)
|
||||
assert hash_hex[0] == "0"
|
||||
assert hashlib.sha256(f"abc123{nonce}".encode()).hexdigest() == hash_hex
|
||||
|
||||
def test_solve_difficulty_two_gives_leading_zero_byte(self):
|
||||
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
|
||||
assert hash_hex[:2] == "00"
|
||||
|
||||
|
||||
class TestParseOrigin:
|
||||
def test_country_and_year_from_bullet_format(self):
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(FILM_HTML, "html.parser")
|
||||
assert _parse_origin(soup) == ("USA", "1994")
|
||||
|
||||
def test_first_country_of_coproduction(self):
|
||||
from bs4 import BeautifulSoup
|
||||
html = ('<div class="origin">Francie / Německo <span class="bullet"></span>'
|
||||
'<span>2001</span> 120 min</div>')
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
assert _parse_origin(soup) == ("Francie", "2001")
|
||||
|
||||
|
||||
class TestParseOriginalTitle:
|
||||
def test_picks_name_matching_origin_country(self):
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(FILM_HTML, "html.parser")
|
||||
assert _parse_original_title(soup, "USA") == "The Shawshank Redemption"
|
||||
|
||||
def test_none_without_country_match_so_caller_uses_czech_title(self):
|
||||
# A Czech film lists only foreign/festival alt-titles here — none match
|
||||
# the origin country, so we must NOT return one of them.
|
||||
from bs4 import BeautifulSoup
|
||||
html = ('<ul class="film-names">'
|
||||
'<li><img class="flag" title="angličtina"/> Loners</li>'
|
||||
'<li><img class="flag" title="Slovinsko"/> Samotarji</li></ul>')
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
assert _parse_original_title(soup, "Česko") is None
|
||||
|
||||
def test_cesko_matches_ceskoslovensko_flag(self):
|
||||
from bs4 import BeautifulSoup
|
||||
html = ('<ul class="film-names">'
|
||||
'<li><img class="flag" title="Československo"/> Ostře sledované vlaky</li>'
|
||||
'</ul>')
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
assert _parse_original_title(soup, "Česko") == "Ostře sledované vlaky"
|
||||
|
||||
def test_none_when_no_names(self):
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup("<div>x</div>", "html.parser")
|
||||
assert _parse_original_title(soup, "USA") is None
|
||||
|
||||
|
||||
class TestFetchFilm:
|
||||
def test_parses_czech_and_original_title_and_year(self):
|
||||
class _Resp:
|
||||
text = FILM_HTML
|
||||
|
||||
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||
film = fetch_film("https://www.csfd.cz/film/2294-x/")
|
||||
|
||||
assert film.czech_title == "Vykoupení z věznice Shawshank"
|
||||
assert film.original_title == "The Shawshank Redemption"
|
||||
assert film.year == "1994"
|
||||
assert film.csfd_id == 2294
|
||||
assert film.tmdb_query == ("The Shawshank Redemption", "1994")
|
||||
|
||||
def test_czech_film_original_title_falls_back_to_czech(self):
|
||||
class _Resp:
|
||||
text = CZECH_FILM_HTML
|
||||
|
||||
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||
film = fetch_film("https://www.csfd.cz/film/15387-pelisky/")
|
||||
|
||||
assert film.czech_title == "Pelíšky"
|
||||
assert film.original_title == "Pelíšky"
|
||||
assert film.year == "1999"
|
||||
assert film.tmdb_query == ("Pelíšky", "1999")
|
||||
|
||||
def test_wraps_network_error_in_csfd_error(self):
|
||||
import requests
|
||||
|
||||
with patch("src.core.csfd._get_page",
|
||||
side_effect=requests.RequestException("boom")):
|
||||
with pytest.raises(CsfdError):
|
||||
fetch_film("https://www.csfd.cz/film/1/")
|
||||
|
||||
|
||||
class TestIsFilmHref:
|
||||
def test_accepts_single_segment_film(self):
|
||||
assert _is_film_href("/film/2294-vykoupeni/prehled/") is True
|
||||
assert _is_film_href("/film/2294-vykoupeni/") is True
|
||||
|
||||
def test_rejects_series_episode(self):
|
||||
assert _is_film_href("/film/318290-arrow/620181-vykoupeni/prehled/") is False
|
||||
|
||||
def test_rejects_non_film(self):
|
||||
assert _is_film_href("/tvurce/123-nekdo/") is False
|
||||
|
||||
|
||||
class TestSearchFilms:
|
||||
def test_scopes_to_films_section_and_drops_episodes(self):
|
||||
class _Resp:
|
||||
text = SEARCH_HTML
|
||||
|
||||
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||
films = search_films("shawshank", limit=5)
|
||||
|
||||
# the Seriály-section episode link is excluded
|
||||
assert [f.csfd_id for f in films] == [2294, 248803]
|
||||
assert films[0].url == (
|
||||
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/")
|
||||
|
||||
def test_empty_query_returns_empty(self):
|
||||
assert search_films(" ") == []
|
||||
|
||||
|
||||
class TestFindCsfdUrl:
|
||||
def test_returns_first_hit_url(self):
|
||||
hit = CsfdFilm(url="https://www.csfd.cz/film/2294-x/", csfd_id=2294)
|
||||
with patch("src.core.csfd.search_films", return_value=[hit]):
|
||||
assert find_csfd_url("shawshank 1994") == "https://www.csfd.cz/film/2294-x/"
|
||||
|
||||
def test_retries_without_trailing_year_when_first_query_misses(self):
|
||||
hit = CsfdFilm(url="https://www.csfd.cz/film/221006-vratne-lahve/", csfd_id=221006)
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_search(query, limit=1, session=None):
|
||||
calls.append(query)
|
||||
return [] if query.endswith("2006") else [hit]
|
||||
|
||||
with patch("src.core.csfd.search_films", side_effect=fake_search):
|
||||
assert find_csfd_url("Vratne lahve 2006") == hit.url
|
||||
assert calls == ["Vratne lahve 2006", "Vratne lahve"]
|
||||
|
||||
def test_none_for_blank_query(self):
|
||||
assert find_csfd_url(" ") is None
|
||||
|
||||
def test_none_when_no_results(self):
|
||||
with patch("src.core.csfd.search_films", return_value=[]):
|
||||
assert find_csfd_url("nic") is None
|
||||
|
||||
|
||||
class TestDependencies:
|
||||
def test_dependencies_available_in_test_env(self):
|
||||
assert csfd.dependencies_available() is True
|
||||
+4
-4
@@ -37,7 +37,7 @@ class TestFile:
|
||||
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
|
||||
"""Metadata jsou zapsána do indexu už při vytvoření."""
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
assert index.get(file_obj.file_path) is not None
|
||||
assert index.record_for(file_obj.file_path) is not None
|
||||
|
||||
def test_file_no_sidecar_written(self, test_file, tag_manager, index):
|
||||
"""Žádný .!tag sidecar už nevzniká."""
|
||||
@@ -57,7 +57,7 @@ class TestFile:
|
||||
file_obj.ignored = True
|
||||
file_obj.save_metadata()
|
||||
|
||||
data = index.get(file_obj.file_path)
|
||||
data = index.record_for(file_obj.file_path)
|
||||
assert data["new"] is False
|
||||
assert data["ignored"] is True
|
||||
|
||||
@@ -78,7 +78,7 @@ class TestFile:
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.set_date("2025-12-25")
|
||||
assert file_obj.date == "2025-12-25"
|
||||
assert index.get(file_obj.file_path)["date"] == "2025-12-25"
|
||||
assert index.record_for(file_obj.file_path)["date"] == "2025-12-25"
|
||||
|
||||
def test_file_set_date_to_none(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
@@ -169,7 +169,7 @@ class TestFile:
|
||||
file_obj.add_tag("Test/Tag")
|
||||
file_obj.set_date("2025-06-15")
|
||||
|
||||
data = index.get(file_obj.file_path)
|
||||
data = index.record_for(file_obj.file_path)
|
||||
assert "new" in data
|
||||
assert "ignored" in data
|
||||
assert "tags" in data
|
||||
|
||||
+645
-9
@@ -208,7 +208,7 @@ class TestPoolManagement:
|
||||
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
|
||||
assert source.exists() # non-destructive copy
|
||||
assert movie.title == "Matrix"
|
||||
assert file_manager.index.get(movie.file_path) is not None
|
||||
assert file_manager.index.record_for(movie.file_path) is not None
|
||||
|
||||
def test_statistics_aggregates_pool(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
@@ -274,7 +274,7 @@ class TestPoolManagement:
|
||||
# parseable ISO timestamp, persisted in the index
|
||||
from datetime import datetime
|
||||
datetime.fromisoformat(movie.added)
|
||||
assert file_manager.index.get(movie.file_path)["added"] == movie.added
|
||||
assert file_manager.index.record_for(movie.file_path)["added"] == movie.added
|
||||
|
||||
def test_import_movie_move_removes_source(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
@@ -325,7 +325,7 @@ class TestPoolManagement:
|
||||
assert second.file_path.read_bytes() == b"bb" # new content in place
|
||||
assert [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"]
|
||||
# index record reflects the new import (fresh metadata, old tags dropped)
|
||||
record = file_manager.index.get(second.file_path)
|
||||
record = file_manager.index.record_for(second.file_path)
|
||||
assert record is not None
|
||||
assert record["tags"] == []
|
||||
assert second.tags == []
|
||||
@@ -367,8 +367,8 @@ class TestPoolManagement:
|
||||
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
|
||||
assert file_manager.index.record_for(new_path) is not None
|
||||
assert file_manager.index.record_for(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")
|
||||
@@ -427,6 +427,15 @@ class TestPoolManagement:
|
||||
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||
assert movie.title == "Matrix" # clean title, no year/id
|
||||
|
||||
def test_import_movie_with_3d_format(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", "Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
|
||||
|
||||
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||
|
||||
def test_libraries_default_and_derivation(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
assert file_manager.libraries == [
|
||||
@@ -529,15 +538,18 @@ class TestPoolManagement:
|
||||
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()
|
||||
old_path = movie.file_path
|
||||
|
||||
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
|
||||
# The movie keeps its identity record; only its media path + library move.
|
||||
assert file_manager.index.record_for(old_path) is None
|
||||
record = file_manager.index.record_for(movie.file_path)
|
||||
assert record is not None
|
||||
assert record["library"] == "Kids"
|
||||
assert [m["file"] for m in record["media"]] == ["Kids/Auta (2006).mkv"]
|
||||
|
||||
def test_move_to_unknown_library_rejected(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
@@ -613,6 +625,42 @@ class TestPoolManagement:
|
||||
csfd_url="https://www.csfd.cz/film/9499-matrix/")
|
||||
assert movie.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
||||
|
||||
def test_apply_movie_edits_sets_3d_format(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", "Avatar", year=2009, tmdb_id=19995)
|
||||
|
||||
file_manager.apply_movie_edits(
|
||||
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
|
||||
|
||||
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||
|
||||
def test_apply_movie_edits_omitted_3d_format_is_preserved(self, file_manager, tmp_path):
|
||||
"""Editing an unrelated field (e.g. date) must not silently drop the tag."""
|
||||
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", "Avatar", year=2009, tmdb_id=19995)
|
||||
file_manager.apply_movie_edits(
|
||||
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
|
||||
|
||||
file_manager.apply_movie_edits(
|
||||
movie, title="Avatar", year=2009, tmdb_id=19995, date="2026-01-01")
|
||||
|
||||
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||
assert movie.date == "2026-01-01"
|
||||
|
||||
def test_apply_movie_edits_empty_string_clears_3d_format(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", "Avatar", year=2009, tmdb_id=19995)
|
||||
file_manager.apply_movie_edits(
|
||||
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
|
||||
|
||||
file_manager.apply_movie_edits(
|
||||
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="")
|
||||
|
||||
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].mkv"
|
||||
|
||||
def test_apply_movie_edits_rejects_name_clash(self, file_manager, tmp_path):
|
||||
file_manager.set_pool_dir(tmp_path / "pool")
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
@@ -634,6 +682,19 @@ class TestPoolManagement:
|
||||
assert renamed is not None
|
||||
assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv"
|
||||
|
||||
def test_rename_to_canonical_preserves_3d_format(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", "Avatar", year=2009, tmdb_id=19995)
|
||||
file_manager.apply_movie_edits(
|
||||
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="fsbs")
|
||||
|
||||
movie.title = "Avatar Retitled"
|
||||
file_manager.rename_to_canonical(movie)
|
||||
|
||||
assert movie.file_path.name == "Avatar Retitled (2009) [tmdbid-19995].3D.FSBS.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")
|
||||
@@ -734,3 +795,578 @@ class TestPoolManagement:
|
||||
assert file_manager.copyasis_folders == ["Seriály", "Filmy jinak"]
|
||||
assert "copyasis_folders" not in file_manager.global_config
|
||||
assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Filmy jinak"]
|
||||
|
||||
|
||||
class TestCsfdBridge:
|
||||
"""ČSFD → TMDb title bridge (resolve_via_csfd, migration wiring)."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, temp_global_config):
|
||||
return FileManager(TagManager())
|
||||
|
||||
def test_resolve_via_csfd_bridges_original_title_to_tmdb(
|
||||
self, file_manager, monkeypatch
|
||||
):
|
||||
from src.core import csfd, tmdb
|
||||
film = csfd.CsfdFilm(
|
||||
url="https://www.csfd.cz/film/2294-x/", csfd_id=2294,
|
||||
czech_title="Vykoupení z věznice Shawshank",
|
||||
original_title="The Shawshank Redemption", year="1994")
|
||||
monkeypatch.setattr(csfd, "fetch_film", lambda url, session=None: film)
|
||||
monkeypatch.setattr(tmdb, "api_available", lambda: True)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def fake_search(query, year=None, limit=5):
|
||||
captured["query"], captured["year"] = query, year
|
||||
return [tmdb.TmdbResult(278, "Vykoupení z věznice Shawshank", "1994", "movie")]
|
||||
|
||||
monkeypatch.setattr(tmdb, "search_movie", fake_search)
|
||||
|
||||
result = file_manager.resolve_via_csfd(
|
||||
csfd_url="https://www.csfd.cz/film/2294-x/")
|
||||
|
||||
assert captured == {"query": "The Shawshank Redemption", "year": "1994"}
|
||||
assert result["tmdb"].tmdb_id == 278
|
||||
assert result["film"] is film
|
||||
|
||||
def test_resolve_via_csfd_swallows_scrape_failure(self, file_manager, monkeypatch):
|
||||
from src.core import csfd
|
||||
|
||||
def boom(url, session=None):
|
||||
raise csfd.CsfdError("anti-bot")
|
||||
|
||||
monkeypatch.setattr(csfd, "fetch_film", boom)
|
||||
result = file_manager.resolve_via_csfd(csfd_url="https://www.csfd.cz/film/1/")
|
||||
assert result == {"csfd_url": "https://www.csfd.cz/film/1/",
|
||||
"film": None, "tmdb": None}
|
||||
|
||||
def test_match_pool_uses_csfd_url_for_the_search(
|
||||
self, file_manager, tmp_path, monkeypatch
|
||||
):
|
||||
from src.core import csfd, tmdb
|
||||
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", "Vykoupení z věznice Shawshank", year=1994,
|
||||
csfd_url="https://www.csfd.cz/film/2294-x/")
|
||||
|
||||
film = csfd.CsfdFilm(
|
||||
url=movie.csfd_url, czech_title="Vykoupení z věznice Shawshank",
|
||||
original_title="The Shawshank Redemption", year="1994")
|
||||
monkeypatch.setattr(csfd, "fetch_film", lambda url, session=None: film)
|
||||
monkeypatch.setattr(tmdb, "api_available", lambda: True)
|
||||
|
||||
seen: list = []
|
||||
|
||||
def fake_search(query, year=None, limit=5):
|
||||
seen.append((query, year))
|
||||
return [tmdb.TmdbResult(278, "The Shawshank Redemption", "1994", "movie")]
|
||||
|
||||
monkeypatch.setattr(tmdb, "search_movie", fake_search)
|
||||
|
||||
proposals = file_manager.match_pool_to_tmdb([movie])
|
||||
|
||||
assert seen == [("The Shawshank Redemption", "1994")]
|
||||
assert len(proposals) == 1
|
||||
assert proposals[0].status == "auto"
|
||||
assert proposals[0].chosen.tmdb_id == 278
|
||||
assert "ČSFD" in proposals[0].note
|
||||
|
||||
|
||||
class TestSubtitleAttachment:
|
||||
"""External subtitle sidecars for movies (attach / list / remove / rename-along)."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, temp_global_config, tmp_path):
|
||||
fm = FileManager(TagManager())
|
||||
fm.set_pool_dir(tmp_path / "pool")
|
||||
return fm
|
||||
|
||||
def test_attach_subtitle_copies_next_to_movie(self, file_manager, tmp_path):
|
||||
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||
srt = tmp_path / "downloaded.srt"
|
||||
srt.write_bytes(b"1\n00:00:01,000 --> 00:00:02,000\nAhoj\n")
|
||||
|
||||
target = file_manager.attach_subtitle(movie, srt, language="cs")
|
||||
|
||||
assert target == movie.file_path.with_name("Matrix (1999).cs.srt")
|
||||
assert target.exists()
|
||||
assert srt.exists() # copied, not moved
|
||||
assert [s.path for s in file_manager.list_subtitles(movie)] == [target]
|
||||
|
||||
def test_remove_subtitle_deletes_file(self, file_manager, tmp_path):
|
||||
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||
srt = tmp_path / "s.srt"
|
||||
srt.write_bytes(b"x")
|
||||
target = file_manager.attach_subtitle(movie, srt, language="cs")
|
||||
|
||||
file_manager.remove_subtitle(target)
|
||||
|
||||
assert not target.exists()
|
||||
assert file_manager.list_subtitles(movie) == []
|
||||
|
||||
def test_rename_movie_carries_subtitle_along(self, file_manager, tmp_path):
|
||||
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||
srt = tmp_path / "s.srt"
|
||||
srt.write_bytes(b"x")
|
||||
old_sub = file_manager.attach_subtitle(movie, srt, language="cs")
|
||||
|
||||
file_manager.rename_movie(movie, "Matrix Reloaded")
|
||||
|
||||
assert not old_sub.exists()
|
||||
new_sub = movie.file_path.with_name("Matrix Reloaded.cs.srt")
|
||||
assert new_sub.exists()
|
||||
|
||||
def test_apply_movie_edits_carries_subtitle_along(self, file_manager, tmp_path):
|
||||
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||
srt = tmp_path / "s.srt"
|
||||
srt.write_bytes(b"x")
|
||||
file_manager.attach_subtitle(movie, srt, language="cs", flag="forced")
|
||||
|
||||
file_manager.apply_movie_edits(movie, title="Matrix", year=1999, tmdb_id=603)
|
||||
|
||||
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||
assert movie.file_path.with_name(
|
||||
"Matrix (1999) [tmdbid-603].cs.forced.srt").exists()
|
||||
|
||||
def test_move_to_library_carries_subtitle_along(self, file_manager, tmp_path):
|
||||
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||
srt = tmp_path / "s.srt"
|
||||
srt.write_bytes(b"x")
|
||||
file_manager.attach_subtitle(movie, srt, language="cs")
|
||||
|
||||
file_manager.move_to_library(movie, "Kids")
|
||||
|
||||
assert movie.file_path == tmp_path / "pool" / "Kids" / "Matrix (1999).mkv"
|
||||
assert (tmp_path / "pool" / "Kids" / "Matrix (1999).cs.srt").exists()
|
||||
assert not (tmp_path / "pool" / "Filmy" / "Matrix (1999).cs.srt").exists()
|
||||
|
||||
|
||||
class TestMinidlnaScope:
|
||||
"""Which libraries feed miniDLNA generation (minidlna_libraries / scope)."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, temp_global_config, tmp_path):
|
||||
fm = FileManager(TagManager())
|
||||
fm.set_pool_dir(tmp_path / "pool")
|
||||
return fm
|
||||
|
||||
def test_default_is_none_meaning_all(self, file_manager):
|
||||
assert file_manager.minidlna_libraries is None
|
||||
|
||||
def test_default_scope_is_the_whole_pool(self, file_manager, tmp_path):
|
||||
file_manager.set_libraries([
|
||||
{"name": "Filmy", "kind": "movie"}, {"name": "Seriály", "kind": "series"}])
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
|
||||
|
||||
movies, mirrors, mixed_series = file_manager.minidlna_scope()
|
||||
|
||||
assert movies == [movie]
|
||||
assert mirrors == ["Seriály"]
|
||||
assert mixed_series == []
|
||||
|
||||
def test_scope_filters_to_selected_libraries(self, file_manager, tmp_path):
|
||||
file_manager.set_libraries([
|
||||
{"name": "Filmy", "kind": "movie"}, {"name": "Kids", "kind": "movie"},
|
||||
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "series"}])
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
(tmp_path / "b.mkv").write_bytes(b"x")
|
||||
in_scope = file_manager.import_movie(
|
||||
tmp_path / "a.mkv", "Matrix", year=1999, library="Filmy")
|
||||
file_manager.import_movie(tmp_path / "b.mkv", "Kids Movie", year=2005, library="Kids")
|
||||
|
||||
file_manager.set_minidlna_libraries(["Filmy", "Seriály"])
|
||||
movies, mirrors, mixed_series = file_manager.minidlna_scope()
|
||||
|
||||
assert movies == [in_scope]
|
||||
assert mirrors == ["Seriály"]
|
||||
assert mixed_series == []
|
||||
|
||||
def test_homevideo_libraries_are_mirrored_like_series(self, file_manager):
|
||||
file_manager.set_libraries([
|
||||
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
|
||||
|
||||
assert file_manager.mirror_as_is_libraries == ["Seriály", "Dokumenty"]
|
||||
_, mirrors, _ = file_manager.minidlna_scope()
|
||||
assert mirrors == ["Seriály", "Dokumenty"]
|
||||
|
||||
def test_homevideo_library_excluded_when_not_selected(self, file_manager):
|
||||
file_manager.set_libraries([
|
||||
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
|
||||
file_manager.set_minidlna_libraries(["Seriály"])
|
||||
|
||||
_, mirrors, _ = file_manager.minidlna_scope()
|
||||
assert mirrors == ["Seriály"]
|
||||
|
||||
def test_copyasis_folders_stays_series_only(self, file_manager):
|
||||
"""The library-organizer "copy-as-is" concept doesn't widen to homevideo."""
|
||||
file_manager.set_libraries([
|
||||
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
|
||||
assert file_manager.copyasis_folders == ["Seriály"]
|
||||
|
||||
def test_set_none_resets_to_all(self, file_manager):
|
||||
file_manager.set_minidlna_libraries(["Filmy"])
|
||||
file_manager.set_minidlna_libraries(None)
|
||||
assert file_manager.minidlna_libraries is None
|
||||
|
||||
def test_drops_unknown_library_names_on_read(self, file_manager):
|
||||
file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}])
|
||||
file_manager.set_minidlna_libraries(["Filmy", "Zaniklá knihovna"])
|
||||
assert file_manager.minidlna_libraries == ["Filmy"]
|
||||
|
||||
def test_persists_across_reload(self, file_manager, tmp_path):
|
||||
file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}])
|
||||
file_manager.set_minidlna_libraries(["Filmy"])
|
||||
|
||||
reloaded = FileManager(TagManager())
|
||||
reloaded.set_pool_dir(tmp_path / "pool")
|
||||
assert reloaded.minidlna_libraries == ["Filmy"]
|
||||
|
||||
def test_mixed_library_series_returned_separately_from_movies(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||
(tmp_path / "movie.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(
|
||||
tmp_path / "movie.mkv", "Matrix", year=1999, library="Hlavní")
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396,
|
||||
library="Hlavní")
|
||||
|
||||
movies, mirrors, mixed_series = file_manager.minidlna_scope()
|
||||
|
||||
assert movies == [movie]
|
||||
assert mirrors == []
|
||||
assert len(mixed_series) == 1
|
||||
assert mixed_series[0].title == "Breaking Bad"
|
||||
assert mixed_series[0].library == "Hlavní"
|
||||
|
||||
def test_mixed_library_series_excluded_when_library_not_selected(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
file_manager.set_libraries([
|
||||
{"name": "Hlavní", "kind": "mixed"}, {"name": "Filmy", "kind": "movie"}])
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, library="Hlavní")
|
||||
|
||||
file_manager.set_minidlna_libraries(["Filmy"])
|
||||
_, _, mixed_series = file_manager.minidlna_scope()
|
||||
|
||||
assert mixed_series == []
|
||||
|
||||
|
||||
class TestMinidlnaPruneTargets:
|
||||
"""What to remove from a previous generation's output once deselected."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, temp_global_config, tmp_path):
|
||||
fm = FileManager(TagManager())
|
||||
fm.set_pool_dir(tmp_path / "pool")
|
||||
return fm
|
||||
|
||||
def test_deselected_series_library_is_stale(self, file_manager):
|
||||
file_manager.set_libraries([
|
||||
{"name": "Anime", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
|
||||
# both deselected — neither is "Seriály" so both go whole-folder
|
||||
stale, valid = file_manager.minidlna_prune_targets(
|
||||
mirror_libs=[], mixed_series=[], mixed_series_bucket="Seriály")
|
||||
assert set(stale) == {"Anime", "Dokumenty"}
|
||||
assert valid == set()
|
||||
|
||||
def test_selected_library_is_not_stale(self, file_manager):
|
||||
file_manager.set_libraries([{"name": "Anime", "kind": "series"}])
|
||||
stale, _ = file_manager.minidlna_prune_targets(
|
||||
mirror_libs=["Anime"], mixed_series=[], mixed_series_bucket="Seriály")
|
||||
assert stale == []
|
||||
|
||||
def test_bucket_named_library_never_whole_folder_removed(self, file_manager):
|
||||
"""A real 'Seriály' library, even deselected, is reconciled per-child —
|
||||
never nuked via the whole-folder path (that would also erase valid
|
||||
mixed-library series sharing the same bucket)."""
|
||||
file_manager.set_libraries([{"name": "Seriály", "kind": "series"}])
|
||||
stale, valid = file_manager.minidlna_prune_targets(
|
||||
mirror_libs=[], mixed_series=[], mixed_series_bucket="Seriály")
|
||||
assert "Seriály" not in stale
|
||||
assert valid == set()
|
||||
|
||||
def test_bucket_named_library_selected_contributes_its_children(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
file_manager.set_libraries([{"name": "Seriály", "kind": "series"}])
|
||||
(tmp_path / "pool" / "Seriály" / "Show A").mkdir(parents=True)
|
||||
(tmp_path / "pool" / "Seriály" / "Show B").mkdir(parents=True)
|
||||
|
||||
_, valid = file_manager.minidlna_prune_targets(
|
||||
mirror_libs=["Seriály"], mixed_series=[], mixed_series_bucket="Seriály")
|
||||
|
||||
assert valid == {"Show A", "Show B"}
|
||||
|
||||
def test_mixed_series_folder_names_are_valid(self, file_manager, tmp_path):
|
||||
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008,
|
||||
tmdb_id=1396, library="Hlavní")
|
||||
_, _, mixed_series = file_manager.minidlna_scope()
|
||||
|
||||
_, valid = file_manager.minidlna_prune_targets(
|
||||
mirror_libs=[], mixed_series=mixed_series, mixed_series_bucket="Seriály")
|
||||
|
||||
assert valid == {"Breaking Bad (2008) [tmdbid-1396]"}
|
||||
|
||||
def test_deselected_mixed_series_drops_out_of_valid(self, file_manager, tmp_path):
|
||||
"""Simulates re-running after the mixed library is unchecked: the
|
||||
previously-mirrored series' folder name is no longer valid."""
|
||||
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008,
|
||||
tmdb_id=1396, library="Hlavní")
|
||||
|
||||
file_manager.set_minidlna_libraries([]) # deselect everything
|
||||
_, _, mixed_series = file_manager.minidlna_scope()
|
||||
_, valid = file_manager.minidlna_prune_targets(
|
||||
mirror_libs=[], mixed_series=mixed_series, mixed_series_bucket="Seriály")
|
||||
|
||||
assert mixed_series == []
|
||||
assert valid == set()
|
||||
|
||||
|
||||
class TestMovieVersions:
|
||||
"""Multiple versions of one movie (2D+3D, 4K+1080p, …) — flat until a
|
||||
second file shows up, then grouped into a per-movie folder."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, temp_global_config, tmp_path):
|
||||
fm = FileManager(TagManager())
|
||||
fm.set_pool_dir(tmp_path / "pool")
|
||||
return fm
|
||||
|
||||
def test_flat_movie_is_not_a_group_member(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
assert file_manager.is_movie_group_member(movie) is False
|
||||
assert file_manager.list_movie_versions(movie) == [movie]
|
||||
assert file_manager.visible_movies() == [movie]
|
||||
|
||||
def test_add_version_promotes_flat_movie_to_a_folder(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
|
||||
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
|
||||
|
||||
base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]"
|
||||
assert movie.file_path == base / "Avatar (2009) [tmdbid-19995] - Výchozí.mkv"
|
||||
assert v2.file_path == base / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"
|
||||
assert file_manager.is_movie_group_member(movie)
|
||||
assert file_manager.is_movie_group_member(v2)
|
||||
assert (tmp_path / "b.mkv").exists() # copied, not moved (move=False default)
|
||||
|
||||
def test_add_version_carries_subtitle_along_on_promotion(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
srt = tmp_path / "s.srt"
|
||||
srt.write_bytes(b"x")
|
||||
file_manager.attach_subtitle(movie, srt, language="cs")
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
|
||||
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
|
||||
|
||||
base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]"
|
||||
assert (base / "Avatar (2009) [tmdbid-19995] - Výchozí.cs.srt").exists()
|
||||
|
||||
def test_second_version_added_to_existing_group(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
|
||||
(tmp_path / "c.mkv").write_bytes(b"4k")
|
||||
|
||||
v3 = file_manager.add_movie_version(movie, tmp_path / "c.mkv", "4K")
|
||||
|
||||
assert len(file_manager.list_movie_versions(movie)) == 3
|
||||
assert v3.file_path.name == "Avatar (2009) [tmdbid-19995] - 4K.mkv"
|
||||
|
||||
def test_add_version_rejects_empty_label(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
with pytest.raises(ValueError):
|
||||
file_manager.add_movie_version(movie, tmp_path / "a.mkv", " ")
|
||||
|
||||
def test_add_version_rejects_duplicate_label(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||
(tmp_path / "c.mkv").write_bytes(b"3d-2")
|
||||
with pytest.raises(FileExistsError):
|
||||
file_manager.add_movie_version(movie, tmp_path / "c.mkv", "3D")
|
||||
|
||||
def test_visible_movies_shows_only_the_primary_version(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
movie.add_tag("Žánr/Sci-Fi")
|
||||
movie.csfd_url = "https://www.csfd.cz/film/avatar/"
|
||||
movie.save_metadata()
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
|
||||
|
||||
visible = file_manager.visible_movies()
|
||||
|
||||
assert len(visible) == 1
|
||||
assert visible[0].file_path == movie.file_path
|
||||
assert visible[0].title == "Avatar"
|
||||
assert visible[0].csfd_url == "https://www.csfd.cz/film/avatar/"
|
||||
assert {t.full_path for t in visible[0].tags} == {"Žánr/Sci-Fi"}
|
||||
|
||||
def test_remove_version_keeps_group_when_more_than_one_remains(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||
(tmp_path / "c.mkv").write_bytes(b"4k")
|
||||
v3 = file_manager.add_movie_version(movie, tmp_path / "c.mkv", "4K")
|
||||
|
||||
file_manager.remove_movie_version(v3)
|
||||
|
||||
assert not v3.file_path.exists()
|
||||
assert file_manager.is_movie_group_member(movie)
|
||||
assert file_manager.is_movie_group_member(v2)
|
||||
assert len(file_manager.list_movie_versions(movie)) == 2
|
||||
|
||||
def test_remove_last_extra_version_demotes_to_flat(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||
|
||||
file_manager.remove_movie_version(v2)
|
||||
|
||||
assert not file_manager.is_movie_group_member(movie)
|
||||
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995].mkv"
|
||||
assert movie.file_path.exists()
|
||||
assert not (tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]").exists()
|
||||
|
||||
def test_removing_primary_migrates_metadata_to_survivor(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
movie.add_tag("Žánr/Sci-Fi")
|
||||
movie.csfd_url = "https://www.csfd.cz/film/avatar/"
|
||||
movie.attributes = {"collection_sort": "01"}
|
||||
movie.save_metadata()
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||
|
||||
file_manager.remove_movie_version(movie) # remove the primary
|
||||
|
||||
assert v2.title == "Avatar"
|
||||
assert v2.csfd_url == "https://www.csfd.cz/film/avatar/"
|
||||
assert v2.attributes == {"collection_sort": "01"}
|
||||
assert {t.full_path for t in v2.tags} == {"Žánr/Sci-Fi"}
|
||||
assert not file_manager.is_movie_group_member(v2) # demoted, only one left
|
||||
|
||||
def test_remove_version_rejects_ungrouped_movie(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
with pytest.raises(ValueError):
|
||||
file_manager.remove_movie_version(movie)
|
||||
|
||||
def test_rename_movie_refuses_grouped_movie(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
file_manager.rename_movie(movie, "Avatar Reloaded")
|
||||
|
||||
def test_move_to_library_refuses_grouped_movie(self, file_manager, tmp_path):
|
||||
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
file_manager.move_to_library(movie, "Kids")
|
||||
|
||||
def test_apply_movie_edits_refuses_rename_of_grouped_movie(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
file_manager.apply_movie_edits(movie, title="Avatar Reloaded", year=2009, tmdb_id=19995)
|
||||
|
||||
def test_apply_movie_edits_allows_metadata_only_edit_of_grouped_movie(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
"""Same title/year/tmdb_id → no path change → date/tags/etc still work."""
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||
|
||||
file_manager.apply_movie_edits(
|
||||
movie, title="Avatar", year=2009, tmdb_id=19995, date="2026-01-01")
|
||||
|
||||
assert movie.date == "2026-01-01"
|
||||
assert file_manager.is_movie_group_member(movie)
|
||||
|
||||
def test_rename_to_canonical_skips_grouped_movie(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||
|
||||
movie.title = "Avatar Retitled"
|
||||
assert file_manager.rename_to_canonical(movie) is None
|
||||
assert movie.title == "Avatar Retitled" # metadata field unaffected
|
||||
assert file_manager.is_movie_group_member(movie) # file untouched
|
||||
|
||||
@@ -942,3 +942,30 @@ class TestMirrorAsIs:
|
||||
"""Neexistující zdroj nic neudělá"""
|
||||
manager = HardlinkManager(tmp_path / "out")
|
||||
assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0)
|
||||
|
||||
|
||||
class TestRemoveMirror:
|
||||
"""Removing a whole mirrored library (or one bucket entry) from the output."""
|
||||
|
||||
def test_removes_a_whole_mirrored_folder(self, tmp_path):
|
||||
output = tmp_path / "out"
|
||||
(output / "Dokumenty" / "sub").mkdir(parents=True)
|
||||
(output / "Dokumenty" / "sub" / "clip.mp4").write_bytes(b"x")
|
||||
manager = HardlinkManager(output)
|
||||
|
||||
assert manager.remove_mirror("Dokumenty") is True
|
||||
assert not (output / "Dokumenty").exists()
|
||||
|
||||
def test_removes_one_bucket_entry_by_compound_name(self, tmp_path):
|
||||
output = tmp_path / "out"
|
||||
(output / "Seriály" / "Show A").mkdir(parents=True)
|
||||
(output / "Seriály" / "Show B").mkdir(parents=True)
|
||||
manager = HardlinkManager(output)
|
||||
|
||||
assert manager.remove_mirror("Seriály/Show A") is True
|
||||
assert not (output / "Seriály" / "Show A").exists()
|
||||
assert (output / "Seriály" / "Show B").exists() # sibling untouched
|
||||
|
||||
def test_missing_target_returns_false(self, tmp_path):
|
||||
manager = HardlinkManager(tmp_path / "out")
|
||||
assert manager.remove_mirror("Nope") is False
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for recovering ČSFD link / title / year from pre-index layouts."""
|
||||
import json
|
||||
|
||||
from src.core.legacy_meta import LegacyMeta, read_legacy_meta
|
||||
|
||||
|
||||
def _video(tmp_path, name="Matrix (1999).mkv"):
|
||||
v = tmp_path / name
|
||||
v.write_bytes(b"x")
|
||||
return v
|
||||
|
||||
|
||||
def test_reads_tagger_era_sidecar(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text(json.dumps({
|
||||
"title": "Matrix",
|
||||
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
|
||||
"csfd_cache": {"year": 1999, "rating": 90},
|
||||
}), encoding="utf-8")
|
||||
|
||||
meta = read_legacy_meta(video)
|
||||
assert meta == LegacyMeta(
|
||||
csfd_url="https://www.csfd.cz/film/9499-matrix/",
|
||||
title="Matrix", year="1999", source="sidecar")
|
||||
|
||||
|
||||
def test_sidecar_new_key_and_meta_block(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text(json.dumps({
|
||||
"title": "Matrix",
|
||||
"csfd_url": "https://www.csfd.cz/film/9499/",
|
||||
"meta": {"year": "1999"},
|
||||
}), encoding="utf-8")
|
||||
|
||||
meta = read_legacy_meta(video)
|
||||
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
|
||||
assert meta.year == "1999"
|
||||
|
||||
|
||||
def test_reads_old_pool_index_by_relative_key(tmp_path):
|
||||
pool = tmp_path / "old-pool"
|
||||
movies_dir = pool / "Filmy"
|
||||
movies_dir.mkdir(parents=True)
|
||||
video = _video(movies_dir)
|
||||
(pool / ".Curator.!index").write_text(json.dumps({
|
||||
"settings": {},
|
||||
"movies": {
|
||||
"Filmy/Matrix (1999).mkv": {
|
||||
"title": "Matrix",
|
||||
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
|
||||
"csfd_cache": {"year": 1999},
|
||||
}
|
||||
},
|
||||
}), encoding="utf-8")
|
||||
|
||||
meta = read_legacy_meta(video)
|
||||
assert meta.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
||||
assert meta.title == "Matrix"
|
||||
assert meta.year == "1999"
|
||||
assert meta.source == "index"
|
||||
|
||||
|
||||
def test_index_fallback_to_unique_basename_match(tmp_path):
|
||||
pool = tmp_path / "pool"
|
||||
pool.mkdir()
|
||||
elsewhere = tmp_path / "loose"
|
||||
elsewhere.mkdir()
|
||||
video = _video(elsewhere)
|
||||
(pool.parent / ".Curator.!index").write_text(json.dumps({
|
||||
"movies": {"whatever/Matrix (1999).mkv": {
|
||||
"csfd_url": "https://www.csfd.cz/film/9499/"}},
|
||||
}), encoding="utf-8")
|
||||
|
||||
meta = read_legacy_meta(video)
|
||||
assert meta is not None
|
||||
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
|
||||
|
||||
|
||||
def test_sidecar_wins_over_index(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text(
|
||||
json.dumps({"csfd_link": "https://www.csfd.cz/film/SIDECAR/"}), encoding="utf-8")
|
||||
(tmp_path / ".Curator.!index").write_text(json.dumps({
|
||||
"movies": {"Matrix (1999).mkv": {"csfd_link": "https://www.csfd.cz/film/INDEX/"}},
|
||||
}), encoding="utf-8")
|
||||
|
||||
assert read_legacy_meta(video).csfd_url == "https://www.csfd.cz/film/SIDECAR/"
|
||||
|
||||
|
||||
def test_none_when_no_metadata(tmp_path):
|
||||
assert read_legacy_meta(_video(tmp_path)) is None
|
||||
|
||||
|
||||
def test_none_on_broken_sidecar(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text("{not json", encoding="utf-8")
|
||||
assert read_legacy_meta(video) is None
|
||||
|
||||
|
||||
def test_record_without_useful_fields_is_none(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text(
|
||||
json.dumps({"new": True, "ignored": False, "tags": []}), encoding="utf-8")
|
||||
assert read_legacy_meta(video) is None
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for the movie object and the pool layout it derives (src.core.movie)."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.movie import (
|
||||
DEFAULT_VERSION_LABEL,
|
||||
MediaFile,
|
||||
Movie,
|
||||
identity_key,
|
||||
media_filename,
|
||||
normalize_3d_label,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,expected", [
|
||||
("HSBS", "HSBS"), ("hsbs", "HSBS"), ("3D HSBS", "HSBS"),
|
||||
("3d.hsbs", "HSBS"), ("3D_FTAB", "FTAB"), ("3d-mvc", "MVC"),
|
||||
("4K", None), ("Director's Cut", None), ("", None), (None, None),
|
||||
])
|
||||
def test_normalize_3d_label(label, expected):
|
||||
assert normalize_3d_label(label) == expected
|
||||
|
||||
|
||||
class TestMediaFilename:
|
||||
def test_no_label_is_the_bare_stem(self):
|
||||
assert media_filename("Matrix (1999)", "", ".mkv", grouped=False) == \
|
||||
"Matrix (1999).mkv"
|
||||
|
||||
def test_3d_label_uses_jellyfins_documented_form(self):
|
||||
assert media_filename("Avatar (2009)", "3D HSBS", ".mkv", grouped=False) == \
|
||||
"Avatar (2009).3D.HSBS.mkv"
|
||||
# same spelling grouped — "." is a valid separator after the folder name
|
||||
assert media_filename("Avatar (2009)", "hsbs", ".mkv", grouped=True) == \
|
||||
"Avatar (2009).3D.HSBS.mkv"
|
||||
|
||||
def test_plain_label_uses_dash_form(self):
|
||||
assert media_filename("Avatar (2009)", "4K", ".mkv", grouped=True) == \
|
||||
"Avatar (2009) - 4K.mkv"
|
||||
|
||||
def test_grouped_empty_label_gets_a_default(self):
|
||||
assert media_filename("Avatar (2009)", "", ".mkv", grouped=True) == \
|
||||
f"Avatar (2009) - {DEFAULT_VERSION_LABEL}.mkv"
|
||||
|
||||
|
||||
class TestIdentityKey:
|
||||
def test_tmdb_id_wins(self):
|
||||
assert identity_key(603, "Matrix", "1999") == "tmdb-603"
|
||||
assert identity_key("603", "cokoli", None) == "tmdb-603"
|
||||
|
||||
def test_slug_fallback_without_id(self):
|
||||
assert identity_key(None, "Matrix", "1999") == "name-matrix-1999"
|
||||
|
||||
def test_slug_folds_diacritics(self):
|
||||
assert identity_key(None, "Pelíšky", "1999") == "name-pelisky-1999"
|
||||
|
||||
def test_slug_without_year(self):
|
||||
assert identity_key(None, "Nějaký film", None) == "name-nejaky-film"
|
||||
|
||||
|
||||
def _movie(**kwargs) -> Movie:
|
||||
base = dict(key="tmdb-19995", title="Avatar", year="2009", tmdb_id=19995,
|
||||
library="Filmy")
|
||||
base.update(kwargs)
|
||||
return Movie(**base)
|
||||
|
||||
|
||||
class TestMovieShape:
|
||||
def test_stem_is_the_canonical_form(self):
|
||||
assert _movie().stem == "Avatar (2009) [tmdbid-19995]"
|
||||
|
||||
def test_stem_falls_back_to_title_without_year(self):
|
||||
assert _movie(year=None, tmdb_id=None).stem == "Avatar"
|
||||
|
||||
def test_single_video_is_not_grouped(self):
|
||||
m = _movie(media=[MediaFile(Path("/pool/Filmy/x.mkv"))])
|
||||
assert m.is_grouped is False
|
||||
|
||||
def test_two_videos_are_grouped(self):
|
||||
m = _movie(media=[MediaFile(Path("/a.mkv"), "3D HSBS"),
|
||||
MediaFile(Path("/b.mkv"), "Výchozí")])
|
||||
assert m.is_grouped is True
|
||||
|
||||
def test_primary_prefers_the_plain_version(self):
|
||||
m = _movie(media=[MediaFile(Path("/a.mkv"), "3D HSBS"),
|
||||
MediaFile(Path("/b.mkv"), "")])
|
||||
assert m.primary.label == ""
|
||||
|
||||
|
||||
class TestDesiredLayout:
|
||||
def test_one_video_stays_flat_in_the_library(self, tmp_path):
|
||||
m = _movie(media=[MediaFile(tmp_path / "kdekoli.mkv", "")])
|
||||
(media, target), = m.desired_layout(tmp_path)
|
||||
assert target == tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995].mkv"
|
||||
|
||||
def test_one_3d_video_keeps_the_3d_tag_flat(self, tmp_path):
|
||||
m = _movie(media=[MediaFile(tmp_path / "x.mkv", "3D HSBS")])
|
||||
(_m, target), = m.desired_layout(tmp_path)
|
||||
assert target.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||
assert target.parent == tmp_path / "Filmy"
|
||||
|
||||
def test_two_videos_move_into_the_movie_folder(self, tmp_path):
|
||||
m = _movie(media=[MediaFile(tmp_path / "a.mkv", ""),
|
||||
MediaFile(tmp_path / "b.mkv", "3D HSBS")])
|
||||
targets = {mf.label: path for mf, path in m.desired_layout(tmp_path)}
|
||||
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
|
||||
assert targets[""] == folder / \
|
||||
f"Avatar (2009) [tmdbid-19995] - {DEFAULT_VERSION_LABEL}.mkv"
|
||||
assert targets["3D HSBS"] == folder / "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||
|
||||
def test_layout_follows_a_retitle(self, tmp_path):
|
||||
"""Renaming the movie is just a different desired layout — the thing
|
||||
that used to be impossible for a grouped movie."""
|
||||
m = _movie(title="Avatar: Nová verze",
|
||||
media=[MediaFile(tmp_path / "a.mkv", "A"),
|
||||
MediaFile(tmp_path / "b.mkv", "B")])
|
||||
folders = {path.parent for _mf, path in m.desired_layout(tmp_path)}
|
||||
assert folders == {
|
||||
tmp_path / "Filmy" / "Avatar: Nová verze (2009) [tmdbid-19995]"}
|
||||
|
||||
def test_layout_follows_a_library_move(self, tmp_path):
|
||||
m = _movie(library="Kids", media=[MediaFile(tmp_path / "a.mkv", "")])
|
||||
(_m, target), = m.desired_layout(tmp_path)
|
||||
assert target.parent == tmp_path / "Kids"
|
||||
|
||||
|
||||
class TestRecordRoundTrip:
|
||||
def test_to_and_from_record(self, tmp_path):
|
||||
m = _movie(
|
||||
media=[MediaFile(tmp_path / "Filmy" / "a.mkv", "3D HSBS")],
|
||||
csfd_url="https://www.csfd.cz/film/1/", date="2026-01-01",
|
||||
attributes={"collection_sort": "01"}, meta={"year": "2009"})
|
||||
|
||||
record = m.to_record(tmp_path)
|
||||
assert record["media"] == [
|
||||
{"role": "video", "label": "3D HSBS", "file": "Filmy/a.mkv"}]
|
||||
|
||||
back = Movie.from_record("tmdb-19995", record, tmp_path)
|
||||
assert back.title == "Avatar"
|
||||
assert back.year == "2009"
|
||||
assert back.tmdb_id == 19995
|
||||
assert back.library == "Filmy"
|
||||
assert back.csfd_url == "https://www.csfd.cz/film/1/"
|
||||
assert back.attributes == {"collection_sort": "01"}
|
||||
assert back.meta == {"year": "2009"}
|
||||
assert back.media[0].path == tmp_path / "Filmy" / "a.mkv"
|
||||
assert back.media[0].format_3d == "HSBS"
|
||||
|
||||
def test_from_record_builds_tags_via_tagmanager(self, tmp_path):
|
||||
from src.core.tag_manager import TagManager
|
||||
record = {"title": "Avatar", "media": [], "tags": ["Žánr/Sci-Fi"]}
|
||||
|
||||
back = Movie.from_record("tmdb-19995", record, tmp_path, TagManager())
|
||||
|
||||
assert {t.full_path for t in back.tags} == {"Žánr/Sci-Fi"}
|
||||
@@ -2,9 +2,11 @@
|
||||
import pytest
|
||||
|
||||
from src.core.naming import (
|
||||
THREE_D_FORMATS,
|
||||
canonical_pool_stem,
|
||||
is_canonical_pool_stem,
|
||||
is_valid_year,
|
||||
parse_3d_format,
|
||||
parse_pool_stem,
|
||||
parse_tmdb_id,
|
||||
year_from_filename,
|
||||
@@ -79,6 +81,43 @@ def test_is_canonical_pool_stem():
|
||||
assert not is_canonical_pool_stem("Matrix")
|
||||
|
||||
|
||||
def test_canonical_pool_stem_with_3d_format():
|
||||
assert canonical_pool_stem("Avatar", 2009, 19995, "hsbs") == \
|
||||
"Avatar (2009) [tmdbid-19995].3D.HSBS"
|
||||
# upper-cased regardless of input case
|
||||
assert canonical_pool_stem("Avatar", 2009, None, "fsbs") == "Avatar (2009).3D.FSBS"
|
||||
# empty/None → no tag at all
|
||||
assert canonical_pool_stem("Avatar", 2009, None, None) == "Avatar (2009)"
|
||||
assert canonical_pool_stem("Avatar", 2009, None, "") == "Avatar (2009)"
|
||||
|
||||
|
||||
def test_canonical_pool_stem_rejects_bad_3d_format():
|
||||
with pytest.raises(ValueError):
|
||||
canonical_pool_stem("Avatar", 2009, None, "bogus")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fmt", THREE_D_FORMATS)
|
||||
def test_parse_3d_format_roundtrip(fmt):
|
||||
stem = canonical_pool_stem("Avatar", 2009, 19995, fmt)
|
||||
assert parse_3d_format(stem) == fmt
|
||||
assert parse_pool_stem(stem) == ("Avatar", 2009)
|
||||
assert parse_tmdb_id(stem) == 19995
|
||||
|
||||
|
||||
def test_parse_3d_format_case_insensitive_on_read():
|
||||
assert parse_3d_format("Avatar (2009).3D.hsbs") == "HSBS"
|
||||
|
||||
|
||||
def test_parse_3d_format_absent_is_none():
|
||||
assert parse_3d_format("Avatar (2009) [tmdbid-19995]") is None
|
||||
assert parse_3d_format("Avatar (2009)") is None
|
||||
|
||||
|
||||
def test_canonical_pool_stem_3d_without_tmdb_id():
|
||||
assert canonical_pool_stem("Avatar", 2009, None, "MVC") == "Avatar (2009).3D.MVC"
|
||||
assert parse_3d_format("Avatar (2009).3D.MVC") == "MVC"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,year", [
|
||||
("Matrix.1999.1080p.BluRay.mkv", "1999"),
|
||||
("Šíleně smutná princezna (1968).mkv", "1968"),
|
||||
|
||||
@@ -52,6 +52,35 @@ def test_tmdbid_from_tmdbid_tag(tmp_path):
|
||||
assert read_nfo(video).tmdb_id == 603
|
||||
|
||||
|
||||
def test_csfd_url_from_uniqueid_bare_id(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
'<movie><uniqueid type="csfd">9499</uniqueid></movie>', encoding="utf-8")
|
||||
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499/"
|
||||
|
||||
|
||||
def test_csfd_url_from_uniqueid_full_url(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
'<movie><uniqueid type="csfd">https://www.csfd.cz/film/9499-matrix/'
|
||||
'</uniqueid></movie>', encoding="utf-8")
|
||||
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
||||
|
||||
|
||||
def test_csfd_url_from_legacy_csfdid_tag(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
"<movie><csfdid>12345</csfdid></movie>", encoding="utf-8")
|
||||
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/12345/"
|
||||
|
||||
|
||||
def test_csfd_url_absent_is_none(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
"<movie><title>Matrix</title></movie>", encoding="utf-8")
|
||||
assert read_nfo(video).csfd_url is None
|
||||
|
||||
|
||||
def test_find_prefers_sibling_over_movie_nfo(tmp_path):
|
||||
folder = tmp_path / "Matrix (1999)"
|
||||
folder.mkdir()
|
||||
|
||||
+144
-23
@@ -1,48 +1,68 @@
|
||||
import json
|
||||
|
||||
from src.core.pool_index import PoolIndex, INDEX_FILENAME
|
||||
from src.core.pool_index import PoolIndex, INDEX_FILENAME, INDEX_VERSION
|
||||
from src.core.file import File
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
|
||||
class TestPoolIndex:
|
||||
"""Testy pro unifikovaný metadata index poolu"""
|
||||
"""Testy pro unifikovaný metadata index poolu (v2 — klíč = identita filmu)"""
|
||||
|
||||
def test_empty_index_when_no_file(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
assert index.records == {}
|
||||
assert index.get(tmp_path / "Filmy" / "x.mkv") is None
|
||||
assert index.get_movie("tmdb-603") is None
|
||||
|
||||
def test_set_and_get_by_relative_key(self, tmp_path):
|
||||
def test_set_and_get_movie_by_identity(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
path = tmp_path / "Filmy" / "Matrix.mkv"
|
||||
index.set(path, {"title": "Matrix"})
|
||||
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||
|
||||
assert index.get(path) == {"title": "Matrix"}
|
||||
# key is the pool-relative POSIX path
|
||||
assert "Filmy/Matrix.mkv" in index.records
|
||||
assert index.get_movie("tmdb-603") == {"title": "Matrix", "media": []}
|
||||
assert "tmdb-603" in index.records
|
||||
|
||||
def test_set_persists_to_disk(self, tmp_path):
|
||||
def test_set_persists_to_disk_with_version(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
index.set(tmp_path / "Filmy" / "A.mkv", {"title": "A"})
|
||||
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||
|
||||
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
|
||||
assert on_disk["movies"]["Filmy/A.mkv"]["title"] == "A"
|
||||
assert on_disk["version"] == INDEX_VERSION
|
||||
assert on_disk["movies"]["tmdb-603"]["title"] == "Matrix"
|
||||
|
||||
def test_reload_reads_existing_index(self, tmp_path):
|
||||
PoolIndex(tmp_path).set(tmp_path / "Filmy" / "A.mkv", {"title": "A"})
|
||||
PoolIndex(tmp_path).set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||
|
||||
reloaded = PoolIndex(tmp_path)
|
||||
assert reloaded.get(tmp_path / "Filmy" / "A.mkv") == {"title": "A"}
|
||||
assert reloaded.get_movie("tmdb-603")["title"] == "Matrix"
|
||||
|
||||
def test_delete_removes_record(self, tmp_path):
|
||||
def test_delete_movie_removes_record(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
path = tmp_path / "Filmy" / "A.mkv"
|
||||
index.set(path, {"title": "A"})
|
||||
index.delete(path)
|
||||
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||
index.delete_movie("tmdb-603")
|
||||
|
||||
assert index.get(path) is None
|
||||
assert PoolIndex(tmp_path).get(path) is None
|
||||
assert index.get_movie("tmdb-603") is None
|
||||
assert PoolIndex(tmp_path).get_movie("tmdb-603") is None
|
||||
|
||||
def test_rekey_movie(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
index.set_movie("name-matrix-1999", {"title": "Matrix", "media": []})
|
||||
|
||||
index.rekey_movie("name-matrix-1999", "tmdb-603")
|
||||
|
||||
assert index.get_movie("name-matrix-1999") is None
|
||||
assert index.get_movie("tmdb-603")["title"] == "Matrix"
|
||||
|
||||
def test_find_movie_by_media(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
index.set_movie("tmdb-603", {
|
||||
"title": "Matrix",
|
||||
"media": [{"role": "video", "label": "", "file": "Filmy/Matrix.mkv"}]})
|
||||
|
||||
found = index.find_movie_by_media(tmp_path / "Filmy" / "Matrix.mkv")
|
||||
assert found is not None
|
||||
key, record = found
|
||||
assert key == "tmdb-603"
|
||||
assert record["title"] == "Matrix"
|
||||
assert index.find_movie_by_media(tmp_path / "Filmy" / "Jiny.mkv") is None
|
||||
|
||||
def test_corrupt_index_loads_empty(self, tmp_path):
|
||||
(tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8")
|
||||
@@ -50,8 +70,89 @@ class TestPoolIndex:
|
||||
assert index.records == {}
|
||||
|
||||
|
||||
class TestV1Migration:
|
||||
"""Starý path-klíčovaný index se převede na identity-klíčovaný (bez hýbání soubory)"""
|
||||
|
||||
def _write_v1(self, tmp_path, movies: dict) -> None:
|
||||
(tmp_path / INDEX_FILENAME).write_text(
|
||||
json.dumps({"settings": {"tag_schema": []}, "movies": movies}),
|
||||
encoding="utf-8")
|
||||
|
||||
def test_flat_movie_becomes_identity_keyed_record(self, tmp_path):
|
||||
self._write_v1(tmp_path, {
|
||||
"Filmy/Matrix (1999) [tmdbid-603].mkv": {
|
||||
"title": "Matrix", "tags": ["Žánr/Akční"],
|
||||
"csfd_url": "https://www.csfd.cz/film/9499/",
|
||||
"date": "1999-03-31", "added": "2026-01-01T00:00:00",
|
||||
"attributes": {"collection_sort": "01"},
|
||||
}})
|
||||
|
||||
index = PoolIndex(tmp_path)
|
||||
|
||||
record = index.get_movie("tmdb-603")
|
||||
assert record is not None
|
||||
assert record["title"] == "Matrix"
|
||||
assert record["year"] == "1999"
|
||||
assert record["tmdb_id"] == 603
|
||||
assert record["library"] == "Filmy"
|
||||
assert record["media"] == [
|
||||
{"role": "video", "label": "",
|
||||
"file": "Filmy/Matrix (1999) [tmdbid-603].mkv"}]
|
||||
assert record["tags"] == ["Žánr/Akční"]
|
||||
assert record["csfd_url"] == "https://www.csfd.cz/film/9499/"
|
||||
assert record["attributes"] == {"collection_sort": "01"}
|
||||
|
||||
def test_grouped_versions_merge_into_one_record(self, tmp_path):
|
||||
base = "Filmy/Avatar (2009) [tmdbid-19995]"
|
||||
self._write_v1(tmp_path, {
|
||||
f"{base}/Avatar (2009) [tmdbid-19995] - Výchozí.mkv": {"title": "Avatar"},
|
||||
f"{base}/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv": {"title": "Avatar"},
|
||||
})
|
||||
|
||||
index = PoolIndex(tmp_path)
|
||||
|
||||
assert len(index.all_movies()) == 1
|
||||
record = index.get_movie("tmdb-19995")
|
||||
assert {m["label"] for m in record["media"]} == {"Výchozí", "3D HSBS"}
|
||||
assert record["library"] == "Filmy"
|
||||
|
||||
def test_movie_without_tmdbid_gets_slug_key(self, tmp_path):
|
||||
self._write_v1(tmp_path, {
|
||||
"Filmy/Pelíšky (1999).mkv": {"title": "Pelíšky"}})
|
||||
|
||||
index = PoolIndex(tmp_path)
|
||||
|
||||
assert index.get_movie("name-pelisky-1999") is not None
|
||||
|
||||
def test_migration_backs_up_and_bumps_version(self, tmp_path):
|
||||
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
|
||||
|
||||
PoolIndex(tmp_path)
|
||||
|
||||
backup = tmp_path / (INDEX_FILENAME + ".v1.bak")
|
||||
assert backup.exists()
|
||||
assert "Filmy/A (2000).mkv" in json.loads(backup.read_text(encoding="utf-8"))["movies"]
|
||||
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
|
||||
assert on_disk["version"] == INDEX_VERSION
|
||||
|
||||
def test_migration_keeps_settings(self, tmp_path):
|
||||
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
|
||||
index = PoolIndex(tmp_path)
|
||||
assert index.get_setting("tag_schema") == []
|
||||
|
||||
def test_v2_index_is_not_migrated_again(self, tmp_path):
|
||||
(tmp_path / INDEX_FILENAME).write_text(json.dumps({
|
||||
"version": INDEX_VERSION, "settings": {},
|
||||
"movies": {"tmdb-1": {"title": "X", "media": []}}}), encoding="utf-8")
|
||||
|
||||
index = PoolIndex(tmp_path)
|
||||
|
||||
assert index.get_movie("tmdb-1")["title"] == "X"
|
||||
assert not (tmp_path / (INDEX_FILENAME + ".v1.bak")).exists()
|
||||
|
||||
|
||||
class TestFileWithIndex:
|
||||
"""File používá index místo sidecar souboru, je-li injektován"""
|
||||
"""File je pohled na jeden media záznam filmu v indexu"""
|
||||
|
||||
def test_index_backed_file_writes_no_sidecar(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
@@ -62,7 +163,7 @@ class TestFileWithIndex:
|
||||
f = File(movie, TagManager(), index=index)
|
||||
|
||||
assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar
|
||||
assert index.get(movie) is not None # record created in index
|
||||
assert index.find_movie_by_media(movie) is not None # record created
|
||||
assert f.tags == [] # no automatic tags
|
||||
|
||||
def test_index_backed_metadata_persists_across_reload(self, tmp_path):
|
||||
@@ -94,4 +195,24 @@ class TestFileWithIndex:
|
||||
|
||||
f = File(movie, TagManager(), index=index)
|
||||
f.delete_metadata()
|
||||
assert index.get(movie) is None
|
||||
assert index.find_movie_by_media(movie) is None
|
||||
|
||||
def test_two_versions_share_one_movie_record(self, tmp_path):
|
||||
"""Obě verze čtou stejná metadata — to je smysl identity klíče."""
|
||||
index = PoolIndex(tmp_path)
|
||||
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
|
||||
folder.mkdir(parents=True)
|
||||
a = folder / "Avatar (2009) [tmdbid-19995] - Výchozí.mkv"
|
||||
b = folder / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"
|
||||
a.write_bytes(b"x")
|
||||
b.write_bytes(b"y")
|
||||
|
||||
tm = TagManager()
|
||||
fa = File(a, tm, index=index)
|
||||
fa.title = "Avatar"
|
||||
fa.add_tag("Žánr/Sci-Fi")
|
||||
fb = File(b, tm, index=index)
|
||||
|
||||
assert len(index.all_movies()) == 1
|
||||
assert fb.title == "Avatar"
|
||||
assert "Žánr/Sci-Fi" in {t.full_path for t in fb.tags}
|
||||
|
||||
+138
-16
@@ -2,9 +2,12 @@
|
||||
import pytest
|
||||
|
||||
from src.core.series import (
|
||||
collect_episode_files,
|
||||
episode_filename,
|
||||
is_canonical_episode,
|
||||
is_season_folder,
|
||||
parse_episode,
|
||||
sanitize_title,
|
||||
scan_series,
|
||||
season_folder_name,
|
||||
series_folder_stem,
|
||||
@@ -29,13 +32,29 @@ def test_season_folder_name():
|
||||
assert season_folder_name(12) == "Season 12"
|
||||
|
||||
|
||||
def test_episode_filename_keeps_original_name():
|
||||
assert episode_filename(1, 2, "bb.pilot.hdtv", ".mkv") == "S01E02 - bb.pilot.hdtv.mkv"
|
||||
# already-canonical stem → no doubled prefix
|
||||
assert episode_filename(1, 2, "S01E02 - bb.pilot.hdtv", ".mkv") == \
|
||||
"S01E02 - bb.pilot.hdtv.mkv"
|
||||
# stem that was only the code → collapses to just the code
|
||||
assert episode_filename(3, 4, "S03E04", ".mkv") == "S03E04.mkv"
|
||||
def test_episode_filename_is_code_plus_series_title():
|
||||
assert episode_filename(1, 2, "Most!", ".mkv") == "S01E02 - Most!.mkv"
|
||||
assert episode_filename(0, 3, "Most!", ".mkv") == "S00E03 - Most!.mkv"
|
||||
# release string is NOT kept — only the code + series title
|
||||
assert episode_filename(12, 145, "Breaking Bad", ".mkv") == \
|
||||
"S12E145 - Breaking Bad.mkv"
|
||||
# empty / unusable title → just the code
|
||||
assert episode_filename(3, 4, "", ".mkv") == "S03E04.mkv"
|
||||
assert episode_filename(3, 4, "/", ".mkv") == "S03E04.mkv"
|
||||
|
||||
|
||||
def test_sanitize_title():
|
||||
assert sanitize_title("Most!") == "Most!"
|
||||
assert sanitize_title('A: B / C D') == "A B C D"
|
||||
assert sanitize_title(" trailing. ") == "trailing"
|
||||
|
||||
|
||||
def test_is_season_folder():
|
||||
assert is_season_folder("Season 01")
|
||||
assert is_season_folder("Season 1")
|
||||
assert is_season_folder("Specials")
|
||||
assert not is_season_folder("Extras")
|
||||
assert not is_season_folder("Season")
|
||||
|
||||
|
||||
def test_series_folder_stem():
|
||||
@@ -47,12 +66,15 @@ def test_series_folder_stem():
|
||||
def test_is_canonical_episode(tmp_path):
|
||||
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
||||
folder.mkdir(parents=True)
|
||||
good = folder / "S01E02 - bb.pilot.mkv"
|
||||
good = folder / "S01E02 - Breaking Bad.mkv"
|
||||
good.write_bytes(b"x")
|
||||
bad = folder / "bb.s01e02.mkv"
|
||||
bad.write_bytes(b"x")
|
||||
assert is_canonical_episode(good)
|
||||
assert not is_canonical_episode(bad)
|
||||
wrong_body = folder / "S01E02 - old release.mkv"
|
||||
wrong_body.write_bytes(b"x")
|
||||
assert is_canonical_episode(good, "Breaking Bad")
|
||||
assert not is_canonical_episode(bad, "Breaking Bad")
|
||||
assert not is_canonical_episode(wrong_body, "Breaking Bad")
|
||||
|
||||
|
||||
def test_scan_series(tmp_path):
|
||||
@@ -73,6 +95,26 @@ def test_scan_series(tmp_path):
|
||||
assert list(seasons.keys()) == [1] and len(seasons[1]) == 2
|
||||
|
||||
|
||||
def test_collect_episode_files_walks_subfolders(tmp_path):
|
||||
root = tmp_path / "Breaking.Bad.COMPLETE"
|
||||
(root / "Season 01").mkdir(parents=True)
|
||||
(root / "Season 02").mkdir(parents=True)
|
||||
(root / "Season 01" / "BB S01E01.mkv").write_bytes(b"x")
|
||||
(root / "Season 01" / "BB S01E02.mkv").write_bytes(b"x")
|
||||
(root / "Season 01" / "BB S01E02.srt").write_bytes(b"x") # sidecar → skipped
|
||||
(root / "Season 02" / "BB S02E01.avi").write_bytes(b"x")
|
||||
(root / "readme.nfo").write_bytes(b"x") # sidecar → skipped
|
||||
|
||||
files = collect_episode_files(root)
|
||||
|
||||
assert [f.name for f in files] == [
|
||||
"BB S01E01.mkv", "BB S01E02.mkv", "BB S02E01.avi"]
|
||||
|
||||
|
||||
def test_collect_episode_files_missing_folder_is_empty(tmp_path):
|
||||
assert collect_episode_files(tmp_path / "nope") == []
|
||||
|
||||
|
||||
class TestFileManagerSeries:
|
||||
@pytest.fixture
|
||||
def file_manager(self, tmp_path, monkeypatch):
|
||||
@@ -94,8 +136,8 @@ class TestFileManagerSeries:
|
||||
assert result["imported"] == 2
|
||||
base = (tmp_path / "pool" / "Seriály"
|
||||
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
|
||||
assert (base / "S01E01 - bb.mkv").exists()
|
||||
assert (base / "S01E02 - bb.mkv").exists()
|
||||
assert (base / "S01E01 - Breaking Bad.mkv").exists()
|
||||
assert (base / "S01E02 - Breaking Bad.mkv").exists()
|
||||
|
||||
def test_import_episodes_into_mixed_library(self, file_manager, tmp_path):
|
||||
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||
@@ -106,7 +148,7 @@ class TestFileManagerSeries:
|
||||
|
||||
assert result["imported"] == 1
|
||||
assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]"
|
||||
/ "Season 01" / "S01E01 - bb.mkv").exists()
|
||||
/ "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
|
||||
# a mixed library is scanned for series too
|
||||
found = file_manager.load_series()
|
||||
assert [s.title for s in found] == ["Breaking Bad"]
|
||||
@@ -118,6 +160,45 @@ class TestFileManagerSeries:
|
||||
assert result["imported"] == 0
|
||||
assert result["skipped_no_code"] == ["random.mkv"]
|
||||
|
||||
def test_remove_series_deletes_the_folder(self, file_manager, tmp_path):
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
(tmp_path / "bb.S02E01.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S02E01.mkv"],
|
||||
"Breaking Bad", year=2008, tmdb_id=1396)
|
||||
(ser,) = file_manager.load_series()
|
||||
assert ser.folder.is_dir()
|
||||
|
||||
file_manager.remove_series(ser)
|
||||
|
||||
assert not ser.folder.exists()
|
||||
assert file_manager.load_series() == []
|
||||
|
||||
def test_remove_series_refuses_folder_outside_pool(self, file_manager, tmp_path):
|
||||
from src.core.series import Series
|
||||
outside = tmp_path / "not-the-pool" / "Show (2020)"
|
||||
outside.mkdir(parents=True)
|
||||
bogus = Series(outside, "Show", "2020", None, "Seriály")
|
||||
with pytest.raises(ValueError):
|
||||
file_manager.remove_series(bogus)
|
||||
assert outside.is_dir() # untouched
|
||||
|
||||
def test_import_episodes_uses_explicit_codes_override(self, file_manager, tmp_path):
|
||||
uncoded = tmp_path / "pilot.mkv"
|
||||
uncoded.write_bytes(b"x")
|
||||
misparsed = tmp_path / "bb 2008 e07.mkv" # "2008" would parse as 20x08
|
||||
misparsed.write_bytes(b"x")
|
||||
result = file_manager.import_episodes(
|
||||
[uncoded, misparsed], "Breaking Bad", year=2008, tmdb_id=1396,
|
||||
codes={uncoded: (1, 1), misparsed: (1, 7)})
|
||||
|
||||
assert result["imported"] == 2
|
||||
base = (tmp_path / "pool" / "Seriály"
|
||||
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
|
||||
assert (base / "S01E01 - Breaking Bad.mkv").exists()
|
||||
assert (base / "S01E07 - Breaking Bad.mkv").exists()
|
||||
assert result["skipped_no_code"] == []
|
||||
|
||||
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"
|
||||
@@ -129,8 +210,49 @@ class TestFileManagerSeries:
|
||||
assert len(series_list) == 1
|
||||
result = file_manager.rename_episodes_to_canonical(series_list[0])
|
||||
|
||||
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - bb.HDTV.mkv")]
|
||||
assert (folder / "Season 01" / "S01E03 - bb.HDTV.mkv").exists()
|
||||
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
|
||||
assert (folder / "Season 01" / "S01E03 - Breaking Bad.mkv").exists()
|
||||
|
||||
def test_rename_episodes_to_canonical_carries_subtitle_along(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
folder = (tmp_path / "pool" / "Seriály"
|
||||
/ "Breaking Bad (2008) [tmdbid-1396]")
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
|
||||
(folder / "bb.1x03.HDTV.cs.srt").write_bytes(b"x")
|
||||
|
||||
result = file_manager.rename_episodes_to_canonical(file_manager.load_series()[0])
|
||||
|
||||
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
|
||||
assert (folder / "Season 01" / "S01E03 - Breaking Bad.cs.srt").exists()
|
||||
assert not (folder / "bb.1x03.HDTV.cs.srt").exists()
|
||||
|
||||
def test_rename_all_series_to_canonical_bulk(self, file_manager, tmp_path):
|
||||
seri = tmp_path / "pool" / "Seriály"
|
||||
# series A: mis-named episodes in a non-padded season folder
|
||||
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").mkdir(parents=True)
|
||||
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1"
|
||||
/ "fargo.1x01.1080p.WEB.mkv").write_bytes(b"x")
|
||||
# series B: one good, one without a code
|
||||
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01").mkdir(parents=True)
|
||||
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
|
||||
/ "S01E02 - Most!.mkv").write_bytes(b"x")
|
||||
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
|
||||
/ "necoZvlastniho.mkv").write_bytes(b"x")
|
||||
|
||||
result = file_manager.rename_all_series_to_canonical()
|
||||
|
||||
assert result["series"] == 2
|
||||
assert ("Fargo", "fargo.1x01.1080p.WEB.mkv", "S01E01 - Fargo.mkv") in result["renamed"]
|
||||
assert (seri / "Fargo (2014) [tmdbid-60622]" / "Season 01"
|
||||
/ "S01E01 - Fargo.mkv").exists()
|
||||
# emptied non-padded season folder swept
|
||||
assert not (seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").exists()
|
||||
# the already-canonical one is untouched, the code-less one is reported
|
||||
assert ("Most!", "necoZvlastniho.mkv") in result["skipped_no_code"]
|
||||
assert (seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
|
||||
/ "S01E02 - Most!.mkv").exists()
|
||||
|
||||
def test_apply_series_edits_renames_folder_and_episodes(self, file_manager, tmp_path):
|
||||
folder = tmp_path / "pool" / "Seriály" / "Wrong Name"
|
||||
@@ -144,5 +266,5 @@ class TestFileManagerSeries:
|
||||
new_folder = (tmp_path / "pool" / "Seriály"
|
||||
/ "Breaking Bad (2008) [tmdbid-1396]")
|
||||
assert result["folder"] == new_folder
|
||||
assert (new_folder / "Season 01" / "S01E01 - old.mkv").exists()
|
||||
assert (new_folder / "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
|
||||
assert not folder.exists()
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Tests for external subtitle sidecars (src.core.subtitles)."""
|
||||
import pytest
|
||||
|
||||
from src.core.subtitles import (
|
||||
SubtitleFile,
|
||||
attach_subtitle,
|
||||
find_subtitles,
|
||||
rename_subtitles_with,
|
||||
subtitle_filename,
|
||||
)
|
||||
|
||||
|
||||
def _video(tmp_path, name="Matrix (1999) [tmdbid-603].mkv"):
|
||||
v = tmp_path / name
|
||||
v.write_bytes(b"x")
|
||||
return v
|
||||
|
||||
|
||||
class TestSubtitleFilename:
|
||||
def test_bare_no_language(self):
|
||||
assert subtitle_filename("Matrix (1999)", ".srt") == "Matrix (1999).srt"
|
||||
|
||||
def test_with_language(self):
|
||||
assert subtitle_filename("Matrix (1999)", ".srt", "cs") == "Matrix (1999).cs.srt"
|
||||
|
||||
def test_with_language_and_flag(self):
|
||||
assert subtitle_filename("Matrix (1999)", ".srt", "cs", "forced") == \
|
||||
"Matrix (1999).cs.forced.srt"
|
||||
|
||||
def test_uppercase_input_is_lowered(self):
|
||||
assert subtitle_filename("Matrix (1999)", ".SRT", "CS", "FORCED") == \
|
||||
"Matrix (1999).cs.forced.srt"
|
||||
|
||||
|
||||
class TestFindSubtitles:
|
||||
def test_finds_bare_language_and_flagged(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / "Matrix (1999) [tmdbid-603].srt").write_bytes(b"x")
|
||||
(tmp_path / "Matrix (1999) [tmdbid-603].cs.srt").write_bytes(b"x")
|
||||
(tmp_path / "Matrix (1999) [tmdbid-603].en.forced.srt").write_bytes(b"x")
|
||||
|
||||
found = {s.path.name: s for s in find_subtitles(video)}
|
||||
assert set(found) == {
|
||||
"Matrix (1999) [tmdbid-603].srt",
|
||||
"Matrix (1999) [tmdbid-603].cs.srt",
|
||||
"Matrix (1999) [tmdbid-603].en.forced.srt",
|
||||
}
|
||||
assert found["Matrix (1999) [tmdbid-603].srt"].language is None
|
||||
assert found["Matrix (1999) [tmdbid-603].cs.srt"].language == "cs"
|
||||
assert found["Matrix (1999) [tmdbid-603].en.forced.srt"].language == "en"
|
||||
assert found["Matrix (1999) [tmdbid-603].en.forced.srt"].flag == "forced"
|
||||
|
||||
def test_ignores_unrelated_and_overlapping_stem(self, tmp_path):
|
||||
video = _video(tmp_path, "Matrix.mkv")
|
||||
(tmp_path / "Matrix Reloaded.srt").write_bytes(b"x") # overlapping prefix
|
||||
(tmp_path / "Matrix.nfo").write_bytes(b"x") # not a subtitle ext
|
||||
(tmp_path / "poster.jpg").write_bytes(b"x")
|
||||
assert find_subtitles(video) == []
|
||||
|
||||
def test_ignores_other_movies_subtitle(self, tmp_path):
|
||||
video = _video(tmp_path, "Matrix.mkv")
|
||||
(tmp_path / "Other Movie.cs.srt").write_bytes(b"x")
|
||||
assert find_subtitles(video) == []
|
||||
|
||||
def test_missing_folder_is_empty(self, tmp_path):
|
||||
assert find_subtitles(tmp_path / "nope" / "video.mkv") == []
|
||||
|
||||
def test_label_property(self):
|
||||
assert SubtitleFile(path=None, language="cs").label == "cs"
|
||||
assert SubtitleFile(path=None, language="cs", flag="forced").label == "cs (forced)"
|
||||
assert SubtitleFile(path=None).label == "?"
|
||||
|
||||
|
||||
class TestAttachSubtitle:
|
||||
def test_copies_and_names_to_match_video(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
source = tmp_path / "downloaded.srt"
|
||||
source.write_bytes(b"subtitle content")
|
||||
|
||||
target = attach_subtitle(video, source, language="cs")
|
||||
|
||||
assert target == tmp_path / "Matrix (1999) [tmdbid-603].cs.srt"
|
||||
assert target.read_bytes() == b"subtitle content"
|
||||
assert source.exists() # copy, not move
|
||||
|
||||
def test_move_removes_source(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
source = tmp_path / "downloaded.srt"
|
||||
source.write_bytes(b"x")
|
||||
|
||||
target = attach_subtitle(video, source, language="en", move=True)
|
||||
|
||||
assert target.exists()
|
||||
assert not source.exists()
|
||||
|
||||
def test_overwrites_same_language_slot(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
old = tmp_path / "Matrix (1999) [tmdbid-603].cs.srt"
|
||||
old.write_bytes(b"old")
|
||||
source = tmp_path / "new.srt"
|
||||
source.write_bytes(b"new")
|
||||
|
||||
target = attach_subtitle(video, source, language="cs")
|
||||
|
||||
assert target == old
|
||||
assert target.read_bytes() == b"new"
|
||||
|
||||
def test_rejects_unsupported_extension(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
source = tmp_path / "notes.txt"
|
||||
source.write_bytes(b"x")
|
||||
with pytest.raises(ValueError):
|
||||
attach_subtitle(video, source)
|
||||
|
||||
|
||||
class TestRenameSubtitlesWith:
|
||||
def test_moves_every_sidecar_preserving_lang_and_flag(self, tmp_path):
|
||||
old_video = _video(tmp_path, "Matrix.mkv")
|
||||
(tmp_path / "Matrix.srt").write_bytes(b"x")
|
||||
(tmp_path / "Matrix.cs.srt").write_bytes(b"x")
|
||||
(tmp_path / "Matrix.en.forced.srt").write_bytes(b"x")
|
||||
new_video = tmp_path / "Matrix (1999) [tmdbid-603].mkv"
|
||||
|
||||
moved = rename_subtitles_with(old_video, new_video)
|
||||
|
||||
names = {p.name for p in moved}
|
||||
assert names == {
|
||||
"Matrix (1999) [tmdbid-603].srt",
|
||||
"Matrix (1999) [tmdbid-603].cs.srt",
|
||||
"Matrix (1999) [tmdbid-603].en.forced.srt",
|
||||
}
|
||||
for name in names:
|
||||
assert (tmp_path / name).exists()
|
||||
assert not (tmp_path / "Matrix.srt").exists()
|
||||
|
||||
def test_moves_across_folders(self, tmp_path):
|
||||
old_dir = tmp_path / "Filmy"
|
||||
old_dir.mkdir()
|
||||
old_video = old_dir / "Matrix.mkv"
|
||||
old_video.write_bytes(b"x")
|
||||
(old_dir / "Matrix.cs.srt").write_bytes(b"x")
|
||||
new_dir = tmp_path / "Kids"
|
||||
new_dir.mkdir()
|
||||
new_video = new_dir / "Matrix (1999).mkv"
|
||||
|
||||
moved = rename_subtitles_with(old_video, new_video)
|
||||
|
||||
assert moved == [new_dir / "Matrix (1999).cs.srt"]
|
||||
assert (new_dir / "Matrix (1999).cs.srt").exists()
|
||||
assert not (old_dir / "Matrix.cs.srt").exists()
|
||||
|
||||
def test_noop_when_path_unchanged(self, tmp_path):
|
||||
video = _video(tmp_path, "Matrix.mkv")
|
||||
(tmp_path / "Matrix.cs.srt").write_bytes(b"x")
|
||||
assert rename_subtitles_with(video, video) == []
|
||||
assert (tmp_path / "Matrix.cs.srt").exists()
|
||||
|
||||
def test_no_subtitles_is_noop(self, tmp_path):
|
||||
old_video = _video(tmp_path, "Matrix.mkv")
|
||||
new_video = tmp_path / "Matrix (1999).mkv"
|
||||
assert rename_subtitles_with(old_video, new_video) == []
|
||||
Reference in New Issue
Block a user