Skip to content

Commit 55b15ea

Browse files
EstrellaXDclaude
andauthored
feat: add bangumi archive and episode offset features (#958)
* feat: add bangumi archive and episode offset features Archive Feature: - Add archived field to Bangumi model with database migration (v4) - Add archive/unarchive API endpoints (PATCH /bangumi/archive/{id}) - Add auto-archive for ended series via TMDB metadata refresh - Add collapsible archived section in UI with visual styling - Add archive/unarchive button in edit rule popup Episode Offset Feature: - Extract series_status and season_episode_counts from TMDB API - Add suggest-offset API endpoint with auto-detection logic - Apply offset in renamer gen_path() for episode numbering - Add offset field with "Auto Detect" button in rule editor - Look up offset from database when renaming files The offset auto-detection calculates the sum of episodes from all previous seasons (e.g., if S01 has 13 episodes, S02E18 → S02E05 with offset=-13). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add changelog for bangumi archive and episode offset features Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ce5b23e commit 55b15ea

17 files changed

Lines changed: 787 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,31 @@
1+
# [3.2.0-beta.6] - 2026-01-25
2+
3+
## Backend
4+
5+
### Features
6+
7+
- 新增番剧归档功能:支持手动归档/取消归档,已完结番剧自动归档
8+
- 新增剧集偏移自动检测:根据 TMDB 季度集数自动计算偏移量(如 S02E18 → S02E05)
9+
- TMDB 解析器新增 `series_status``season_episode_counts` 字段提取
10+
- 新增数据库迁移 v4:为 `bangumi` 表添加 `archived` 字段
11+
- 新增 API 端点:
12+
- `PATCH /bangumi/archive/{id}` - 归档番剧
13+
- `PATCH /bangumi/unarchive/{id}` - 取消归档
14+
- `GET /bangumi/refresh/metadata` - 刷新元数据并自动归档已完结番剧
15+
- `GET /bangumi/suggest-offset/{id}` - 获取建议的剧集偏移量
16+
- 重命名模块支持从数据库查询偏移量并应用到文件名
17+
18+
## Frontend
19+
20+
### Features
21+
22+
- 番剧列表页新增可折叠的「已归档」分区
23+
- 规则编辑弹窗新增归档/取消归档按钮
24+
- 规则编辑器新增剧集偏移字段和「自动检测」按钮
25+
- 新增 i18n 翻译(中文/英文)
26+
27+
---
28+
129
# [3.2.0-beta.5] - 2026-01-24
230

331
## Backend

backend/src/module/api/bangumi.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
from fastapi import APIRouter, Depends
22
from fastapi.responses import JSONResponse
3+
from pydantic import BaseModel
34

45
from module.manager import TorrentManager
56
from module.models import APIResponse, Bangumi, BangumiUpdate
67
from module.security.api import UNAUTHORIZED, get_current_user
78

89
from .response import u_response
910

11+
12+
class OffsetSuggestion(BaseModel):
13+
suggested_offset: int
14+
reason: str
15+
1016
router = APIRouter(prefix="/bangumi", tags=["bangumi"])
1117

1218

@@ -148,3 +154,51 @@ async def reset_all():
148154
status_code=200,
149155
content={"msg_en": "Reset all rules successfully.", "msg_zh": "重置所有规则成功。"},
150156
)
157+
158+
159+
@router.patch(
160+
path="/archive/{bangumi_id}",
161+
response_model=APIResponse,
162+
dependencies=[Depends(get_current_user)],
163+
)
164+
async def archive_rule(bangumi_id: int):
165+
"""Archive a bangumi."""
166+
with TorrentManager() as manager:
167+
resp = manager.archive_rule(bangumi_id)
168+
return u_response(resp)
169+
170+
171+
@router.patch(
172+
path="/unarchive/{bangumi_id}",
173+
response_model=APIResponse,
174+
dependencies=[Depends(get_current_user)],
175+
)
176+
async def unarchive_rule(bangumi_id: int):
177+
"""Unarchive a bangumi."""
178+
with TorrentManager() as manager:
179+
resp = manager.unarchive_rule(bangumi_id)
180+
return u_response(resp)
181+
182+
183+
@router.get(
184+
path="/refresh/metadata",
185+
response_model=APIResponse,
186+
dependencies=[Depends(get_current_user)],
187+
)
188+
async def refresh_metadata():
189+
"""Refresh TMDB metadata and auto-archive ended series."""
190+
with TorrentManager() as manager:
191+
resp = await manager.refresh_metadata()
192+
return u_response(resp)
193+
194+
195+
@router.get(
196+
path="/suggest-offset/{bangumi_id}",
197+
response_model=OffsetSuggestion,
198+
dependencies=[Depends(get_current_user)],
199+
)
200+
async def suggest_offset(bangumi_id: int):
201+
"""Suggest offset based on TMDB episode counts."""
202+
with TorrentManager() as manager:
203+
resp = await manager.suggest_offset(bangumi_id)
204+
return resp

backend/src/module/database/bangumi.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,17 @@ def match_list(self, torrent_list: list, rss_link: str) -> list:
172172
return unmatched
173173

174174
def match_torrent(self, torrent_name: str) -> Optional[Bangumi]:
175-
statement = select(Bangumi).where(
176-
and_(
177-
func.instr(torrent_name, Bangumi.title_raw) > 0,
178-
Bangumi.deleted == false(),
175+
statement = (
176+
select(Bangumi)
177+
.where(
178+
and_(
179+
func.instr(torrent_name, Bangumi.title_raw) > 0,
180+
Bangumi.deleted == false(),
181+
)
179182
)
183+
# Prefer longer title_raw matches (more specific)
184+
.order_by(func.length(Bangumi.title_raw).desc())
185+
.limit(1)
180186
)
181187
result = self.session.execute(statement)
182188
return result.scalar_one_or_none()
@@ -213,3 +219,35 @@ def search_rss(self, rss_link: str) -> list[Bangumi]:
213219
statement = select(Bangumi).where(func.instr(rss_link, Bangumi.rss_link) > 0)
214220
result = self.session.execute(statement)
215221
return list(result.scalars().all())
222+
223+
def archive_one(self, _id: int) -> bool:
224+
"""Set archived=True for the given bangumi."""
225+
bangumi = self.session.get(Bangumi, _id)
226+
if not bangumi:
227+
logger.warning(f"[Database] Cannot archive bangumi id: {_id}, not found.")
228+
return False
229+
bangumi.archived = True
230+
self.session.add(bangumi)
231+
self.session.commit()
232+
_invalidate_bangumi_cache()
233+
logger.debug(f"[Database] Archived bangumi id: {_id}.")
234+
return True
235+
236+
def unarchive_one(self, _id: int) -> bool:
237+
"""Set archived=False for the given bangumi."""
238+
bangumi = self.session.get(Bangumi, _id)
239+
if not bangumi:
240+
logger.warning(f"[Database] Cannot unarchive bangumi id: {_id}, not found.")
241+
return False
242+
bangumi.archived = False
243+
self.session.add(bangumi)
244+
self.session.commit()
245+
_invalidate_bangumi_cache()
246+
logger.debug(f"[Database] Unarchived bangumi id: {_id}.")
247+
return True
248+
249+
def match_by_save_path(self, save_path: str) -> Optional[Bangumi]:
250+
"""Find bangumi by save_path to get offset."""
251+
statement = select(Bangumi).where(Bangumi.save_path == save_path)
252+
result = self.session.execute(statement)
253+
return result.scalar_one_or_none()

backend/src/module/database/combine.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
logger = logging.getLogger(__name__)
1616

1717
# Increment this when adding new migrations to MIGRATIONS list.
18-
CURRENT_SCHEMA_VERSION = 3
18+
CURRENT_SCHEMA_VERSION = 4
1919

2020
# Each migration is a tuple of (version, description, list of SQL statements).
2121
# Migrations are applied in order. A migration at index i brings the schema
@@ -57,6 +57,11 @@
5757
"CREATE UNIQUE INDEX IF NOT EXISTS ix_passkey_credential_id ON passkey(credential_id)",
5858
],
5959
),
60+
(
61+
4,
62+
"add archived column to bangumi",
63+
["ALTER TABLE bangumi ADD COLUMN archived BOOLEAN DEFAULT 0"],
64+
),
6065
]
6166

6267

@@ -125,6 +130,10 @@ def run_migrations(self):
125130
needs_run = False
126131
if version == 3 and "passkey" in tables:
127132
needs_run = False
133+
if "bangumi" in tables and version == 4:
134+
columns = [col["name"] for col in inspector.get_columns("bangumi")]
135+
if "archived" in columns:
136+
needs_run = False
128137
if needs_run:
129138
with self.engine.connect() as conn:
130139
for stmt in statements:

backend/src/module/manager/renamer.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44

55
from module.conf import settings
6+
from module.database import Database
67
from module.downloader import DownloadClient
78
from module.models import EpisodeFile, Notification, SubtitleFile
89
from module.parser import TitleParser
@@ -26,12 +27,18 @@ def print_result(torrent_count, rename_count):
2627

2728
@staticmethod
2829
def gen_path(
29-
file_info: EpisodeFile | SubtitleFile, bangumi_name: str, method: str
30+
file_info: EpisodeFile | SubtitleFile,
31+
bangumi_name: str,
32+
method: str,
33+
offset: int = 0,
3034
) -> str:
3135
season = f"0{file_info.season}" if file_info.season < 10 else file_info.season
32-
episode = (
33-
f"0{file_info.episode}" if file_info.episode < 10 else file_info.episode
34-
)
36+
# Apply offset (offset is stored as the value to ADD)
37+
adjusted_episode = int(file_info.episode) + offset
38+
if adjusted_episode < 1:
39+
adjusted_episode = int(file_info.episode) # Safety: don't go below 1
40+
logger.warning(f"[Renamer] Offset {offset} would result in negative episode, ignoring")
41+
episode = f"0{adjusted_episode}" if adjusted_episode < 10 else adjusted_episode
3542
if method == "none" or method == "subtitle_none":
3643
return file_info.media_path
3744
elif method == "pn":
@@ -57,6 +64,7 @@ async def rename_file(
5764
method: str,
5865
season: int,
5966
_hash: str,
67+
offset: int = 0,
6068
**kwargs,
6169
):
6270
ep = self._parser.torrent_parser(
@@ -65,16 +73,20 @@ async def rename_file(
6573
season=season,
6674
)
6775
if ep:
68-
new_path = self.gen_path(ep, bangumi_name, method=method)
76+
new_path = self.gen_path(ep, bangumi_name, method=method, offset=offset)
6977
if media_path != new_path:
7078
if new_path not in self.check_pool.keys():
7179
if await self.rename_torrent_file(
7280
_hash=_hash, old_path=media_path, new_path=new_path
7381
):
82+
# Return adjusted episode number for notification
83+
adjusted_episode = int(ep.episode) + offset
84+
if adjusted_episode < 1:
85+
adjusted_episode = int(ep.episode)
7486
return Notification(
7587
official_title=bangumi_name,
7688
season=ep.season,
77-
episode=ep.episode,
89+
episode=adjusted_episode,
7890
)
7991
else:
8092
logger.warning(f"[Renamer] {media_path} parse failed")
@@ -89,6 +101,7 @@ async def rename_collection(
89101
season: int,
90102
method: str,
91103
_hash: str,
104+
offset: int = 0,
92105
**kwargs,
93106
):
94107
for media_path in media_list:
@@ -98,7 +111,7 @@ async def rename_collection(
98111
season=season,
99112
)
100113
if ep:
101-
new_path = self.gen_path(ep, bangumi_name, method=method)
114+
new_path = self.gen_path(ep, bangumi_name, method=method, offset=offset)
102115
if media_path != new_path:
103116
renamed = await self.rename_torrent_file(
104117
_hash=_hash, old_path=media_path, new_path=new_path
@@ -118,6 +131,7 @@ async def rename_subtitles(
118131
season: int,
119132
method: str,
120133
_hash,
134+
offset: int = 0,
121135
**kwargs,
122136
):
123137
method = "subtitle_" + method
@@ -129,14 +143,25 @@ async def rename_subtitles(
129143
file_type="subtitle",
130144
)
131145
if sub:
132-
new_path = self.gen_path(sub, bangumi_name, method=method)
146+
new_path = self.gen_path(sub, bangumi_name, method=method, offset=offset)
133147
if subtitle_path != new_path:
134148
renamed = await self.rename_torrent_file(
135149
_hash=_hash, old_path=subtitle_path, new_path=new_path
136150
)
137151
if not renamed:
138152
logger.warning(f"[Renamer] {subtitle_path} rename failed")
139153

154+
def _lookup_offset_by_path(self, save_path: str) -> int:
155+
"""Look up the offset for a bangumi by its save_path."""
156+
try:
157+
with Database() as db:
158+
bangumi = db.bangumi.match_by_save_path(save_path)
159+
if bangumi:
160+
return bangumi.offset
161+
except Exception as e:
162+
logger.debug(f"[Renamer] Could not lookup offset for {save_path}: {e}")
163+
return 0
164+
140165
async def rename(self) -> list[Notification]:
141166
# Get torrent info
142167
logger.debug("[Renamer] Start rename process.")
@@ -153,12 +178,15 @@ async def rename(self) -> list[Notification]:
153178
save_path = info["save_path"]
154179
media_list, subtitle_list = self.check_files(files)
155180
bangumi_name, season = self._path_to_bangumi(save_path)
181+
# Look up offset from database
182+
offset = self._lookup_offset_by_path(save_path)
156183
kwargs = {
157184
"torrent_name": torrent_name,
158185
"bangumi_name": bangumi_name,
159186
"method": rename_method,
160187
"season": season,
161188
"_hash": torrent_hash,
189+
"offset": offset,
162190
}
163191
# Rename single media file
164192
if len(media_list) == 1:

0 commit comments

Comments
 (0)