Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
# [3.2.0-beta.6] - 2026-01-25

## Backend

### Features

- 新增番剧归档功能:支持手动归档/取消归档,已完结番剧自动归档
- 新增剧集偏移自动检测:根据 TMDB 季度集数自动计算偏移量(如 S02E18 → S02E05)
- TMDB 解析器新增 `series_status` 和 `season_episode_counts` 字段提取
- 新增数据库迁移 v4:为 `bangumi` 表添加 `archived` 字段
- 新增 API 端点:
- `PATCH /bangumi/archive/{id}` - 归档番剧
- `PATCH /bangumi/unarchive/{id}` - 取消归档
- `GET /bangumi/refresh/metadata` - 刷新元数据并自动归档已完结番剧
- `GET /bangumi/suggest-offset/{id}` - 获取建议的剧集偏移量
- 重命名模块支持从数据库查询偏移量并应用到文件名

## Frontend

### Features

- 番剧列表页新增可折叠的「已归档」分区
- 规则编辑弹窗新增归档/取消归档按钮
- 规则编辑器新增剧集偏移字段和「自动检测」按钮
- 新增 i18n 翻译(中文/英文)

---

# [3.2.0-beta.5] - 2026-01-24

## Backend
Expand Down
54 changes: 54 additions & 0 deletions backend/src/module/api/bangumi.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from module.manager import TorrentManager
from module.models import APIResponse, Bangumi, BangumiUpdate
from module.security.api import UNAUTHORIZED, get_current_user

from .response import u_response


class OffsetSuggestion(BaseModel):
suggested_offset: int
reason: str

router = APIRouter(prefix="/bangumi", tags=["bangumi"])


Expand Down Expand Up @@ -148,3 +154,51 @@ async def reset_all():
status_code=200,
content={"msg_en": "Reset all rules successfully.", "msg_zh": "重置所有规则成功。"},
)


@router.patch(
path="/archive/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def archive_rule(bangumi_id: int):
"""Archive a bangumi."""
with TorrentManager() as manager:
resp = manager.archive_rule(bangumi_id)
return u_response(resp)


@router.patch(
path="/unarchive/{bangumi_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def unarchive_rule(bangumi_id: int):
"""Unarchive a bangumi."""
with TorrentManager() as manager:
resp = manager.unarchive_rule(bangumi_id)
return u_response(resp)


@router.get(
path="/refresh/metadata",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def refresh_metadata():
"""Refresh TMDB metadata and auto-archive ended series."""
with TorrentManager() as manager:
resp = await manager.refresh_metadata()
return u_response(resp)


@router.get(
path="/suggest-offset/{bangumi_id}",
response_model=OffsetSuggestion,
dependencies=[Depends(get_current_user)],
)
async def suggest_offset(bangumi_id: int):
"""Suggest offset based on TMDB episode counts."""
with TorrentManager() as manager:
resp = await manager.suggest_offset(bangumi_id)
return resp
46 changes: 42 additions & 4 deletions backend/src/module/database/bangumi.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,17 @@ def match_list(self, torrent_list: list, rss_link: str) -> list:
return unmatched

def match_torrent(self, torrent_name: str) -> Optional[Bangumi]:
statement = select(Bangumi).where(
and_(
func.instr(torrent_name, Bangumi.title_raw) > 0,
Bangumi.deleted == false(),
statement = (
select(Bangumi)
.where(
and_(
func.instr(torrent_name, Bangumi.title_raw) > 0,
Bangumi.deleted == false(),
)
)
# Prefer longer title_raw matches (more specific)
.order_by(func.length(Bangumi.title_raw).desc())
.limit(1)
)
result = self.session.execute(statement)
return result.scalar_one_or_none()
Expand Down Expand Up @@ -213,3 +219,35 @@ def search_rss(self, rss_link: str) -> list[Bangumi]:
statement = select(Bangumi).where(func.instr(rss_link, Bangumi.rss_link) > 0)
result = self.session.execute(statement)
return list(result.scalars().all())

def archive_one(self, _id: int) -> bool:
"""Set archived=True for the given bangumi."""
bangumi = self.session.get(Bangumi, _id)
if not bangumi:
logger.warning(f"[Database] Cannot archive bangumi id: {_id}, not found.")
return False
bangumi.archived = True
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Archived bangumi id: {_id}.")
return True

def unarchive_one(self, _id: int) -> bool:
"""Set archived=False for the given bangumi."""
bangumi = self.session.get(Bangumi, _id)
if not bangumi:
logger.warning(f"[Database] Cannot unarchive bangumi id: {_id}, not found.")
return False
bangumi.archived = False
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Unarchived bangumi id: {_id}.")
return True

def match_by_save_path(self, save_path: str) -> Optional[Bangumi]:
"""Find bangumi by save_path to get offset."""
statement = select(Bangumi).where(Bangumi.save_path == save_path)
result = self.session.execute(statement)
return result.scalar_one_or_none()
11 changes: 10 additions & 1 deletion backend/src/module/database/combine.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
logger = logging.getLogger(__name__)

# Increment this when adding new migrations to MIGRATIONS list.
CURRENT_SCHEMA_VERSION = 3
CURRENT_SCHEMA_VERSION = 4

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


Expand Down Expand Up @@ -125,6 +130,10 @@ def run_migrations(self):
needs_run = False
if version == 3 and "passkey" in tables:
needs_run = False
if "bangumi" in tables and version == 4:
columns = [col["name"] for col in inspector.get_columns("bangumi")]
if "archived" in columns:
needs_run = False
if needs_run:
with self.engine.connect() as conn:
for stmt in statements:
Expand Down
44 changes: 36 additions & 8 deletions backend/src/module/manager/renamer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re

from module.conf import settings
from module.database import Database
from module.downloader import DownloadClient
from module.models import EpisodeFile, Notification, SubtitleFile
from module.parser import TitleParser
Expand All @@ -26,12 +27,18 @@ def print_result(torrent_count, rename_count):

@staticmethod
def gen_path(
file_info: EpisodeFile | SubtitleFile, bangumi_name: str, method: str
file_info: EpisodeFile | SubtitleFile,
bangumi_name: str,
method: str,
offset: int = 0,
) -> str:
season = f"0{file_info.season}" if file_info.season < 10 else file_info.season
episode = (
f"0{file_info.episode}" if file_info.episode < 10 else file_info.episode
)
# Apply offset (offset is stored as the value to ADD)
adjusted_episode = int(file_info.episode) + offset
if adjusted_episode < 1:
adjusted_episode = int(file_info.episode) # Safety: don't go below 1
logger.warning(f"[Renamer] Offset {offset} would result in negative episode, ignoring")
episode = f"0{adjusted_episode}" if adjusted_episode < 10 else adjusted_episode
if method == "none" or method == "subtitle_none":
return file_info.media_path
elif method == "pn":
Expand All @@ -57,6 +64,7 @@ async def rename_file(
method: str,
season: int,
_hash: str,
offset: int = 0,
**kwargs,
):
ep = self._parser.torrent_parser(
Expand All @@ -65,16 +73,20 @@ async def rename_file(
season=season,
)
if ep:
new_path = self.gen_path(ep, bangumi_name, method=method)
new_path = self.gen_path(ep, bangumi_name, method=method, offset=offset)
if media_path != new_path:
if new_path not in self.check_pool.keys():
if await self.rename_torrent_file(
_hash=_hash, old_path=media_path, new_path=new_path
):
# Return adjusted episode number for notification
adjusted_episode = int(ep.episode) + offset
if adjusted_episode < 1:
adjusted_episode = int(ep.episode)
return Notification(
official_title=bangumi_name,
season=ep.season,
episode=ep.episode,
episode=adjusted_episode,
)
else:
logger.warning(f"[Renamer] {media_path} parse failed")
Expand All @@ -89,6 +101,7 @@ async def rename_collection(
season: int,
method: str,
_hash: str,
offset: int = 0,
**kwargs,
):
for media_path in media_list:
Expand All @@ -98,7 +111,7 @@ async def rename_collection(
season=season,
)
if ep:
new_path = self.gen_path(ep, bangumi_name, method=method)
new_path = self.gen_path(ep, bangumi_name, method=method, offset=offset)
if media_path != new_path:
renamed = await self.rename_torrent_file(
_hash=_hash, old_path=media_path, new_path=new_path
Expand All @@ -118,6 +131,7 @@ async def rename_subtitles(
season: int,
method: str,
_hash,
offset: int = 0,
**kwargs,
):
method = "subtitle_" + method
Expand All @@ -129,14 +143,25 @@ async def rename_subtitles(
file_type="subtitle",
)
if sub:
new_path = self.gen_path(sub, bangumi_name, method=method)
new_path = self.gen_path(sub, bangumi_name, method=method, offset=offset)
if subtitle_path != new_path:
renamed = await self.rename_torrent_file(
_hash=_hash, old_path=subtitle_path, new_path=new_path
)
if not renamed:
logger.warning(f"[Renamer] {subtitle_path} rename failed")

def _lookup_offset_by_path(self, save_path: str) -> int:
"""Look up the offset for a bangumi by its save_path."""
try:
with Database() as db:
bangumi = db.bangumi.match_by_save_path(save_path)
if bangumi:
return bangumi.offset
except Exception as e:
logger.debug(f"[Renamer] Could not lookup offset for {save_path}: {e}")
return 0

async def rename(self) -> list[Notification]:
# Get torrent info
logger.debug("[Renamer] Start rename process.")
Expand All @@ -153,12 +178,15 @@ async def rename(self) -> list[Notification]:
save_path = info["save_path"]
media_list, subtitle_list = self.check_files(files)
bangumi_name, season = self._path_to_bangumi(save_path)
# Look up offset from database
offset = self._lookup_offset_by_path(save_path)
kwargs = {
"torrent_name": torrent_name,
"bangumi_name": bangumi_name,
"method": rename_method,
"season": season,
"_hash": torrent_hash,
"offset": offset,
}
# Rename single media file
if len(media_list) == 1:
Expand Down
Loading