fix(offset): apply season_offset to folder path and update RSS rules

When user sets season_offset, the save path now reflects the adjusted season:
- _gen_save_path() uses (season + season_offset) for folder name
- Files saved directly to correct folder (e.g., Season 2 instead of Season 1)
- update_rule() now updates qBittorrent RSS rule's savePath when offset changes
- Existing torrents are moved to the new location

Renamer changes:
- gen_path() no longer double-applies season_offset (folder already has it)
- Season number taken directly from folder name
- Added path normalization for better save_path matching
- Added debug logging for offset lookup

Torrent name matching (title_raw) remains primary fallback for finding bangumi.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
EstrellaXD
2026-01-26 13:39:01 +01:00
parent f2f00a9f82
commit 95165da3b6
6 changed files with 379 additions and 43 deletions

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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"