import pytest from src.core.file import File from src.core.file_manager import FileManager from src.core.tag import Tag from src.core.tag_manager import TagManager @pytest.fixture def temp_global_config(tmp_path, monkeypatch): """Point the global config file at a throwaway path for each test.""" 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 tag_manager(): return TagManager() @pytest.fixture def file_manager(tag_manager, temp_global_config): return FileManager(tag_manager) def _in_memory_files(tmp_path, tag_manager, *names): """Create real files and wrap them as index-less (in-memory) File objects.""" files = [] for name in names: path = tmp_path / name path.write_text("content") files.append(File(path, tag_manager)) return files class TestFileManager: """Basic FileManager state.""" def test_file_manager_creation(self, file_manager, tag_manager): assert file_manager.filelist == [] assert file_manager.tagmanager == tag_manager assert file_manager.global_config is not None assert file_manager.index is None class TestFileManagerTagOperations: """assign/remove tag operations over the loaded file list.""" def test_assign_tag_to_file_objects_tag_object( self, file_manager, tmp_path, tag_manager ): files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt") file_manager.filelist = files tag = Tag("Video", "HD") file_manager.assign_tag_to_file_objects(files, tag) for f in files: assert tag in f.tags def test_assign_tag_string_with_category(self, file_manager, tmp_path, tag_manager): files = _in_memory_files(tmp_path, tag_manager, "f1.txt") file_manager.filelist = files file_manager.assign_tag_to_file_objects(files, "Video/4K") tag_paths = {tag.full_path for tag in files[0].tags} assert "Video/4K" in tag_paths def test_assign_tag_string_without_category( self, file_manager, tmp_path, tag_manager ): files = _in_memory_files(tmp_path, tag_manager, "f1.txt") file_manager.filelist = files file_manager.assign_tag_to_file_objects(files, "SimpleTag") tag_paths = {tag.full_path for tag in files[0].tags} assert "default/SimpleTag" in tag_paths def test_assign_tag_no_duplicate(self, file_manager, tmp_path, tag_manager): files = _in_memory_files(tmp_path, tag_manager, "f1.txt") file_manager.filelist = files tag = Tag("Video", "HD") file_manager.assign_tag_to_file_objects(files, tag) file_manager.assign_tag_to_file_objects(files, tag) count = sum(1 for t in files[0].tags if t == tag) assert count == 1 def test_remove_tag_from_file_objects(self, file_manager, tmp_path, tag_manager): files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt") file_manager.filelist = files tag = Tag("Video", "HD") file_manager.assign_tag_to_file_objects(files, tag) file_manager.remove_tag_from_file_objects(files, tag) for f in files: assert tag not in f.tags def test_remove_tag_string(self, file_manager, tmp_path, tag_manager): files = _in_memory_files(tmp_path, tag_manager, "f1.txt") file_manager.filelist = files file_manager.assign_tag_to_file_objects(files, "Video/HD") file_manager.remove_tag_from_file_objects(files, "Video/HD") tag_paths = {tag.full_path for tag in files[0].tags} assert "Video/HD" not in tag_paths def test_callback_on_tag_change(self, file_manager, tmp_path, tag_manager): files = _in_memory_files(tmp_path, tag_manager, "f1.txt") file_manager.filelist = files callback_calls = [] file_manager.on_files_changed = lambda filelist: callback_calls.append( len(filelist) ) file_manager.assign_tag_to_file_objects([files[0]], Tag("Test", "Tag")) assert len(callback_calls) == 1 class TestFileManagerFiltering: """filter_files_by_tags over the loaded file list.""" @pytest.fixture def loaded(self, file_manager, tmp_path, tag_manager): files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt", "f3.txt") file_manager.filelist = files return file_manager def test_filter_empty_tags_returns_all(self, loaded): assert len(loaded.filter_files_by_tags([])) == len(loaded.filelist) def test_filter_none_returns_all(self, loaded): assert len(loaded.filter_files_by_tags(None)) == len(loaded.filelist) def test_filter_by_single_tag(self, loaded): tag = Tag("Video", "HD") loaded.assign_tag_to_file_objects(loaded.filelist[:2], tag) filtered = loaded.filter_files_by_tags([tag]) assert len(filtered) == 2 for f in filtered: assert tag in f.tags def test_filter_by_multiple_tags_and_logic(self, loaded): tag1 = Tag("Video", "HD") tag2 = Tag("Audio", "Stereo") loaded.assign_tag_to_file_objects([loaded.filelist[0]], tag1) loaded.assign_tag_to_file_objects([loaded.filelist[0]], tag2) loaded.assign_tag_to_file_objects([loaded.filelist[1]], tag1) filtered = loaded.filter_files_by_tags([tag1, tag2]) assert len(filtered) == 1 assert filtered[0] == loaded.filelist[0] def test_filter_by_tag_strings(self, loaded): loaded.assign_tag_to_file_objects([loaded.filelist[0]], "Video/HD") assert len(loaded.filter_files_by_tags(["Video/HD"])) == 1 def test_filter_no_match(self, loaded): assert len(loaded.filter_files_by_tags([Tag("NonExistent", "Tag")])) == 0 class TestFileManagerEdgeCases: def test_empty_filelist_operations(self, file_manager): assert file_manager.filter_files_by_tags([Tag("Video", "HD")]) == [] file_manager.assign_tag_to_file_objects([], Tag("Video", "HD")) assert len(file_manager.filelist) == 0 def test_assign_tag_to_empty_list(self, file_manager): file_manager.assign_tag_to_file_objects([], Tag("Test", "Tag")) # no exception def test_remove_nonexistent_tag(self, file_manager, tmp_path, tag_manager): file_manager.filelist = _in_memory_files(tmp_path, tag_manager, "file.txt") file_manager.remove_tag_from_file_objects( file_manager.filelist, Tag("NonExistent", "Tag") ) # no exception class TestPoolManagement: """Testy pro pool a copy-as-is složky""" @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_set_pool_creates_top_level_folders(self, file_manager, tmp_path): pool = tmp_path / "pool" file_manager.set_pool_dir(pool) assert (pool / "Filmy").is_dir() assert (pool / "Seriály").is_dir() assert file_manager.pool_dir == pool def test_import_movie_copies_and_indexes(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mkv" source.write_bytes(b"x" * 10) movie = file_manager.import_movie(source, "Matrix") assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv" assert source.exists() # non-destructive copy assert movie.title == "Matrix" 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") (tmp_path / "a.mkv").write_bytes(b"x" * 100) (tmp_path / "b.mkv").write_bytes(b"y" * 50) a = file_manager.import_movie(tmp_path / "a.mkv", "A") a.add_tag("Žánr/Akční") a.add_tag("Žánr/Sci-Fi") a.meta = {"rating": 90} a.save_metadata() b = file_manager.import_movie(tmp_path / "b.mkv", "B") b.add_tag("Žánr/Akční") s = file_manager.statistics() assert s["count"] == 2 assert s["total_size"] == 150 assert s["with_nfo"] == 1 and s["without_nfo"] == 1 assert s["untagged"] == 0 assert s["avg_rating"] == 90 # Akční on both movies → count 2, sorted first assert dict(s["categories"]["Žánr"])["Akční"] == 2 assert s["categories"]["Žánr"][0] == ("Akční", 2) def test_check_data_consistency_clean(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") file_manager.import_movie(tmp_path / "a.mkv", "Matrix") result = file_manager.check_data_consistency() assert result["ok"] assert result["missing"] == [] assert result["untracked"] == [] assert result["index_count"] == 1 and result["disk_count"] == 1 def test_check_data_consistency_detects_missing_file(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") movie.file_path.unlink() # someone deleted the file directly result = file_manager.check_data_consistency() assert result["missing"] == ["Filmy/Matrix.mkv"] assert result["untracked"] == [] def test_check_data_consistency_detects_untracked_file( self, file_manager, tmp_path ): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") file_manager.import_movie(tmp_path / "a.mkv", "Matrix") # a file dropped into the pool without importing (file_manager.movies_dir / "Sneaked.mkv").write_bytes(b"x") result = file_manager.check_data_consistency() assert result["missing"] == [] assert result["untracked"] == ["Filmy/Sneaked.mkv"] def test_import_movie_records_added_timestamp(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mkv" source.write_bytes(b"x") movie = file_manager.import_movie(source, "Matrix") assert movie.added is not None # parseable ISO timestamp, persisted in the index from datetime import datetime datetime.fromisoformat(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") source = tmp_path / "raw.mkv" source.write_bytes(b"x" * 10) movie = file_manager.import_movie(source, "Matrix", move=True) assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv" assert movie.file_path.exists() assert not source.exists() # moved, not copied def test_filmoteka_category_roots_from_schema(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_tag_schema( [ { "category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": "", }, { "category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku", }, { "category": "Herec", "csfd_field": "actors", "transform": None, "filmoteka_root": None, }, ] ) roots = file_manager.filmoteka_category_roots() assert roots == {"Žánr": "", "Rok": "Dle roku"} # None-root excluded assert "Herec" not in roots def test_import_movie_suffix_keeps_both(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") (tmp_path / "b.mkv").write_bytes(b"b") first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") second = file_manager.import_movie( tmp_path / "b.mkv", "Matrix" ) # default suffix assert first.file_path.name == "Matrix.mkv" assert second.file_path.name == "Matrix_1.mkv" assert len(file_manager.filelist) == 2 def test_import_movie_replace_evicts_existing(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") (tmp_path / "b.mkv").write_bytes(b"bb") first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") first.add_tag("Žánr/Akční") old_path = first.file_path second = file_manager.import_movie( tmp_path / "b.mkv", "Matrix", on_conflict="replace" ) assert second.file_path.name == "Matrix.mkv" assert second.file_path == old_path # same name reused 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.record_for(second.file_path) assert record is not None assert record["tags"] == [] assert second.tags == [] def test_import_movie_replace_across_extensions(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") (tmp_path / "b.mp4").write_bytes(b"b") file_manager.import_movie(tmp_path / "a.mkv", "Matrix") file_manager.import_movie(tmp_path / "b.mp4", "Matrix", on_conflict="replace") names = [f.file_path.name for f in file_manager.filelist] assert names == ["Matrix.mp4"] assert not (tmp_path / "pool" / "Filmy" / "Matrix.mkv").exists() def test_import_movie_skip_returns_none(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") (tmp_path / "b.mkv").write_bytes(b"b") file_manager.import_movie(tmp_path / "a.mkv", "Matrix") result = file_manager.import_movie( tmp_path / "b.mkv", "Matrix", on_conflict="skip" ) assert result is None assert len(file_manager.filelist) == 1 def test_rename_movie_renames_file_and_reindexes(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mkv" source.write_bytes(b"x" * 10) movie = file_manager.import_movie(source, "Matrix") movie.add_tag("Žánr/Sci-Fi") old_path = movie.file_path file_manager.rename_movie(movie, "Matrix Reloaded") new_path = tmp_path / "pool" / "Filmy" / "Matrix Reloaded.mkv" assert movie.file_path == new_path assert new_path.exists() assert not old_path.exists() assert movie.title == "Matrix Reloaded" # metadata moved to the new key, old key gone, tags preserved assert file_manager.index.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") reloaded.load_pool_movies() assert [f.filename for f in reloaded.filelist] == ["Matrix Reloaded.mkv"] assert "Žánr/Sci-Fi" in {t.full_path for t in reloaded.filelist[0].tags} def test_rename_movie_preserves_extension(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mp4" source.write_bytes(b"x") movie = file_manager.import_movie(source, "Film") file_manager.rename_movie(movie, "Jiný název") assert movie.file_path.name == "Jiný název.mp4" def test_rename_movie_rejects_existing_name(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") (tmp_path / "b.mkv").write_bytes(b"b") first = file_manager.import_movie(tmp_path / "a.mkv", "Already") second = file_manager.import_movie(tmp_path / "b.mkv", "Other") with pytest.raises(FileExistsError): file_manager.rename_movie(second, "Already") # second movie is left untouched assert second.file_path.name == "Other.mkv" assert first.file_path.exists() def test_rename_movie_rejects_empty_name(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") movie = file_manager.import_movie(tmp_path / "a.mkv", "Name") with pytest.raises(ValueError): file_manager.rename_movie(movie, " ") def test_import_movie_with_year_uses_convention(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mkv" source.write_bytes(b"x" * 10) movie = file_manager.import_movie(source, "Matrix", year=1999) assert movie.file_path.name == "Matrix (1999).mkv" assert movie.title == "Matrix" # clean title, no year def test_import_movie_with_tmdb_id_names_for_jellyfin(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "raw.mkv").write_bytes(b"x") movie = file_manager.import_movie( tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603 ) assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv" assert movie.title == "Matrix" # clean title, no year/id def test_import_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 == [ {"name": "Filmy", "kind": "movie"}, {"name": "Seriály", "kind": "series"}, ] assert file_manager.movie_libraries == ["Filmy"] assert file_manager.copyasis_folders == ["Seriály"] def test_set_libraries_with_all_kinds(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_libraries( [ {"name": "Hlavní", "kind": "mixed"}, {"name": "Dokumenty", "kind": "mixed"}, {"name": "Anime", "kind": "series"}, {"name": "Chajda", "kind": "homevideo"}, ] ) assert file_manager.copyasis_folders == ["Anime"] assert file_manager.movie_libraries == [ "Filmy" ] # none of kind movie → fallback assert file_manager.libraries_of_kind("mixed") == ["Hlavní", "Dokumenty"] assert file_manager.libraries_of_kind("homevideo") == ["Chajda"] def test_set_pool_dir_creates_all_library_folders(self, file_manager, tmp_path): pool = tmp_path / "pool" file_manager.set_pool_dir(pool) file_manager.set_libraries( [ {"name": "Hlavní", "kind": "mixed"}, {"name": "Chajda", "kind": "homevideo"}, ] ) file_manager.set_pool_dir(pool) # re-run creates the newly defined folders assert (pool / "Hlavní").is_dir() assert (pool / "Chajda").is_dir() def test_mixed_library_loads_movies_not_episodes(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}]) hlavni = tmp_path / "pool" / "Hlavní" hlavni.mkdir(parents=True, exist_ok=True) (hlavni / "Matrix (1999) [tmdbid-603].mkv").write_bytes( b"x" ) # movie (top level) ep = hlavni / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01" ep.mkdir(parents=True) (ep / "S01E01 - pilot.mkv").write_bytes(b"y") # series episode (subfolder) file_manager.load_pool_movies() names = [f.filename for f in file_manager.filelist] assert names == [ "Matrix (1999) [tmdbid-603].mkv" ] # episode not loaded as movie assert file_manager.library_of(file_manager.filelist[0]) == "Hlavní" def test_libraries_accepting_movies(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_libraries( [ {"name": "Filmy", "kind": "movie"}, {"name": "Hlavní", "kind": "mixed"}, {"name": "Anime", "kind": "series"}, ] ) assert file_manager.libraries_accepting_movies == ["Filmy", "Hlavní"] def test_orphan_folder_cleanup(self, file_manager, tmp_path): pool = tmp_path / "pool" file_manager.set_pool_dir(pool) file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}]) (pool / "OldKids").mkdir() # empty orphan (pool / "OldStuff").mkdir() (pool / "OldStuff" / "film.mkv").write_bytes(b"x") # non-empty orphan orphans = dict(file_manager.orphan_pool_folders()) assert orphans.get("OldKids") is True assert orphans.get("OldStuff") is False assert "Filmy" not in orphans # a configured library is not an orphan assert file_manager.remove_empty_pool_folder("OldKids") is True assert not (pool / "OldKids").exists() assert file_manager.remove_empty_pool_folder("OldStuff") is False # non-empty assert file_manager.remove_empty_pool_folder("Filmy") is False # a library def test_move_movie_into_mixed_library(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_libraries( [ {"name": "Filmy", "kind": "movie"}, {"name": "Hlavní", "kind": "mixed"}, ] ) (tmp_path / "a.mkv").write_bytes(b"x") movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999) file_manager.move_to_library(movie, "Hlavní") assert file_manager.library_of(movie) == "Hlavní" def test_import_into_named_library(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_movie_libraries(["Filmy", "Kids"]) (tmp_path / "a.mkv").write_bytes(b"x") movie = file_manager.import_movie( tmp_path / "a.mkv", "Auta", year=2006, tmdb_id=920, library="Kids" ) assert ( movie.file_path == tmp_path / "pool" / "Kids" / "Auta (2006) [tmdbid-920].mkv" ) assert file_manager.library_of(movie) == "Kids" def test_move_to_library_reindexes(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_movie_libraries(["Filmy", "Kids"]) (tmp_path / "a.mkv").write_bytes(b"x") movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006) old_path = movie.file_path file_manager.move_to_library(movie, "Kids") assert file_manager.library_of(movie) == "Kids" assert movie.file_path.exists() # 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") (tmp_path / "a.mkv").write_bytes(b"x") movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006) with pytest.raises(ValueError): file_manager.move_to_library(movie, "Neexistuje") def test_load_scans_all_movie_libraries(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_movie_libraries(["Filmy", "Kids"]) (tmp_path / "a.mkv").write_bytes(b"x") (tmp_path / "b.mkv").write_bytes(b"y") file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999) file_manager.import_movie(tmp_path / "b.mkv", "Auta", year=2006, library="Kids") reloaded = FileManager(TagManager()) reloaded.set_pool_dir(tmp_path / "pool") reloaded.load_pool_movies() libs = sorted(reloaded.library_of(f) for f in reloaded.filelist) assert libs == ["Filmy", "Kids"] def test_load_skips_jellyfin_sidecars(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"x") movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999) # Jellyfin writes these next to the movie — must not count as movies movie.file_path.with_suffix(".nfo").write_text("") (movie.file_path.parent / "poster.jpg").write_bytes(b"img") reloaded = FileManager(TagManager()) reloaded.set_pool_dir(tmp_path / "pool") reloaded.load_pool_movies() assert len(reloaded.filelist) == 1 assert reloaded.filelist[0].file_path.suffix == ".mkv" def test_apply_movie_edits_all_at_once(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_movie_libraries(["Filmy", "Kids"]) (tmp_path / "a.mkv").write_bytes(b"x") movie = file_manager.import_movie(tmp_path / "a.mkv", "Puvodni", year=2000) file_manager.apply_movie_edits( movie, title="Auta", year=2006, tmdb_id=920, library="Kids", date="2026-01-02", attributes={"collection_sort": "01"}, tags=["Žánr/Animovaný", "Sbírka/Oblíbené"], ) assert ( movie.file_path == tmp_path / "pool" / "Kids" / "Auta (2006) [tmdbid-920].mkv" ) assert movie.title == "Auta" assert file_manager.library_of(movie) == "Kids" assert movie.date == "2026-01-02" assert movie.attributes == {"collection_sort": "01"} assert {t.full_path for t in movie.tags} == { "Žánr/Animovaný", "Sbírka/Oblíbené", } def test_import_and_edit_store_csfd_url(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"x") movie = file_manager.import_movie( tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603, csfd_url="https://www.csfd.cz/film/9499/", ) assert movie.csfd_url == "https://www.csfd.cz/film/9499/" # persists across reload reloaded = FileManager(TagManager()) reloaded.set_pool_dir(tmp_path / "pool") reloaded.load_pool_movies() assert reloaded.filelist[0].csfd_url == "https://www.csfd.cz/film/9499/" # editable via apply_movie_edits file_manager.apply_movie_edits( movie, title="Matrix", year=1999, 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") (tmp_path / "b.mkv").write_bytes(b"y") file_manager.import_movie(tmp_path / "a.mkv", "Solaris", year=1972) other = file_manager.import_movie(tmp_path / "b.mkv", "Jiny", year=2002) with pytest.raises(FileExistsError): file_manager.apply_movie_edits(other, title="Solaris", year=1972) def test_rename_to_canonical_preserves_tmdb_id(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "raw.mkv").write_bytes(b"x") movie = file_manager.import_movie( tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603 ) movie.title = "Matrix Reloaded" # metadata title changed renamed = file_manager.rename_to_canonical(movie) assert renamed is not None assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv" def test_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") with pytest.raises(ValueError): file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year="99") def test_import_same_title_different_year_coexist(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") (tmp_path / "b.mkv").write_bytes(b"b") first = file_manager.import_movie(tmp_path / "a.mkv", "Solaris", year=1972) second = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=2002) assert first.file_path.name == "Solaris (1972).mkv" assert second.file_path.name == "Solaris (2002).mkv" # no collision/suffix def test_rename_to_canonical_from_csfd_year(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "raw.mkv").write_bytes(b"x") movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix") # plain assert movie.file_path.name == "Matrix.mkv" movie.meta = {"year": 1999} renamed = file_manager.rename_to_canonical(movie) assert renamed is not None assert movie.file_path.name == "Matrix (1999).mkv" assert movie.title == "Matrix" def test_rename_to_canonical_skips_without_year(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", "Unknown") assert file_manager.rename_to_canonical(movie) is None assert movie.file_path.name == "Unknown.mkv" # untouched def test_rename_all_to_canonical_reports(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") for n in ("a.mkv", "b.mkv", "c.mkv"): (tmp_path / n).write_bytes(b"x") with_year = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") with_year.meta = {"year": 1999} already = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=1972) _no_year = file_manager.import_movie(tmp_path / "c.mkv", "Mystery") result = file_manager.rename_all_to_canonical(file_manager.filelist) assert result["renamed"] == [("Matrix.mkv", "Matrix (1999).mkv")] assert result["unchanged"] == 1 # Solaris (1972) already canonical assert result["skipped_no_year"] == ["Mystery.mkv"] assert already.file_path.name == "Solaris (1972).mkv" def test_load_pool_movies_reads_from_index(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mkv" source.write_bytes(b"x" * 10) file_manager.import_movie(source, "Matrix") reloaded = FileManager(TagManager()) reloaded.set_pool_dir(tmp_path / "pool") reloaded.load_pool_movies() assert len(reloaded.filelist) == 1 assert reloaded.filelist[0].title == "Matrix" def test_copyasis_folders_default_and_set(self, file_manager, tmp_path): assert file_manager.copyasis_folders == ["Seriály"] file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_copyasis_folders(["Seriály", " Dokumenty ", ""]) assert file_manager.copyasis_folders == ["Seriály", "Dokumenty"] def test_library_settings_stored_in_index_not_global(self, file_manager, tmp_path): """tag_schema / copyasis_folders persist in the pool index, not .!gtag.""" file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_copyasis_folders(["Seriály", "Dokumenty"]) assert "copyasis_folders" not in file_manager.global_config # series libraries now persist in the unified "libraries" setting (in the index) stored = file_manager.index.get_setting("libraries") assert stored is not None assert [lib["name"] for lib in stored if lib["kind"] == "series"] == [ "Seriály", "Dokumenty", ] # a fresh manager over the same pool reads the stored settings back reloaded = FileManager(TagManager()) reloaded.set_pool_dir(tmp_path / "pool") assert reloaded.copyasis_folders == ["Seriály", "Dokumenty"] def test_migrates_legacy_global_settings_into_index(self, file_manager, tmp_path): """Old settings left in the global config move into the index on open.""" file_manager.set_pool_dir(tmp_path / "pool") # simulate a pre-migration global config carrying library settings file_manager.global_config["copyasis_folders"] = ["Seriály", "Filmy jinak"] file_manager.index = None # force a fresh open + migration 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].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].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_with_empty_label_becomes_vychozi(self, file_manager, tmp_path): """A blank popisek is a plain "2D" version with nothing else to call it — it becomes DEFAULT_VERSION_LABEL instead of being rejected.""" (tmp_path / "a.mkv").write_bytes(b"3d") movie = file_manager.import_movie( tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995, format_3d="HSBS" ) (tmp_path / "b.mkv").write_bytes(b"2d") v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", " ") base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]" assert v2.file_path == base / "Avatar (2009) [tmdbid-19995].mkv" # the pre-existing 3D file keeps its own 3D formát automatically — # promotion never silently drops it just because no one asked for it assert movie.file_path == base / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv" def test_add_version_rejects_a_blank_label_that_collides_with_vychozi( self, file_manager, tmp_path ): (tmp_path / "a.mkv").write_bytes(b"x") # no 3D formát → promotes as "Výchozí" movie = file_manager.import_movie( tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995 ) with pytest.raises(FileExistsError): file_manager.add_movie_version(movie, tmp_path / "a.mkv", " ") def test_add_version_promotes_a_movie_in_a_mixed_library_correctly( self, file_manager, tmp_path ): """A movie's version folder and a series folder are both ``Title (rok) [tmdbid]/`` — indistinguishable by name alone. Told apart by shape instead: a series always nests episodes in a ``Season NN``/``Specials`` folder, a movie's version folder never does. Promoting here must keep the movie a movie, not turn it into a mis-parsed "series" on reload. """ file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}]) (tmp_path / "a.mkv").write_bytes(b"2d") movie = file_manager.import_movie( tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995, library="Hlavní" ) (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" / "Hlavní" / "Avatar (2009) [tmdbid-19995]" assert movie.file_path == base / "Avatar (2009) [tmdbid-19995].mkv" assert file_manager.is_movie_group_member(movie) assert file_manager.is_movie_group_member(v2) # survives a fresh reload from disk too — still a movie, not a series file_manager.load_pool_movies() assert {f.file_path for f in file_manager.filelist} == { movie.file_path, v2.file_path, } assert file_manager.load_series() == [] def test_a_real_series_in_a_mixed_library_stays_a_series( self, file_manager, tmp_path ): """A movie folder and a series folder can sit side by side in the same mixed library without either one leaking into the other's listing. """ file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}]) (tmp_path / "a.mkv").write_bytes(b"2d") movie = file_manager.import_movie( tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995, library="Hlavní" ) (tmp_path / "b.mkv").write_bytes(b"3d") file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS") ep = ( tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01" / "S01E01 - Breaking Bad.mkv" ) ep.parent.mkdir(parents=True) ep.write_bytes(b"x") file_manager.load_pool_movies() assert {f.file_path.parent.name for f in file_manager.filelist} == { "Avatar (2009) [tmdbid-19995]" } series_list = file_manager.load_series() assert [s.title for s in series_list] == ["Breaking Bad"] assert len(series_list[0].episodes) == 1 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_rename_movie_version_changes_the_label_and_filename( 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") old_path = v2.file_path renamed = file_manager.rename_movie_version(v2, "4K") base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]" assert renamed.file_path == base / "Avatar (2009) [tmdbid-19995] - 4K.mkv" assert not old_path.exists() assert renamed.file_path.exists() def test_rename_movie_version_carries_subtitle_along(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") srt = tmp_path / "s.srt" srt.write_bytes(b"x") file_manager.attach_subtitle(v2, srt, language="cs") file_manager.rename_movie_version(v2, "4K") base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]" assert (base / "Avatar (2009) [tmdbid-19995] - 4K.cs.srt").exists() def test_rename_movie_version_keeps_the_shared_metadata( 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.save_metadata() (tmp_path / "b.mkv").write_bytes(b"3d") v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS") renamed = file_manager.rename_movie_version(movie, "Výchozí kvalita") base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]" assert ( renamed.file_path == base / "Avatar (2009) [tmdbid-19995] - Výchozí kvalita.mkv" ) assert {t.full_path for t in renamed.tags} == {"Žánr/Sci-Fi"} assert {t.full_path for t in v2.tags} == {"Žánr/Sci-Fi"} # shared, untouched def test_rename_movie_version_rejects_a_flat_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.rename_movie_version(movie, "4K") def test_rename_movie_version_with_empty_label_becomes_vychozi( 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.rename_movie_version(movie, "Jiná") # free up "Výchozí" first renamed = file_manager.rename_movie_version(v2, " ") base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]" assert renamed.file_path == base / "Avatar (2009) [tmdbid-19995].mkv" def test_rename_movie_version_rejects_a_blank_label_that_collides( 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") with pytest.raises(FileExistsError): file_manager.rename_movie_version(v2, " ") # movie already owns "Výchozí" def test_rename_movie_version_rejects_a_label_already_taken( 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") with pytest.raises(FileExistsError): file_manager.rename_movie_version(v2, "Výchozí") # movie's own label def test_rename_movie_version_to_the_same_label_is_a_noop( 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") old_path = v2.file_path result = file_manager.rename_movie_version(v2, "3D") assert result.file_path == old_path assert old_path.exists() 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_demoting_to_a_3d_survivor_restores_its_3d_tag( self, file_manager, tmp_path ): """The sole survivor's own popisek was a 3D formát — the flat name it demotes back to must say so too, not silently become a plain file. """ (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") file_manager.remove_movie_version(movie) # keep only the 3D version assert not file_manager.is_movie_group_member(v2) assert ( v2.file_path == tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv" ) assert v2.file_path.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 class TestDuplicates: """Duplicate detection (Nástroje → Kontrola duplicit) and merging copies of one movie into a single multi-version movie.""" @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 _import(self, fm, tmp_path, name, content, title="Avatar", **kwargs): src = tmp_path / name src.write_bytes(content) kwargs.setdefault("year", 2009) return fm.import_movie(src, title, **kwargs) def test_find_duplicates_empty_for_distinct_movies(self, file_manager, tmp_path): self._import(file_manager, tmp_path, "a.mkv", b"a", tmdb_id=19995) self._import( file_manager, tmp_path, "b.mkv", b"bb", title="Matrix", year=1999, tmdb_id=603, ) assert file_manager.find_duplicates() == [] def test_find_duplicates_flags_flat_copies_with_same_tmdb_id( self, file_manager, tmp_path ): plain = self._import(file_manager, tmp_path, "a.mkv", b"2d", tmdb_id=19995) three_d = self._import( file_manager, tmp_path, "b.mkv", b"3d-x", tmdb_id=19995, format_3d="HSBS" ) groups = file_manager.find_duplicates() assert len(groups) == 1 assert {f.file_path for f in groups[0]["movies"]} == { plain.file_path, three_d.file_path, } assert groups[0]["reasons"] == ["stejné TMDb ID 19995"] def test_find_duplicates_flags_same_title_and_year_without_id( self, file_manager, tmp_path ): self._import(file_manager, tmp_path, "a.mkv", b"a", tmdb_id=19995) self._import( file_manager, tmp_path, "b.mkv", b"bb", library="Filmy", on_conflict="suffix", ) groups = file_manager.find_duplicates() assert len(groups) == 1 assert "stejný název a rok" in groups[0]["reasons"] def test_find_duplicates_flags_identical_size(self, file_manager, tmp_path): self._import(file_manager, tmp_path, "a.mkv", b"same", tmdb_id=19995) self._import( file_manager, tmp_path, "b.mkv", b"same", title="Matrix", year=1999, tmdb_id=603, ) groups = file_manager.find_duplicates() assert len(groups) == 1 assert groups[0]["reasons"] == ["shodná velikost souboru"] def test_versions_of_one_movie_are_not_duplicates(self, file_manager, tmp_path): movie = self._import(file_manager, tmp_path, "a.mkv", b"same", tmdb_id=19995) (tmp_path / "b.mkv").write_bytes(b"same") file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS") assert file_manager.find_duplicates() == [] def test_merge_as_version_groups_the_two_copies(self, file_manager, tmp_path): plain = self._import(file_manager, tmp_path, "a.mkv", b"2d", tmdb_id=19995) plain.csfd_url = None plain.save_metadata() three_d = self._import( file_manager, tmp_path, "b.mkv", b"3d-x", tmdb_id=19995, format_3d="HSBS" ) old_3d_path = three_d.file_path new_version = file_manager.merge_as_version(plain, three_d, "3D HSBS") folder = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]" assert not old_3d_path.exists() assert ( new_version.file_path == folder / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv" ) assert plain.file_path == folder / "Avatar (2009) [tmdbid-19995].mkv" assert three_d not in file_manager.filelist assert file_manager.find_duplicates() == [] assert len(file_manager.visible_movies()) == 1 record = file_manager.index.get_movie("tmdb-19995") assert sorted(m["label"] for m in record["media"]) == [ "", "3D HSBS", ] # "" = default def test_merge_as_version_preserves_keeps_own_3d_tag_automatically( self, file_manager, tmp_path ): """``keep`` is itself a flat, 3D-only movie — promoting it during the merge must carry its own 3D formát over on its own, without the caller having to compute/pass ``keep_label`` explicitly. """ keep = self._import( file_manager, tmp_path, "a.mkv", b"3d-a", tmdb_id=19995, format_3d="FTAB" ) other = self._import( file_manager, tmp_path, "b.mkv", b"2d-b", title="Avatar", year=2009, on_conflict="suffix", ) new_version = file_manager.merge_as_version(keep, other, "") folder = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]" assert keep.file_path == folder / "Avatar (2009) [tmdbid-19995] - 3D FTAB.mkv" assert new_version.file_path == folder / "Avatar (2009) [tmdbid-19995].mkv" def test_merge_as_version_carries_tags_csfd_and_subtitles( self, file_manager, tmp_path ): keep = self._import(file_manager, tmp_path, "a.mkv", b"a", tmdb_id=19995) other = self._import( file_manager, tmp_path, "b.mkv", b"bb", on_conflict="suffix" ) other.add_tag("Žánr/Sci-Fi") other.csfd_url = "https://www.csfd.cz/film/avatar/" other.save_metadata() srt = tmp_path / "s.srt" srt.write_bytes(b"x") file_manager.attach_subtitle(other, srt, language="cs") new_version = file_manager.merge_as_version(keep, other, "4K") assert keep.csfd_url == "https://www.csfd.cz/film/avatar/" assert {t.full_path for t in keep.tags} == {"Žánr/Sci-Fi"} assert ( new_version.file_path.parent / f"{new_version.file_path.stem}.cs.srt" ).exists() # the id-less duplicate's own record is gone — no phantom movie left assert [k for k in file_manager.index.all_movies()] == ["tmdb-19995"] def test_merge_as_version_rejects_same_file(self, file_manager, tmp_path): movie = self._import(file_manager, tmp_path, "a.mkv", b"a", tmdb_id=19995) with pytest.raises(ValueError): file_manager.merge_as_version(movie, movie, "3D") def test_remove_movie_deletes_every_version(self, file_manager, tmp_path): movie = self._import(file_manager, tmp_path, "a.mkv", b"2d", tmdb_id=19995) (tmp_path / "b.mkv").write_bytes(b"3d") file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS") folder = movie.file_path.parent file_manager.remove_movie(movie) assert not folder.exists() assert file_manager.filelist == [] assert file_manager.index.all_movies() == {} class TestDefaultVersionNaming: """The plain/default version of a grouped movie carries no label at all — ``Folder/Folder.ext`` — and importing a film that's already in the pool (even if it's grouped) must add a version, never a root-level duplicate.""" @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 _grouped_hobbit(self, fm, tmp_path): (tmp_path / "h1.mkv").write_bytes(b"hobit-2d") movie = fm.import_movie(tmp_path / "h1.mkv", "Hobit", year=2013, tmdb_id=49051) (tmp_path / "h2.mkv").write_bytes(b"hobit-3d") v2 = fm.add_movie_version(movie, tmp_path / "h2.mkv", "3D HSBS") return movie, v2 def test_promoted_default_version_has_bare_name(self, file_manager, tmp_path): movie, _v2 = self._grouped_hobbit(file_manager, tmp_path) folder = tmp_path / "pool" / "Filmy" / "Hobit (2013) [tmdbid-49051]" assert movie.file_path == folder / "Hobit (2013) [tmdbid-49051].mkv" def test_new_default_version_has_bare_name(self, file_manager, tmp_path): movie, _v2 = self._grouped_hobbit(file_manager, tmp_path) (tmp_path / "h3.mkv").write_bytes(b"hobit-4k") v3 = file_manager.add_movie_version(movie, tmp_path / "h3.mkv", "4K") (tmp_path / "h4.mkv").write_bytes(b"hobit-plain") with pytest.raises(FileExistsError): file_manager.add_movie_version( movie, tmp_path / "h4.mkv", "" ) # bare exists assert v3.file_path.name == "Hobit (2013) [tmdbid-49051] - 4K.mkv" def test_pooled_with_stem_finds_grouped_members_by_identity( self, file_manager, tmp_path ): self._grouped_hobbit(file_manager, tmp_path) found = file_manager.pooled_with_stem("Hobit (2013) [tmdbid-49051]") assert len(found) == 2 def test_import_of_a_film_already_in_the_pool_adds_a_version_not_a_duplicate( self, file_manager, tmp_path ): movie, _v2 = self._grouped_hobbit(file_manager, tmp_path) (tmp_path / "h5.mkv").write_bytes(b"hobit-4k") result = file_manager.import_movie( tmp_path / "h5.mkv", "Hobit", year=2013, tmdb_id=49051, on_conflict="version", version_label="4K", ) folder = tmp_path / "pool" / "Filmy" / "Hobit (2013) [tmdbid-49051]" assert result.file_path == folder / "Hobit (2013) [tmdbid-49051] - 4K.mkv" assert len(file_manager.list_movie_versions(movie)) == 3 root_files = [ p.name for p in (tmp_path / "pool" / "Filmy").iterdir() if p.is_file() ] assert root_files == [] # nothing loose at the library root def test_plain_reimport_of_an_existing_plain_version_is_refused_not_duplicated( self, file_manager, tmp_path ): movie, _v2 = self._grouped_hobbit(file_manager, tmp_path) (tmp_path / "h8.mkv").write_bytes(b"hobit-plain-again") with pytest.raises(FileExistsError): file_manager.import_movie( tmp_path / "h8.mkv", "Hobit", year=2013, tmdb_id=49051, on_conflict="version", ) assert len(file_manager.list_movie_versions(movie)) == 2 root_files = [ p.name for p in (tmp_path / "pool" / "Filmy").iterdir() if p.is_file() ] assert root_files == [] def test_import_of_a_3d_file_adds_it_as_a_3d_version(self, file_manager, tmp_path): _movie, _v2 = self._grouped_hobbit(file_manager, tmp_path) (tmp_path / "h6.mkv").write_bytes(b"hobit-ftab") result = file_manager.import_movie( tmp_path / "h6.mkv", "Hobit", year=2013, tmdb_id=49051, on_conflict="version", format_3d="FTAB", ) assert result.file_path.name == "Hobit (2013) [tmdbid-49051] - 3D FTAB.mkv" def test_import_replace_removes_the_whole_group_not_just_one_file( self, file_manager, tmp_path ): _movie, _v2 = self._grouped_hobbit(file_manager, tmp_path) (tmp_path / "h7.mkv").write_bytes(b"hobit-new") file_manager.import_movie( tmp_path / "h7.mkv", "Hobit", year=2013, tmdb_id=49051, on_conflict="replace", ) folder = tmp_path / "pool" / "Filmy" / "Hobit (2013) [tmdbid-49051]" assert not folder.exists() # the old group's folder is gone entirely assert len(file_manager.load_pool_movies() or file_manager.filelist) == 1 def test_normalize_renames_existing_vychozi_files_to_bare_names( self, file_manager, tmp_path ): """Pool files written before this change still carry " - Výchozí".""" folder = tmp_path / "pool" / "Filmy" / "Hobit (2013) [tmdbid-49051]" folder.mkdir(parents=True) (folder / "Hobit (2013) [tmdbid-49051] - Výchozí.mkv").write_bytes(b"x") (folder / "Hobit (2013) [tmdbid-49051] - 3D HSBS.mkv").write_bytes(b"y") file_manager.load_pool_movies() renamed = file_manager.normalize_default_version_names() assert renamed == [ ( "Hobit (2013) [tmdbid-49051] - Výchozí.mkv", "Hobit (2013) [tmdbid-49051].mkv", ) ] assert (folder / "Hobit (2013) [tmdbid-49051].mkv").exists() assert (folder / "Hobit (2013) [tmdbid-49051] - 3D HSBS.mkv").exists() class TestMovieParts: """One film split into several files (Jellyfin multi-part / stacking): ``Folder/Folder-part1.ext``, ``Folder-part2.ext`` — played back to back as one movie, unlike versions, which are alternatives to choose from.""" @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 FOLDER = "Film (2010) [tmdbid-12345]" def _flat(self, fm, tmp_path): (tmp_path / "a.mkv").write_bytes(b"part-one") return fm.import_movie(tmp_path / "a.mkv", "Film", year=2010, tmdb_id=12345) def test_add_part_promotes_flat_movie_to_part1_and_part2( self, file_manager, tmp_path ): movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"part-two") p2 = file_manager.add_movie_part(movie, tmp_path / "b.mkv") folder = tmp_path / "pool" / "Filmy" / self.FOLDER assert movie.file_path == folder / f"{self.FOLDER}-part1.mkv" assert p2.file_path == folder / f"{self.FOLDER}-part2.mkv" assert file_manager.visible_movies() == [movie] # still one movie def test_add_part_appends_the_next_number(self, file_manager, tmp_path): movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"part-two") file_manager.add_movie_part(movie, tmp_path / "b.mkv") (tmp_path / "c.mkv").write_bytes(b"part-three") p3 = file_manager.add_movie_part(movie, tmp_path / "c.mkv") assert p3.file_path.name == f"{self.FOLDER}-part3.mkv" def test_part_labels_survive_a_reload(self, file_manager, tmp_path): movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"part-two") file_manager.add_movie_part(movie, tmp_path / "b.mkv") file_manager.load_pool_movies() labels = sorted(f._identity_from_path()[4] for f in file_manager.filelist) assert labels == ["part1", "part2"] assert len(file_manager.visible_movies()) == 1 def test_part_subtitles_follow_their_part(self, file_manager, tmp_path): movie = self._flat(file_manager, tmp_path) 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"part-two") file_manager.add_movie_part(movie, tmp_path / "b.mkv") folder = tmp_path / "pool" / "Filmy" / self.FOLDER assert (folder / f"{self.FOLDER}-part1.cs.srt").exists() def test_removing_down_to_one_part_demotes_to_flat(self, file_manager, tmp_path): movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"part-two") p2 = file_manager.add_movie_part(movie, tmp_path / "b.mkv") file_manager.remove_movie_version(p2) assert movie.file_path == tmp_path / "pool" / "Filmy" / f"{self.FOLDER}.mkv" def test_add_part_refuses_a_movie_that_already_has_versions( self, file_manager, tmp_path ): movie = self._flat(file_manager, tmp_path) (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"part") with pytest.raises(ValueError): file_manager.add_movie_part(movie, tmp_path / "c.mkv") def test_part_label_through_add_movie_version_names_both_files( self, file_manager, tmp_path ): """Typing "part2" as a popisek (e.g. in the import collision dialog) is the same as "Přidat díl": the existing file becomes part1.""" movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"part-two") p2 = file_manager.import_movie( tmp_path / "b.mkv", "Film", year=2010, tmdb_id=12345, on_conflict="version", version_label="CD2", ) assert movie.file_path.name == f"{self.FOLDER}-part1.mkv" assert p2.file_path.name == f"{self.FOLDER}-part2.mkv" def test_rename_part_label_keeps_the_part_form(self, file_manager, tmp_path): movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"part-two") p2 = file_manager.add_movie_part(movie, tmp_path / "b.mkv") file_manager.rename_movie_version(p2, "pt3") assert p2.file_path.name == f"{self.FOLDER}-part3.mkv" class TestPartsAndVersionsDontMix: """A folder holds either parts (played back to back) or versions (alternatives) — never both, or Jellyfin and the UI can't tell which file is which (the "Císařův pekař" case: part1, part2 and a version "díl 1").""" @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 _flat(self, fm, tmp_path): (tmp_path / "a.mkv").write_bytes(b"one") return fm.import_movie(tmp_path / "a.mkv", "Film", year=2010, tmdb_id=12345) def test_part_label_on_a_versioned_movie_is_refused(self, file_manager, tmp_path): movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"3d") file_manager.add_movie_version(movie, tmp_path / "b.mkv", "díl 1") (tmp_path / "c.mkv").write_bytes(b"part") with pytest.raises(ValueError): file_manager.add_movie_version(movie, tmp_path / "c.mkv", "part2") assert not ( tmp_path / "pool" / "Filmy" / "Film (2010) [tmdbid-12345]" / "Film (2010) [tmdbid-12345]-part2.mkv" ).exists() def test_version_label_on_a_movie_with_parts_is_refused( self, file_manager, tmp_path ): movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"two") file_manager.add_movie_part(movie, tmp_path / "b.mkv") (tmp_path / "c.mkv").write_bytes(b"4k") with pytest.raises(ValueError): file_manager.add_movie_version(movie, tmp_path / "c.mkv", "4K") def test_renaming_a_part_to_a_version_label_is_refused( self, file_manager, tmp_path ): movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"two") p2 = file_manager.add_movie_part(movie, tmp_path / "b.mkv") with pytest.raises(ValueError): file_manager.rename_movie_version(p2, "díl 2") assert p2.file_path.name.endswith("-part2.mkv") def test_renaming_within_parts_is_allowed(self, file_manager, tmp_path): movie = self._flat(file_manager, tmp_path) (tmp_path / "b.mkv").write_bytes(b"two") p2 = file_manager.add_movie_part(movie, tmp_path / "b.mkv") file_manager.rename_movie_version(p2, "CD3") assert p2.file_path.name.endswith("-part3.mkv") class TestMovieGroupModes: """A movie is flat (one file) or a folder; a folder holds either versions (alternatives, each with a popisek) or parts (ordered, played back to back). Switching the mode and reordering parts relabel the whole 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 F = "Film (2010) [tmdbid-12345]" def _folder(self, tmp_path): return tmp_path / "pool" / "Filmy" / self.F def _parts(self, fm, tmp_path, n=3): (tmp_path / "a.mkv").write_bytes(b"p1") movie = fm.import_movie(tmp_path / "a.mkv", "Film", year=2010, tmdb_id=12345) for i in range(2, n + 1): (tmp_path / f"p{i}.mkv").write_bytes(f"p{i}".encode()) fm.add_movie_part(movie, tmp_path / f"p{i}.mkv") return movie def _content(self, tmp_path, name): return (self._folder(tmp_path) / name).read_bytes() def test_mode_of_flat_versions_and_parts(self, file_manager, tmp_path): (tmp_path / "a.mkv").write_bytes(b"x") movie = file_manager.import_movie( tmp_path / "a.mkv", "Film", year=2010, tmdb_id=12345 ) assert file_manager.movie_group_mode(movie) == "flat" (tmp_path / "b.mkv").write_bytes(b"3d") file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS") assert file_manager.movie_group_mode(movie) == "versions" def test_mode_of_parts(self, file_manager, tmp_path): movie = self._parts(file_manager, tmp_path, n=2) assert file_manager.movie_group_mode(movie) == "parts" def test_ordered_media_sorts_parts_numerically(self, file_manager, tmp_path): movie = self._parts(file_manager, tmp_path, n=11) names = [f.file_path.name for f in file_manager.ordered_media(movie)] assert names[1] == f"{self.F}-part2.mkv" # not part10 assert names[-1] == f"{self.F}-part11.mkv" def test_move_part_down_swaps_the_numbers(self, file_manager, tmp_path): movie = self._parts(file_manager, tmp_path, n=3) first = file_manager.ordered_media(movie)[0] # content b"p1" file_manager.move_movie_part(first, +1) assert self._content(tmp_path, f"{self.F}-part1.mkv") == b"p2" assert self._content(tmp_path, f"{self.F}-part2.mkv") == b"p1" assert self._content(tmp_path, f"{self.F}-part3.mkv") == b"p3" assert first.file_path.name == f"{self.F}-part2.mkv" def test_move_part_carries_its_subtitles(self, file_manager, tmp_path): movie = self._parts(file_manager, tmp_path, n=2) first = file_manager.ordered_media(movie)[0] srt = tmp_path / "s.srt" srt.write_bytes(b"sub-of-p1") file_manager.attach_subtitle(first, srt, language="cs") file_manager.move_movie_part(first, +1) assert self._content(tmp_path, f"{self.F}-part2.cs.srt") == b"sub-of-p1" assert not (self._folder(tmp_path) / f"{self.F}-part1.cs.srt").exists() def test_move_part_beyond_the_ends_is_a_noop(self, file_manager, tmp_path): movie = self._parts(file_manager, tmp_path, n=2) first = file_manager.ordered_media(movie)[0] file_manager.move_movie_part(first, -1) assert self._content(tmp_path, f"{self.F}-part1.mkv") == b"p1" def test_switch_versions_to_parts_numbers_them_in_order( self, file_manager, tmp_path ): (tmp_path / "a.mkv").write_bytes(b"plain") movie = file_manager.import_movie( tmp_path / "a.mkv", "Film", year=2010, tmdb_id=12345 ) (tmp_path / "b.mkv").write_bytes(b"cut") file_manager.add_movie_version(movie, tmp_path / "b.mkv", "zkrácená verze") file_manager.set_movie_group_mode(movie, "parts") assert file_manager.movie_group_mode(movie) == "parts" assert ( self._content(tmp_path, f"{self.F}-part1.mkv") == b"plain" ) # default first assert self._content(tmp_path, f"{self.F}-part2.mkv") == b"cut" def test_switch_parts_to_versions_uses_given_labels(self, file_manager, tmp_path): movie = self._parts(file_manager, tmp_path, n=3) file_manager.set_movie_group_mode( movie, "versions", labels=["kinoverze", "3D HSBS"] ) assert file_manager.movie_group_mode(movie) == "versions" assert self._content(tmp_path, f"{self.F}.mkv") == b"p1" # default assert self._content(tmp_path, f"{self.F} - kinoverze.mkv") == b"p2" assert self._content(tmp_path, f"{self.F} - 3D HSBS.mkv") == b"p3" def test_switch_parts_to_versions_needs_a_label_per_extra_file( self, file_manager, tmp_path ): movie = self._parts(file_manager, tmp_path, n=3) with pytest.raises(ValueError): file_manager.set_movie_group_mode(movie, "versions", labels=["jen jeden"]) assert file_manager.movie_group_mode(movie) == "parts" # untouched def test_switch_parts_to_versions_refuses_duplicate_labels( self, file_manager, tmp_path ): movie = self._parts(file_manager, tmp_path, n=3) with pytest.raises(ValueError): file_manager.set_movie_group_mode(movie, "versions", labels=["x", "X"]) assert file_manager.movie_group_mode(movie) == "parts" def test_switch_to_parts_refuses_part_labels_as_versions( self, file_manager, tmp_path ): movie = self._parts(file_manager, tmp_path, n=2) with pytest.raises(ValueError): file_manager.set_movie_group_mode(movie, "versions", labels=["part5"]) def test_metadata_survives_a_mode_switch(self, file_manager, tmp_path): movie = self._parts(file_manager, tmp_path, n=2) movie.add_tag("Žánr/Komedie") movie.save_metadata() file_manager.set_movie_group_mode(movie, "versions", labels=["kinoverze"]) file_manager.load_pool_movies() assert all( {t.full_path for t in f.tags} == {"Žánr/Komedie"} for f in file_manager.filelist ) assert len(file_manager.index.get_movie("tmdb-12345")["media"]) == 2 class TestStaleSiblingDoesNotClobberMetadata: """A version object loaded before the shared metadata changed must not write its stale copy back when it's renamed or demoted.""" @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 _pair(self, fm, tmp_path): (tmp_path / "a.mkv").write_bytes(b"a") movie = fm.import_movie(tmp_path / "a.mkv", "Film", year=2010, tmdb_id=12345) (tmp_path / "b.mkv").write_bytes(b"b") v2 = fm.add_movie_version(movie, tmp_path / "b.mkv", "kinoverze") movie.add_tag("Žánr/Komedie") # v2 still holds the old, tag-less copy movie.save_metadata() return movie, v2 def _tags(self, fm): return set(fm.index.get_movie("tmdb-12345")["tags"]) def test_renaming_a_stale_version_keeps_the_tags(self, file_manager, tmp_path): _movie, v2 = self._pair(file_manager, tmp_path) file_manager.rename_movie_version(v2, "zkrácená verze") assert self._tags(file_manager) == {"Žánr/Komedie"} def test_demoting_to_a_stale_survivor_keeps_the_tags(self, file_manager, tmp_path): movie, _v2 = self._pair(file_manager, tmp_path) file_manager.remove_movie_version(movie) # v2 (stale) survives, demoted assert self._tags(file_manager) == {"Žánr/Komedie"}