diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 09ec7ce5..94816028 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -305,15 +305,51 @@ class BangumiDatabase: def match_by_save_path(self, save_path: str) -> Optional[Bangumi]: """Find bangumi by save_path to get offset. + Tries exact match first, then falls back to matching with/without trailing slashes + and different path separators. + Note: When multiple subscriptions share the same save_path (e.g., different RSS sources for the same anime), this returns the first match. Use match_torrent() for more accurate matching when torrent_name is available. """ + if not save_path: + return None + + # Try exact match first statement = select(Bangumi).where( and_(Bangumi.save_path == save_path, Bangumi.deleted == false()) ) result = self.session.execute(statement) - return result.scalars().first() + bangumi = result.scalars().first() + if bangumi: + return bangumi + + # Normalize the input path and try variations + normalized = save_path.replace("\\", "/").rstrip("/") + variations = [ + normalized, + normalized + "/", + save_path.rstrip("/"), + save_path.rstrip("\\"), + ] + # Remove duplicates while preserving order + seen = {save_path} + unique_variations = [] + for v in variations: + if v not in seen: + seen.add(v) + unique_variations.append(v) + + for variant in unique_variations: + statement = select(Bangumi).where( + and_(Bangumi.save_path == variant, Bangumi.deleted == false()) + ) + result = self.session.execute(statement) + bangumi = result.scalars().first() + if bangumi: + return bangumi + + return None def get_needs_review(self) -> list[Bangumi]: """Get all bangumi that need review for offset mismatch.""" diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index c309ae1d..a9c41880 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -58,10 +58,24 @@ class TorrentPath: @staticmethod def _gen_save_path(data: Bangumi | BangumiUpdate): + """Generate save path for a bangumi. + + The save path uses the adjusted season number (season + season_offset) + so files are saved directly to the correct season folder. + """ folder = ( f"{data.official_title} ({data.year})" if data.year else data.official_title ) - save_path = Path(settings.downloader.path) / folder / f"Season {data.season}" + # Apply season_offset to get the adjusted season number for the folder + adjusted_season = data.season + getattr(data, "season_offset", 0) + if adjusted_season < 1: + adjusted_season = data.season # Safety: don't go below 1 + logger.warning( + f"[Path] Season offset would result in invalid season for {data.official_title}, using original season" + ) + save_path = ( + Path(settings.downloader.path) / folder / f"Season {adjusted_season}" + ) return str(save_path) @staticmethod diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 6f96ffa5..8c158a6c 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -31,16 +31,13 @@ class Renamer(DownloadClient): bangumi_name: str, method: str, episode_offset: int = 0, - season_offset: int = 0, + season_offset: int = 0, # Kept for API compatibility, but no longer used ) -> str: - # Apply season offset - adjusted_season = file_info.season + season_offset - if adjusted_season < 1: - adjusted_season = file_info.season # Safety: don't go below 1 - logger.warning( - f"[Renamer] Season offset {season_offset} would result in invalid season, ignoring" - ) - season = f"0{adjusted_season}" if adjusted_season < 10 else adjusted_season + # Season comes from the folder name which already includes the offset + # (folder is now "Season {season + season_offset}") + # So we use file_info.season directly without applying offset again + season_num = file_info.season + season = f"0{season_num}" if season_num < 10 else season_num # Apply episode offset adjusted_episode = int(file_info.episode) + episode_offset if adjusted_episode < 1: @@ -96,16 +93,14 @@ class Renamer(DownloadClient): if await self.rename_torrent_file( _hash=_hash, old_path=media_path, new_path=new_path ): - # Return adjusted season and episode numbers for notification - adjusted_season = ep.season + season_offset - if adjusted_season < 1: - adjusted_season = ep.season + # Season comes from folder which already has offset applied + # Only apply episode offset adjusted_episode = int(ep.episode) + episode_offset if adjusted_episode < 1: adjusted_episode = int(ep.episode) return Notification( official_title=bangumi_name, - season=adjusted_season, + season=ep.season, episode=adjusted_episode, ) else: @@ -202,6 +197,16 @@ class Renamer(DownloadClient): pass return None + @staticmethod + def _normalize_path(path: str) -> str: + """Normalize path by removing trailing slashes and standardizing separators.""" + if not path: + return path + # Replace backslashes with forward slashes for consistency + normalized = path.replace("\\", "/") + # Remove trailing slashes + return normalized.rstrip("/") + def _lookup_offsets( self, torrent_hash: str, torrent_name: str, save_path: str, tags: str = "" ) -> tuple[int, int]: @@ -229,6 +234,9 @@ class Renamer(DownloadClient): if torrent_record and torrent_record.bangumi_id: bangumi = db.bangumi.search_id(torrent_record.bangumi_id) if bangumi and not bangumi.deleted: + logger.debug( + f"[Renamer] Found offsets via qb_hash: ep={bangumi.episode_offset}, season={bangumi.season_offset}" + ) return bangumi.episode_offset, bangumi.season_offset # Then try by bangumi_id from tags (for newly added torrents) @@ -236,17 +244,35 @@ class Renamer(DownloadClient): if bangumi_id: bangumi = db.bangumi.search_id(bangumi_id) if bangumi and not bangumi.deleted: + logger.debug( + f"[Renamer] Found offsets via tag ab:{bangumi_id}: ep={bangumi.episode_offset}, season={bangumi.season_offset}" + ) return bangumi.episode_offset, bangumi.season_offset # Then try matching by torrent name bangumi = db.bangumi.match_torrent(torrent_name) if bangumi: + logger.debug( + f"[Renamer] Found offsets via torrent name match: ep={bangumi.episode_offset}, season={bangumi.season_offset}" + ) return bangumi.episode_offset, bangumi.season_offset - # Finally fall back to save_path matching (may fail with multiple subscriptions) + # Finally fall back to save_path matching with normalization + normalized_save_path = self._normalize_path(save_path) bangumi = db.bangumi.match_by_save_path(save_path) + if not bangumi: + # Try with normalized path if exact match failed + bangumi = db.bangumi.match_by_save_path(normalized_save_path) if bangumi: + logger.debug( + f"[Renamer] Found offsets via save_path match: ep={bangumi.episode_offset}, season={bangumi.season_offset}" + ) return bangumi.episode_offset, bangumi.season_offset + + logger.debug( + f"[Renamer] No bangumi found for torrent: hash={torrent_hash[:8] if torrent_hash else 'N/A'}, " + f"name={torrent_name[:50] if torrent_name else 'N/A'}..., path={save_path}" + ) except Exception as e: logger.debug(f"[Renamer] Could not lookup offsets for {save_path}: {e}") return 0, 0 diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py index f2e4b9c4..354d4002 100644 --- a/backend/src/module/manager/torrent.py +++ b/backend/src/module/manager/torrent.py @@ -115,10 +115,46 @@ class TorrentManager(Database): # Move torrent match_list = await self.__match_torrents_list(old_data) async with DownloadClient() as client: - path = client._gen_save_path(data) - if match_list: - await client.move_torrent(match_list, path) - data.save_path = path + new_path = client._gen_save_path(data) + old_path = old_data.save_path + + # Move existing torrents to new location if path changed + if match_list and new_path != old_path: + await client.move_torrent(match_list, new_path) + logger.info( + f"[Manager] Moved torrents from {old_path} to {new_path}" + ) + + # Update qBittorrent RSS rule if save_path changed + if new_path != old_path and old_data.rule_name: + # Recreate the rule with the new save_path + rule = { + "enable": True, + "mustContain": data.title_raw, + "mustNotContain": "|".join(data.filter) + if isinstance(data.filter, list) + else data.filter, + "useRegex": True, + "episodeFilter": "", + "smartFilter": False, + "previouslyMatchedEpisodes": [], + "affectedFeeds": data.rss_link + if isinstance(data.rss_link, str) + else ",".join(data.rss_link), + "ignoreDays": 0, + "lastMatch": "", + "addPaused": False, + "assignedCategory": "Bangumi", + "savePath": new_path, + } + await client.client.rss_set_rule( + rule_name=old_data.rule_name, rule_def=rule + ) + logger.info( + f"[Manager] Updated RSS rule {old_data.rule_name} with new save_path" + ) + + data.save_path = new_path self.bangumi.update(data, bangumi_id) return ResponseModel( status_code=200, @@ -279,12 +315,16 @@ class TorrentManager(Database): if tmdb_info.series_status == "Ended" and not bangumi.archived: bangumi.archived = True archived_count += 1 - logger.info(f"[Manager] Auto-archived ended series: {bangumi.official_title}") + logger.info( + f"[Manager] Auto-archived ended series: {bangumi.official_title}" + ) if archived_count > 0 or poster_count > 0: self.bangumi.update_all(bangumis) - logger.info(f"[Manager] Metadata refresh: archived {archived_count}, updated posters {poster_count}") + logger.info( + f"[Manager] Metadata refresh: archived {archived_count}, updated posters {poster_count}" + ) return ResponseModel( status_code=200, status=True, @@ -296,13 +336,19 @@ class TorrentManager(Database): """Suggest offset based on TMDB episode counts.""" data = self.bangumi.search_id(bangumi_id) if not data: - return {"suggested_offset": 0, "reason": f"Bangumi id {bangumi_id} not found"} + return { + "suggested_offset": 0, + "reason": f"Bangumi id {bangumi_id} not found", + } language = settings.rss_parser.language tmdb_info = await tmdb_parser(data.official_title, language) if not tmdb_info or not tmdb_info.season_episode_counts: - return {"suggested_offset": 0, "reason": "Unable to fetch TMDB episode data"} + return { + "suggested_offset": 0, + "reason": "Unable to fetch TMDB episode data", + } season = data.season if season <= 1: diff --git a/backend/src/test/test_path_parser.py b/backend/src/test/test_path_parser.py index 9e14ea17..799631e6 100644 --- a/backend/src/test/test_path_parser.py +++ b/backend/src/test/test_path_parser.py @@ -1,12 +1,91 @@ +from unittest.mock import patch + from module.conf import PLATFORM def test_path_to_bangumi(): # Test for unix-like path from module.downloader.path import TorrentPath + path = "Downloads/Bangumi/Kono Subarashii Sekai ni Shukufuku wo!/Season 2/" bangumi_name, season = TorrentPath()._path_to_bangumi(path) assert bangumi_name == "Kono Subarashii Sekai ni Shukufuku wo!" assert season == 2 +class TestGenSavePath: + """Tests for TorrentPath._gen_save_path with season_offset.""" + + def test_gen_save_path_no_offset(self): + """Save path uses season directly when no offset.""" + from module.downloader.path import TorrentPath + from module.models import Bangumi + + bangumi = Bangumi( + official_title="Test Anime", + year="2024", + season=1, + season_offset=0, + title_raw="test", + ) + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = TorrentPath._gen_save_path(bangumi) + + assert "Season 1" in result + assert "Test Anime (2024)" in result + + def test_gen_save_path_with_positive_offset(self): + """Save path uses adjusted season when offset is positive.""" + from module.downloader.path import TorrentPath + from module.models import Bangumi + + bangumi = Bangumi( + official_title="Test Anime", + year="2024", + season=1, + season_offset=1, + title_raw="test", + ) + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = TorrentPath._gen_save_path(bangumi) + + assert "Season 2" in result # 1 + 1 = 2 + assert "Test Anime (2024)" in result + + def test_gen_save_path_with_negative_offset(self): + """Save path uses adjusted season when offset is negative.""" + from module.downloader.path import TorrentPath + from module.models import Bangumi + + bangumi = Bangumi( + official_title="Test Anime", + year="2024", + season=3, + season_offset=-1, + title_raw="test", + ) + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = TorrentPath._gen_save_path(bangumi) + + assert "Season 2" in result # 3 - 1 = 2 + + def test_gen_save_path_offset_below_one_ignored(self): + """Save path doesn't go below Season 1.""" + from module.downloader.path import TorrentPath + from module.models import Bangumi + + bangumi = Bangumi( + official_title="Test Anime", + year="2024", + season=1, + season_offset=-5, + title_raw="test", + ) + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = TorrentPath._gen_save_path(bangumi) + + assert "Season 1" in result # Would be -4, so uses original season diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index 44bfd4dd..e56ed3ff 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -364,7 +364,11 @@ class TestRenameSubtitles: renamer.client.torrents_rename_file.assert_called_once() call_args = renamer.client.torrents_rename_file.call_args - new_path = call_args[1]["new_path"] if "new_path" in (call_args[1] or {}) else call_args[0][2] + new_path = ( + call_args[1]["new_path"] + if "new_path" in (call_args[1] or {}) + else call_args[0][2] + ) assert ".zh." in new_path @@ -396,11 +400,13 @@ class TestRenameFlow: async def test_single_file_rename(self, renamer): """Full rename flow for a single-file torrent.""" renamer.client.torrents_info.return_value = [ - {"hash": "h1", "name": "[Sub] Anime - 01.mkv", "save_path": "/downloads/Bangumi/Anime (2024)/Season 1"} - ] - renamer.client.torrents_files.return_value = [ - {"name": "[Sub] Anime - 01.mkv"} + { + "hash": "h1", + "name": "[Sub] Anime - 01.mkv", + "save_path": "/downloads/Bangumi/Anime (2024)/Season 1", + } ] + renamer.client.torrents_files.return_value = [{"name": "[Sub] Anime - 01.mkv"}] renamer.client.torrents_rename_file.return_value = True ep = EpisodeFile( @@ -424,7 +430,11 @@ class TestRenameFlow: async def test_collection_sets_category(self, renamer): """Multi-file torrent triggers collection rename and set_category.""" renamer.client.torrents_info.return_value = [ - {"hash": "h1", "name": "Anime Collection", "save_path": "/downloads/Bangumi/Anime (2024)/Season 1"} + { + "hash": "h1", + "name": "Anime Collection", + "save_path": "/downloads/Bangumi/Anime (2024)/Season 1", + } ] renamer.client.torrents_files.return_value = [ {"name": "ep01.mkv"}, @@ -456,7 +466,11 @@ class TestRenameFlow: async def test_no_media_files_no_crash(self, renamer): """When torrent has no media files, logs warning but doesn't crash.""" renamer.client.torrents_info.return_value = [ - {"hash": "h1", "name": "No Media", "save_path": "/downloads/Bangumi/Anime/Season 1"} + { + "hash": "h1", + "name": "No Media", + "save_path": "/downloads/Bangumi/Anime/Season 1", + } ] renamer.client.torrents_files.return_value = [ {"name": "readme.txt"}, @@ -564,38 +578,54 @@ class TestGenPathWithOffsets: assert "E05" in result # Would be -5, so offset ignored def test_season_offset_positive(self): - """Season offset adds to season number.""" + """Season offset is now applied to folder path, not filename. + + The season_offset parameter is kept for API compatibility but no longer + affects the filename. The folder path (generated by _gen_save_path) + already includes the offset, so the season from the folder is used directly. + """ + # Simulate file in Season 2 folder (offset already applied to folder) ep = EpisodeFile( - media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + media_path="old.mkv", title="My Anime", season=2, episode=5, suffix=".mkv" ) result = Renamer.gen_path(ep, "Bangumi", method="pn", season_offset=1) - assert "S02" in result # 1 + 1 = 2 + assert ( + "S02" in result + ) # Season from folder used directly, offset not re-applied def test_season_offset_negative(self): - """Negative season offset subtracts from season number.""" + """Season offset is now applied to folder path, not filename.""" + # Simulate file in Season 2 folder (offset already applied to folder) ep = EpisodeFile( - media_path="old.mkv", title="My Anime", season=3, episode=5, suffix=".mkv" + media_path="old.mkv", title="My Anime", season=2, episode=5, suffix=".mkv" ) result = Renamer.gen_path(ep, "Bangumi", method="pn", season_offset=-1) - assert "S02" in result # 3 - 1 = 2 + assert ( + "S02" in result + ) # Season from folder used directly, offset not re-applied def test_season_offset_negative_below_one_ignored(self): - """Negative season offset that would go below 1 is ignored.""" + """Season offset parameter no longer affects filename.""" ep = EpisodeFile( media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" ) result = Renamer.gen_path(ep, "Bangumi", method="pn", season_offset=-5) - assert "S01" in result # Would be -4, so offset ignored + assert "S01" in result # Season from folder used directly def test_both_offsets_combined(self): - """Both episode and season offset applied together.""" + """Episode offset applied to filename, season offset applied to folder path. + + The folder path already includes season_offset (Season 2 in this case). + Only episode_offset is applied during filename generation. + """ + # Simulate file in Season 2 folder (season_offset=1 applied to folder: 1+1=2) ep = EpisodeFile( - media_path="old.mkv", title="My Anime", season=1, episode=13, suffix=".mkv" + media_path="old.mkv", title="My Anime", season=2, episode=13, suffix=".mkv" ) result = Renamer.gen_path( ep, "Bangumi", method="pn", episode_offset=-12, season_offset=1 ) - assert "S02E01" in result # Season 1+1=2, Episode 13-12=1 + assert "S02E01" in result # Season 2 from folder, Episode 13-12=1 def test_offset_with_advance_method(self): """Offset works with advance rename method.""" @@ -892,3 +922,108 @@ class TestLookupOffsets: assert episode_offset == 0 assert season_offset == 0 + + def test_lookup_by_save_path_with_trailing_slash(self, renamer, db_session): + """Save path matching works with trailing slashes.""" + from module.database.bangumi import BangumiDatabase + from module.database.torrent import TorrentDatabase + from module.models import Bangumi + + # Create bangumi with save_path WITHOUT trailing slash + bangumi_db = BangumiDatabase(db_session) + bangumi = Bangumi( + official_title="Trailing Slash Test", + year="2024", + title_raw="unique_raw_trailing", + season=1, + save_path="/downloads/Bangumi/Test (2024)/Season 1", + episode_offset=5, + season_offset=2, + ) + bangumi_db.add(bangumi) + + with patch("module.manager.renamer.Database") as MockDatabase: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.torrent = TorrentDatabase(db_session) + mock_db.bangumi = BangumiDatabase(db_session) + MockDatabase.return_value = mock_db + + # Query WITH trailing slash - should still match + episode_offset, season_offset = renamer._lookup_offsets( + torrent_hash="nonexistent", + torrent_name="no_match", + save_path="/downloads/Bangumi/Test (2024)/Season 1/", + tags="", + ) + + assert episode_offset == 5 + assert season_offset == 2 + + def test_lookup_by_save_path_with_backslashes(self, renamer, db_session): + """Save path matching works with Windows-style backslashes.""" + from module.database.bangumi import BangumiDatabase + from module.database.torrent import TorrentDatabase + from module.models import Bangumi + + # Create bangumi with forward slashes + bangumi_db = BangumiDatabase(db_session) + bangumi = Bangumi( + official_title="Backslash Test", + year="2024", + title_raw="unique_raw_backslash", + season=1, + save_path="/downloads/Bangumi/Test (2024)/Season 1", + episode_offset=3, + season_offset=1, + ) + bangumi_db.add(bangumi) + + with patch("module.manager.renamer.Database") as MockDatabase: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.torrent = TorrentDatabase(db_session) + mock_db.bangumi = BangumiDatabase(db_session) + MockDatabase.return_value = mock_db + + # Query with backslashes - should still match after normalization + episode_offset, season_offset = renamer._lookup_offsets( + torrent_hash="nonexistent", + torrent_name="no_match", + save_path="\\downloads\\Bangumi\\Test (2024)\\Season 1", + tags="", + ) + + assert episode_offset == 3 + assert season_offset == 1 + + +class TestNormalizePath: + """Tests for Renamer._normalize_path static method.""" + + def test_empty_path(self): + from module.manager.renamer import Renamer + + assert Renamer._normalize_path("") == "" + + def test_removes_trailing_slash(self): + from module.manager.renamer import Renamer + + assert Renamer._normalize_path("/path/to/dir/") == "/path/to/dir" + + def test_removes_trailing_backslash(self): + from module.manager.renamer import Renamer + + assert Renamer._normalize_path("C:\\path\\to\\dir\\") == "C:/path/to/dir" + + def test_converts_backslashes(self): + from module.manager.renamer import Renamer + + assert Renamer._normalize_path("C:\\path\\to\\dir") == "C:/path/to/dir" + + def test_preserves_forward_slashes(self): + from module.manager.renamer import Renamer + + assert Renamer._normalize_path("/path/to/dir") == "/path/to/dir"