"""Tests for src/Matcher/episode_renamer.py - EpisodeRenamer.""" import sys from pathlib import Path from unittest.mock import patch, MagicMock sys.path.insert(0, str(Path(__file__).parent.parent)) import pytest from src.Matcher.episode_renamer import EpisodeRenamer class TestEpisodeRenamerInit: def test_init_defaults(self): r = EpisodeRenamer("/tmp/test", "Show Name", 1) assert r.show_name == "Show Name" assert r.season_number == 1 assert r.disc_mapping is None assert r.auto_delete_duplicates is False def test_init_with_disc_mapping(self): r = EpisodeRenamer("/tmp/test", "Show", 2, disc_mapping={1: [1, 2], 2: [3, 4]}) assert r.disc_mapping == {1: [1, 2], 2: [3, 4]} def test_init_auto_delete(self): r = EpisodeRenamer("/tmp/test", "Show", 1, auto_delete_duplicates=True) assert r.auto_delete_duplicates is True def test_init_folder_paths(self): r = EpisodeRenamer("/tmp/test", "Show", 1) assert r.extras_folder == Path("/tmp/test/extras") assert r.delete_folder == Path("/tmp/test/delete me") class TestSanitizeFilename: def test_remove_invalid_chars(self): r = EpisodeRenamer("/tmp/t", "S", 1) result = r._sanitize_filename('ac:d"e/f\\g|h?i*j') assert ":" not in result assert "<" not in result assert ">" not in result assert '"' not in result assert "/" not in result assert "\\" not in result assert "|" not in result assert "?" not in result assert "*" not in result assert "a" in result and "b" in result and "c" in result def test_collapse_spaces(self): r = EpisodeRenamer("/tmp/t", "S", 1) assert r._sanitize_filename("a b") == "a b" def test_strip(self): r = EpisodeRenamer("/tmp/t", "S", 1) assert r._sanitize_filename(" hello ") == "hello" def test_preserve_valid_chars(self): r = EpisodeRenamer("/tmp/t", "S", 1) assert r._sanitize_filename("hello_world-1.mkv") == "hello_world-1.mkv" class TestGenerateEpisodeFilename: def test_standard(self): r = EpisodeRenamer("/tmp/t", "Show Name", 3) assert r._generate_episode_filename(5, ".mkv") == "Show Name s03e05.mkv" def test_single_digit_padding(self): r = EpisodeRenamer("/tmp/t", "Show", 1) assert r._generate_episode_filename(1, ".mp4") == "Show s01e01.mp4" def test_double_digit_episode(self): r = EpisodeRenamer("/tmp/t", "Show", 10) assert r._generate_episode_filename(12, ".mkv") == "Show s10e12.mkv" def test_sanitizes_output(self): r = EpisodeRenamer("/tmp/t", "Show:Name", 1) assert r._generate_episode_filename(1, ".mkv") == "ShowName s01e01.mkv" class TestExtractDiscInfo: def test_disc_with_underscore(self): r = EpisodeRenamer("/tmp/t", "S", 1) info = r._extract_disc_info("Disc 1_t01.mkv") assert info["disc_number"] == 1 assert info["track_number"] == 1 def test_disk_variant(self): r = EpisodeRenamer("/tmp/t", "S", 1) info = r._extract_disc_info("Disk2_t03.mkv") assert info["disc_number"] == 2 assert info["track_number"] == 3 def test_disc_hyphen(self): r = EpisodeRenamer("/tmp/t", "S", 1) info = r._extract_disc_info("disc-3_track05.mkv") assert info["disc_number"] == 3 assert info["track_number"] == 5 def test_no_disc_info(self): r = EpisodeRenamer("/tmp/t", "S", 1) info = r._extract_disc_info("episode_01.mkv") assert info["disc_number"] is None assert info["track_number"] is None def test_track_pattern(self): r = EpisodeRenamer("/tmp/t", "S", 1) info = r._extract_disc_info("file_t22.mkv") assert info["track_number"] == 22 def test_disc_only(self): r = EpisodeRenamer("/tmp/t", "S", 1) info = r._extract_disc_info("Disc 5.mkv") assert info["disc_number"] == 5 assert info["track_number"] is None class TestComputeFileHash: def test_hash_consistent(self, tmp_path): f = tmp_path / "test.mkv" f.write_bytes(b"\x00" * 1000) r = EpisodeRenamer(str(tmp_path), "S", 1) h1 = r._compute_file_hash(f) h2 = r._compute_file_hash(f) assert h1 == h2 assert len(h1) == 64 def test_hash_different_content(self, tmp_path): f1 = tmp_path / "a.mkv" f2 = tmp_path / "b.mkv" f1.write_bytes(b"\x00" * 100) f2.write_bytes(b"\xff" * 100) r = EpisodeRenamer(str(tmp_path), "S", 1) assert r._compute_file_hash(f1) != r._compute_file_hash(f2) def test_hash_custom_bytes(self, tmp_path): f = tmp_path / "test.mkv" f.write_bytes(b"\x00" * 10000) r = EpisodeRenamer(str(tmp_path), "S", 1) h = r._compute_file_hash(f, num_bytes=512) assert h def test_hash_missing_file(self, tmp_path): r = EpisodeRenamer(str(tmp_path), "S", 1) h = r._compute_file_hash(tmp_path / "nope.mkv") assert h == "" class TestCreateFolders: def test_create_extras_folder(self, tmp_path): r = EpisodeRenamer(str(tmp_path), "S", 1) assert r._create_extras_folder() is True assert (tmp_path / "extras").exists() def test_create_delete_folder(self, tmp_path): r = EpisodeRenamer(str(tmp_path), "S", 1) assert r._create_delete_folder() is True assert (tmp_path / "delete me").exists() def test_create_extras_already_exists(self, tmp_path): (tmp_path / "extras").mkdir() r = EpisodeRenamer(str(tmp_path), "S", 1) assert r._create_extras_folder() is True class TestMoveExtras: def test_move_extras(self, tmp_path): (tmp_path / "extra.mkv").write_bytes(b"\x00" * 100) r = EpisodeRenamer(str(tmp_path), "S", 1) extras = [{"path": tmp_path / "extra.mkv", "name": "extra.mkv"}] moved = r.move_extras_to_folder(extras) assert len(moved) == 1 assert (tmp_path / "extras" / "extra.mkv").exists() assert not (tmp_path / "extra.mkv").exists() def test_move_extras_skip_existing(self, tmp_path): (tmp_path / "extras").mkdir() (tmp_path / "extras" / "extra.mkv").write_bytes(b"\x00") (tmp_path / "extra.mkv").write_bytes(b"\x00" * 100) r = EpisodeRenamer(str(tmp_path), "S", 1) extras = [{"path": tmp_path / "extra.mkv", "name": "extra.mkv"}] moved = r.move_extras_to_folder(extras) assert len(moved) == 0 def test_move_extras_empty(self, tmp_path): r = EpisodeRenamer(str(tmp_path), "S", 1) assert r.move_extras_to_folder([]) == [] class TestDuplicateDetection: def test_detect_duplicates_same_duration(self, tmp_path): f1 = tmp_path / "Disc 1_t01.mkv" f2 = tmp_path / "Disc 1_t02.mkv" f1.write_bytes(b"\x00" * 2000) f2.write_bytes(b"\x00" * 2000) eps = [ {"path": f1, "name": "Disc 1_t01.mkv", "size_gb": 2.0, "duration_minutes": 45.0}, {"path": f2, "name": "Disc 1_t02.mkv", "size_gb": 2.0, "duration_minutes": 45.0}, ] r = EpisodeRenamer(str(tmp_path), "S", 1) result = r.detect_and_move_duplicates(eps) assert isinstance(result, list) def test_no_duplicates_different_duration(self, tmp_path): f1 = tmp_path / "Disc 1_t01.mkv" f2 = tmp_path / "Disc 2_t01.mkv" f1.write_bytes(b"\x00" * 2000) f2.write_bytes(b"\x01" * 3000) eps = [ {"path": f1, "name": "Disc 1_t01.mkv", "size_gb": 2.0, "duration_minutes": 45.0}, {"path": f2, "name": "Disc 2_t01.mkv", "size_gb": 3.0, "duration_minutes": 55.0}, ] r = EpisodeRenamer(str(tmp_path), "S", 1) r.detect_and_move_duplicates(eps) def test_duplicate_moved_to_delete_me(self, tmp_path): f1 = tmp_path / "Disc 1_t01.mkv" f2 = tmp_path / "Disc 1_t02.mkv" f1.write_bytes(b"\x00" * 2000) f2.write_bytes(b"\x00" * 2000) eps = [ {"path": f1, "name": "Disc 1_t01.mkv", "size_gb": 2.0, "duration_minutes": 45.0}, {"path": f2, "name": "Disc 1_t02.mkv", "size_gb": 2.0, "duration_minutes": 45.0}, ] r = EpisodeRenamer(str(tmp_path), "S", 1, auto_delete_duplicates=False) result = r.detect_and_move_duplicates(eps) assert (tmp_path / "delete me").exists() class TestMatchEpisodes: def test_disc_mapping_match(self, mock_episodes, mock_tvdb_episodes, disc_mapping): r = EpisodeRenamer("/tmp/t", "Show", 1, disc_mapping=disc_mapping) episodes_info = [] for ep in mock_episodes: disc_info = r._extract_disc_info(ep["name"]) episodes_info.append({ "file_info": ep, "disc_number": disc_info["disc_number"], "track_number": disc_info["track_number"], "filename": ep["name"], "duration": ep["duration_minutes"], }) matched = r._match_using_disc_mapping(episodes_info, mock_tvdb_episodes) assert len(matched) > 0 ep_nums = [m["episode_number"] for m in matched] assert len(set(ep_nums)) == len(ep_nums) def test_fallback_sequential(self, mock_episodes, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) files = [] for ep in mock_episodes: files.append({ "file_info": ep, "filename": ep["name"], "disc_number": None, "duration": ep["duration_minutes"], }) allowed = list(range(1, 11)) result = r._fallback_sequential(files, mock_tvdb_episodes, allowed) assert len(result) > 0 def test_dp_match_episodes(self, mock_episodes, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) files = [] for ep in mock_episodes: files.append({ "file_info": ep, "filename": ep["name"], "disc_number": None, "duration": ep["duration_minutes"], }) allowed = list(range(1, 11)) result = r._dp_match_episodes(files, mock_tvdb_episodes, allowed) assert len(result) > 0 ep_nums = [m["episode_number"] for m in result] assert len(set(ep_nums)) == len(ep_nums) def test_precise_duration_match(self, mock_episodes, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) files = [] for ep in mock_episodes: files.append({ "file_info": ep, "filename": ep["name"], "disc_number": None, "duration": ep["duration_minutes"], }) allowed = list(range(1, 7)) result = r._precise_duration_match(files, mock_tvdb_episodes, allowed) assert len(result) > 0 def test_dp_match_empty_files(self, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) result = r._dp_match_episodes([], mock_tvdb_episodes, list(range(1, 11))) assert isinstance(result, list) def test_precise_match_empty(self, mock_episodes, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) files = [{"file_info": {}, "filename": "a.mkv", "duration": 45.0}] result = r._precise_duration_match(files, [], []) assert isinstance(result, list) def test_fallback_sequential_empty(self, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) result = r._fallback_sequential([], mock_tvdb_episodes, []) assert result == [] class TestRenameEpisodes: def test_rename_no_episodes(self): r = EpisodeRenamer("/tmp/t", "S", 1) assert r.rename_episodes([]) == [] def test_rename_with_tvdb(self, tmp_path, mock_episodes, mock_tvdb_episodes): for ep in mock_episodes: f = tmp_path / ep["name"] f.write_bytes(b"\x00" * ep["size_bytes"]) ep["path"] = f r = EpisodeRenamer(str(tmp_path), "TestShow", 1) renamed = r.rename_episodes(mock_episodes, mock_tvdb_episodes) assert len(renamed) > 0 for item in renamed: assert "original_name" in item assert "new_name" in item assert "episode_number" in item def test_rename_skip_existing(self, tmp_path, mock_episodes, capsys): for ep in mock_episodes[:2]: f = tmp_path / ep["name"] f.write_bytes(b"\x00" * ep["size_bytes"]) ep["path"] = f (tmp_path / "TestShow s01e01.mkv").write_bytes(b"\x00") r = EpisodeRenamer(str(tmp_path), "TestShow", 1) renamed = r.rename_episodes(mock_episodes[:2], None) out = capsys.readouterr().out assert "already exists" in out or len(renamed) < 2 class TestEstimateEpisodesPerDisc: def test_estimate_basic(self, tmp_path, mock_episodes): for ep in mock_episodes: f = tmp_path / ep["name"] f.write_bytes(b"\x00" * ep["size_bytes"]) ep["path"] = f r = EpisodeRenamer(str(tmp_path), "S", 1) episodes_info = [] for ep in mock_episodes: disc_info = r._extract_disc_info(ep["name"]) episodes_info.append({ "file_info": ep, "disc_number": disc_info["disc_number"], "filename": ep["name"], }) result = r._estimate_episodes_per_disc(episodes_info, 6) assert isinstance(result, dict) def test_estimate_no_disc_info(self): r = EpisodeRenamer("/tmp/t", "S", 1) result = r._estimate_episodes_per_disc([], 10) assert result == {} def test_estimate_no_disc_numbers(self): r = EpisodeRenamer("/tmp/t", "S", 1) info = [{"disc_number": None, "filename": "a.mkv", "file_info": {"size_gb": 5.0}}] result = r._estimate_episodes_per_disc(info, 10) assert result == {} class TestAnalyzeDiscCapacity: def test_analyze_basic(self, tmp_path, mock_episodes): for ep in mock_episodes: f = tmp_path / ep["name"] f.write_bytes(b"\x00" * ep["size_bytes"]) ep["path"] = f r = EpisodeRenamer(str(tmp_path), "S", 1) info = [] for ep in mock_episodes: disc_info = r._extract_disc_info(ep["name"]) info.append({ "file_info": ep, "disc_number": disc_info["disc_number"], "filename": ep["name"], }) result = r._analyze_disc_capacity(info) assert isinstance(result, dict) def test_analyze_no_discs(self): r = EpisodeRenamer("/tmp/t", "S", 1) result = r._analyze_disc_capacity([{"disc_number": None, "file_info": {"size_gb": 5}}]) assert result == {} class TestDurationMatching: def test_match_by_duration_and_order(self, mock_episodes, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) info = [] for ep in mock_episodes: disc_info = r._extract_disc_info(ep["name"]) info.append({ "file_info": ep, "disc_number": disc_info["disc_number"], "track_number": disc_info["track_number"], "filename": ep["name"], "duration": ep["duration_minutes"], }) result = r._match_by_duration_and_order(info, mock_tvdb_episodes) assert len(result) > 0 ep_nums = [m["episode_number"] for m in result] assert len(set(ep_nums)) == len(ep_nums) def test_flexible_duration_match(self, mock_episodes, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) info = [] for ep in mock_episodes: disc_info = r._extract_disc_info(ep["name"]) info.append({ "file_info": ep, "disc_number": disc_info["disc_number"], "filename": ep["name"], "duration": ep["duration_minutes"], }) result = r._flexible_duration_match(info, mock_tvdb_episodes) assert isinstance(result, list) def test_sequential_assignment_with_constraints(self, mock_episodes, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) info = [] for ep in mock_episodes: disc_info = r._extract_disc_info(ep["name"]) info.append({ "file_info": ep, "disc_number": disc_info["disc_number"], "filename": ep["name"], "duration": ep["duration_minutes"], }) disc_capacity = {1: 3, 2: 3} result = r._sequential_assignment_with_constraints(info, mock_tvdb_episodes, disc_capacity) assert isinstance(result, list) def test_validate_sequential_assignment_valid(self): r = EpisodeRenamer("/tmp/t", "S", 1) file_info = {"duration": 55.0, "disc_number": 1} tvdb_ep = {"runtime": 55, "episode_number": 1} assert r._validate_sequential_assignment(file_info, tvdb_ep, {}, {}) is True def test_validate_sequential_assignment_duration_mismatch(self): r = EpisodeRenamer("/tmp/t", "S", 1) file_info = {"duration": 30.0, "disc_number": 1} tvdb_ep = {"runtime": 60, "episode_number": 1} result = r._validate_sequential_assignment(file_info, tvdb_ep, {}, {}) assert result is False def test_validate_sequential_assignment_disc_full(self): r = EpisodeRenamer("/tmp/t", "S", 1) file_info = {"duration": 55.0, "disc_number": 1} tvdb_ep = {"runtime": 55, "episode_number": 1} disc_assignments = {1: 3} disc_capacity = {1: 3} result = r._validate_sequential_assignment(file_info, tvdb_ep, disc_assignments, disc_capacity) assert result is False def test_assignment_satisfies_constraints(self): r = EpisodeRenamer("/tmp/t", "S", 1) fake_path = type("P", (), {"name": "Disc 1_t01.mkv"})() file_info = { "duration": 55.0, "disc_number": 1, "file_info": {"path": fake_path}, } tvdb_ep = {"runtime": 55, "episode_number": 1} result = r._assignment_satisfies_constraints(file_info, tvdb_ep, [], {}) assert result is True def test_assignment_satisfies_duration_exceeded(self): r = EpisodeRenamer("/tmp/t", "S", 1) fake_path = type("P", (), {"name": "a.mkv"})() file_info = { "duration": 30.0, "disc_number": None, "file_info": {"path": fake_path}, } tvdb_ep = {"runtime": 60, "episode_number": 1} result = r._assignment_satisfies_constraints(file_info, tvdb_ep, [], {}) assert result is False class TestMatchByDurationAndDisc: def test_match_disc(self, mock_episodes, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) info = [] for ep in mock_episodes: disc_info = r._extract_disc_info(ep["name"]) info.append({ "file_info": ep, "disc_number": disc_info["disc_number"], "filename": ep["name"], "duration": ep["duration_minutes"], }) episodes_per_disc = {1: [1, 2, 3], 2: [4, 5, 6]} result = r._match_by_duration_and_disc(info, mock_tvdb_episodes, episodes_per_disc) assert len(result) > 0 for m in result: assert m["episode_number"] in [1, 2, 3, 4, 5, 6] class TestFindOptimalAssignment: def test_find_optimal_assignment(self, mock_episodes, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) info = [] for ep in mock_episodes: disc_info = r._extract_disc_info(ep["name"]) info.append({ "file_info": ep, "disc_number": disc_info["disc_number"], "filename": ep["name"], "duration": ep["duration_minutes"], }) result = r._find_optimal_assignment(info, mock_tvdb_episodes, {1: 3, 2: 3}) assert isinstance(result, list) def test_find_optimal_empty(self, mock_tvdb_episodes): r = EpisodeRenamer("/tmp/t", "Show", 1) result = r._find_optimal_assignment([], mock_tvdb_episodes, {}) assert result == [] class TestIsValidAssignment: def test_valid_assignment(self): r = EpisodeRenamer("/tmp/t", "S", 1) file_info = {"duration": 55.0, "disc_number": None} tvdb_ep = {"runtime": 55, "episode_number": 1} result = r._is_valid_assignment(file_info, tvdb_ep, {}, 0, []) assert result is True def test_invalid_duration(self): r = EpisodeRenamer("/tmp/t", "S", 1) file_info = {"duration": 30.0, "disc_number": None} tvdb_ep = {"runtime": 60, "episode_number": 1} result = r._is_valid_assignment(file_info, tvdb_ep, {}, 0, []) assert result is False