Add movie-keyed pool index with multi-version movies, ČSFD title bridge, subtitles and miniDLNA library scoping

This commit is contained in:
2026-09-13 17:16:48 +02:00
parent c0ad1c1553
commit c399a6aed3
26 changed files with 4635 additions and 221 deletions
+645 -9
View File
@@ -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