Skip to content

Commit b33ec01

Browse files
EstrellaXDclaude
andcommitted
fix: improve rename reliability and add torrent tagging API
- Fix qBittorrent rename verification (verify file actually renamed) - Add pending rename cooldown to prevent spam when rename delayed - Add torrent tagging API for accurate offset lookup - Add auto calendar refresh every 24 hours - Fix frontend error handling (don't logout on server errors) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b0c0059 commit b33ec01

9 files changed

Lines changed: 391 additions & 18 deletions

File tree

backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "auto-bangumi"
3-
version = "3.2.2"
3+
version = "3.2.3"
44
description = "AutoBangumi - Automated anime download manager"
55
requires-python = ">=3.13"
66
dependencies = [

backend/src/module/api/downloader.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import logging
2+
13
from fastapi import APIRouter, Depends
24
from pydantic import BaseModel
35

6+
from module.database import Database
47
from module.downloader import DownloadClient
58
from module.security.api import get_current_user
69

10+
logger = logging.getLogger(__name__)
11+
712
router = APIRouter(prefix="/downloader", tags=["downloader"])
813

914

@@ -16,6 +21,12 @@ class TorrentDeleteRequest(BaseModel):
1621
delete_files: bool = False
1722

1823

24+
class TorrentTagRequest(BaseModel):
25+
"""Request to tag a torrent with a bangumi ID."""
26+
hash: str
27+
bangumi_id: int
28+
29+
1930
@router.get("/torrents", dependencies=[Depends(get_current_user)])
2031
async def get_torrents():
2132
async with DownloadClient() as client:
@@ -44,3 +55,91 @@ async def delete_torrents(req: TorrentDeleteRequest):
4455
async with DownloadClient() as client:
4556
await client.delete_torrent(hashes, delete_files=req.delete_files)
4657
return {"msg_en": "Torrents deleted", "msg_zh": "种子已删除"}
58+
59+
60+
@router.post("/torrents/tag", dependencies=[Depends(get_current_user)])
61+
async def tag_torrent(req: TorrentTagRequest):
62+
"""Tag a torrent with a bangumi ID for accurate offset lookup.
63+
64+
This adds the 'ab:ID' tag to the torrent in qBittorrent, which allows
65+
the renamer to look up the correct episode/season offset.
66+
"""
67+
# Verify bangumi exists
68+
with Database() as db:
69+
bangumi = db.bangumi.search_id(req.bangumi_id)
70+
if not bangumi:
71+
return {
72+
"status": False,
73+
"msg_en": f"Bangumi {req.bangumi_id} not found",
74+
"msg_zh": f"未找到番剧 {req.bangumi_id}",
75+
}
76+
77+
tag = f"ab:{req.bangumi_id}"
78+
async with DownloadClient() as client:
79+
await client.add_tag(req.hash, tag)
80+
81+
return {
82+
"status": True,
83+
"msg_en": f"Tagged torrent with {tag}",
84+
"msg_zh": f"已为种子添加标签 {tag}",
85+
}
86+
87+
88+
@router.post("/torrents/tag/auto", dependencies=[Depends(get_current_user)])
89+
async def auto_tag_torrents():
90+
"""Auto-tag all untagged Bangumi torrents based on name/path matching.
91+
92+
This helps fix torrents that were added before tagging was implemented.
93+
Returns the number of torrents tagged and any that couldn't be matched.
94+
"""
95+
tagged_count = 0
96+
unmatched = []
97+
98+
async with DownloadClient() as client:
99+
# Get all Bangumi torrents
100+
torrents = await client.get_torrent_info(category="Bangumi", status_filter=None)
101+
102+
with Database() as db:
103+
for torrent in torrents:
104+
torrent_hash = torrent["hash"]
105+
torrent_name = torrent["name"]
106+
save_path = torrent["save_path"]
107+
tags = torrent.get("tags", "")
108+
109+
# Skip if already has ab: tag
110+
if "ab:" in tags:
111+
continue
112+
113+
# Try to match bangumi
114+
bangumi = None
115+
116+
# First try by torrent name
117+
bangumi = db.bangumi.match_torrent(torrent_name)
118+
119+
# Then try by save_path
120+
if not bangumi:
121+
bangumi = db.bangumi.match_by_save_path(save_path)
122+
123+
if bangumi and not bangumi.deleted:
124+
tag = f"ab:{bangumi.id}"
125+
await client.add_tag(torrent_hash, tag)
126+
tagged_count += 1
127+
logger.info(
128+
f"[AutoTag] Tagged '{torrent_name[:50]}...' with {tag} "
129+
f"(matched: {bangumi.official_title})"
130+
)
131+
else:
132+
unmatched.append({
133+
"hash": torrent_hash,
134+
"name": torrent_name,
135+
"save_path": save_path,
136+
})
137+
138+
return {
139+
"status": True,
140+
"tagged_count": tagged_count,
141+
"unmatched_count": len(unmatched),
142+
"unmatched": unmatched[:10], # Return first 10 unmatched for debugging
143+
"msg_en": f"Tagged {tagged_count} torrents, {len(unmatched)} could not be matched",
144+
"msg_zh": f"已标记 {tagged_count} 个种子,{len(unmatched)} 个无法匹配",
145+
}

backend/src/module/core/program.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
start_up,
1414
)
1515

16-
from .sub_thread import OffsetScanThread, RenameThread, RSSThread
16+
from .sub_thread import CalendarRefreshThread, OffsetScanThread, RenameThread, RSSThread
1717

1818
logger = logging.getLogger(__name__)
1919

@@ -29,7 +29,7 @@
2929
"""
3030

3131

32-
class Program(RenameThread, RSSThread, OffsetScanThread):
32+
class Program(RenameThread, RSSThread, OffsetScanThread, CalendarRefreshThread):
3333
def __init__(self):
3434
super().__init__()
3535
self._startup_done = False
@@ -101,6 +101,8 @@ async def start(self):
101101
self.rss_start()
102102
# Start offset scanner for background mismatch detection
103103
self.scan_start()
104+
# Start calendar refresh (every 24 hours)
105+
self.calendar_start()
104106
logger.info("Program running.")
105107
return ResponseModel(
106108
status=True,
@@ -115,6 +117,7 @@ async def stop(self):
115117
await self.rename_stop()
116118
await self.rss_stop()
117119
await self.scan_stop()
120+
await self.calendar_stop()
118121
return ResponseModel(
119122
status=True,
120123
status_code=200,

backend/src/module/core/sub_thread.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from module.conf import settings
55
from module.downloader import DownloadClient
6-
from module.manager import Renamer, eps_complete
6+
from module.manager import Renamer, TorrentManager, eps_complete
77
from module.notification import PostNotification
88
from module.rss import RSSAnalyser, RSSEngine
99

@@ -12,6 +12,9 @@
1212

1313
logger = logging.getLogger(__name__)
1414

15+
# Calendar refresh interval in seconds (24 hours)
16+
CALENDAR_REFRESH_INTERVAL = 24 * 60 * 60
17+
1518

1619
class RSSThread(ProgramStatus):
1720
def __init__(self):
@@ -134,3 +137,51 @@ async def scan_stop(self):
134137
pass
135138
self._scan_task = None
136139
logger.info("[OffsetScanThread] Stopped offset scanner")
140+
141+
142+
class CalendarRefreshThread(ProgramStatus):
143+
"""Background thread for refreshing bangumi calendar data every 24 hours."""
144+
145+
def __init__(self):
146+
super().__init__()
147+
self._calendar_task: asyncio.Task | None = None
148+
149+
async def calendar_loop(self):
150+
# Initial delay to let the system stabilize
151+
await asyncio.sleep(120)
152+
153+
while not self.stop_event.is_set():
154+
try:
155+
with TorrentManager() as manager:
156+
resp = await manager.refresh_calendar()
157+
if resp.status:
158+
logger.info("[CalendarRefreshThread] Calendar refresh completed")
159+
else:
160+
logger.warning(
161+
f"[CalendarRefreshThread] Calendar refresh failed: {resp.msg_en}"
162+
)
163+
except Exception as e:
164+
logger.error(f"[CalendarRefreshThread] Error during refresh: {e}")
165+
166+
try:
167+
await asyncio.wait_for(
168+
self.stop_event.wait(),
169+
timeout=CALENDAR_REFRESH_INTERVAL,
170+
)
171+
except asyncio.TimeoutError:
172+
pass
173+
174+
def calendar_start(self):
175+
self._calendar_task = asyncio.create_task(self.calendar_loop())
176+
logger.info("[CalendarRefreshThread] Started calendar refresh (every 24h)")
177+
178+
async def calendar_stop(self):
179+
if self._calendar_task and not self._calendar_task.done():
180+
self.stop_event.set()
181+
self._calendar_task.cancel()
182+
try:
183+
await self._calendar_task
184+
except asyncio.CancelledError:
185+
pass
186+
self._calendar_task = None
187+
logger.info("[CalendarRefreshThread] Stopped calendar refresh")

backend/src/module/downloader/client/qb_downloader.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,23 @@ async def torrents_rename_file(self, torrent_hash, old_path, new_path) -> bool:
202202
if resp.status_code == 409:
203203
logger.debug(f"Conflict409Error: {old_path} >> {new_path}")
204204
return False
205-
return resp.status_code == 200
205+
if resp.status_code != 200:
206+
return False
207+
208+
# Verify the rename actually happened by checking file list
209+
# qBittorrent can return 200 but delay the actual rename (e.g., while seeding)
210+
await asyncio.sleep(0.5) # Brief delay to allow qBittorrent to process
211+
files = await self.torrents_files(torrent_hash)
212+
for f in files:
213+
if f.get("name") == new_path:
214+
return True
215+
if f.get("name") == old_path:
216+
# File still has old name - rename didn't actually happen
217+
logger.debug(
218+
f"[Downloader] Rename API returned 200 but file unchanged: {old_path}"
219+
)
220+
return False
221+
return True # new_path found or old_path not found
206222
except (httpx.ConnectError, httpx.RequestError, httpx.TimeoutException) as e:
207223
logger.warning(f"[Downloader] Failed to rename file {old_path}: {e}")
208224
return False

backend/src/module/downloader/download_client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,8 @@ async def get_torrents_by_tag(self, tag: str) -> list[dict]:
231231
if hasattr(self.client, "get_torrents_by_tag"):
232232
return await self.client.get_torrents_by_tag(tag)
233233
return []
234+
235+
async def add_tag(self, torrent_hash: str, tag: str):
236+
"""Add a tag to a torrent."""
237+
await self.client.add_tag(torrent_hash, tag)
238+
logger.debug(f"[Downloader] Added tag '{tag}' to torrent {torrent_hash[:8]}...")

backend/src/module/manager/renamer.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import logging
33
import re
4+
import time
45

56
from module.conf import settings
67
from module.database import Database
@@ -10,6 +11,12 @@
1011

1112
logger = logging.getLogger(__name__)
1213

14+
# Module-level cache to track pending renames that qBittorrent hasn't processed yet
15+
# Key: (torrent_hash, old_path, new_path), Value: timestamp of last attempt
16+
# This prevents spamming the same rename when qBittorrent returns 200 but doesn't actually rename
17+
_pending_renames: dict[tuple[str, str, str], float] = {}
18+
_PENDING_RENAME_COOLDOWN = 300 # 5 minutes cooldown before retrying same rename
19+
1320

1421
class Renamer(DownloadClient):
1522
def __init__(self):
@@ -100,9 +107,21 @@ async def rename_file(
100107
)
101108
if media_path != new_path:
102109
if new_path not in self.check_pool.keys():
110+
# Check if this rename was recently attempted but didn't take effect
111+
# (qBittorrent can return 200 but delay actual rename while seeding)
112+
pending_key = (_hash, media_path, new_path)
113+
last_attempt = _pending_renames.get(pending_key)
114+
if last_attempt and (time.time() - last_attempt) < _PENDING_RENAME_COOLDOWN:
115+
logger.debug(
116+
f"[Renamer] Skipping rename (pending cooldown): {media_path}"
117+
)
118+
return None
119+
103120
if await self.rename_torrent_file(
104121
_hash=_hash, old_path=media_path, new_path=new_path
105122
):
123+
# Rename verified successful, remove from pending cache
124+
_pending_renames.pop(pending_key, None)
106125
# Season comes from folder which already has offset applied
107126
# Only apply episode offset
108127
original_ep = int(ep.episode)
@@ -114,6 +133,18 @@ async def rename_file(
114133
season=ep.season,
115134
episode=adjusted_episode,
116135
)
136+
else:
137+
# Rename API returned success but file wasn't actually renamed
138+
# Add to pending cache to avoid spamming
139+
_pending_renames[pending_key] = time.time()
140+
# Clean up old entries from cache
141+
current_time = time.time()
142+
expired_keys = [
143+
k for k, v in _pending_renames.items()
144+
if current_time - v > _PENDING_RENAME_COOLDOWN * 2
145+
]
146+
for k in expired_keys:
147+
_pending_renames.pop(k, None)
117148
else:
118149
logger.warning(f"[Renamer] {media_path} parse failed")
119150
if settings.bangumi_manage.remove_bad_torrent:
@@ -263,8 +294,9 @@ def _lookup_offsets(
263294
# Then try matching by torrent name
264295
bangumi = db.bangumi.match_torrent(torrent_name)
265296
if bangumi:
266-
logger.debug(
267-
f"[Renamer] Found offsets via torrent name match: ep={bangumi.episode_offset}, season={bangumi.season_offset}"
297+
logger.info(
298+
f"[Renamer] Matched bangumi '{bangumi.official_title}' (id={bangumi.id}) via name, "
299+
f"offsets: ep={bangumi.episode_offset}, season={bangumi.season_offset}"
268300
)
269301
return bangumi.episode_offset, bangumi.season_offset
270302

@@ -275,14 +307,15 @@ def _lookup_offsets(
275307
# Try with normalized path if exact match failed
276308
bangumi = db.bangumi.match_by_save_path(normalized_save_path)
277309
if bangumi:
278-
logger.debug(
279-
f"[Renamer] Found offsets via save_path match: ep={bangumi.episode_offset}, season={bangumi.season_offset}"
310+
logger.info(
311+
f"[Renamer] Matched bangumi '{bangumi.official_title}' (id={bangumi.id}) via save_path, "
312+
f"offsets: ep={bangumi.episode_offset}, season={bangumi.season_offset}"
280313
)
281314
return bangumi.episode_offset, bangumi.season_offset
282315

283-
logger.debug(
284-
f"[Renamer] No bangumi found for torrent: hash={torrent_hash[:8] if torrent_hash else 'N/A'}, "
285-
f"name={torrent_name[:50] if torrent_name else 'N/A'}..., path={save_path}"
316+
logger.info(
317+
f"[Renamer] No bangumi match for torrent (using offset=0): "
318+
f"name={torrent_name[:60] if torrent_name else 'N/A'}..."
286319
)
287320
except Exception as e:
288321
logger.debug(f"[Renamer] Could not lookup offsets for {save_path}: {e}")

0 commit comments

Comments
 (0)