import pytest from src.core.file import File from src.core.file_manager import FileManager from src.core.tag_manager import TagManager from src.core.tag import Tag @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] - 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