Skip to content

Commit dcc60ce

Browse files
EstrellaXDclaudehappy-otter
committed
fix(db,downloader): fix server error on upgrade from 3.1.x to 3.2.x (#956)
- Fix 'dict' object has no attribute 'files' in renamer by using dict access for qBittorrent API responses and fetching file lists via separate torrents/files endpoint - Replace version-file-based migration with schema_version table to reliably track and apply database migrations on every startup - Add air_weekday column migration as versioned migration entry - Add torrents_files method to QbDownloader and Aria2Downloader Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent e9367e3 commit dcc60ce

12 files changed

Lines changed: 185 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
# [3.2.0-beta.2] - 2026-01-24
2+
3+
## Backend
4+
5+
### Bugfixes
6+
7+
- 修复从 3.1.x 升级后数据库缺少 `air_weekday` 列导致服务器错误的问题 (#956)
8+
- 修复重命名模块中 `'dict' object has no attribute 'files'` 的错误
9+
- 新增 `schema_version` 表追踪数据库版本,确保迁移可靠执行
10+
- 修复 qBittorrent 下载器中缺少 `torrents_files` API 调用的问题
11+
12+
### Changes
13+
14+
- 数据库迁移机制重构:使用 `schema_version` 表替代仅依赖应用版本号的迁移策略
15+
- 启动时始终检查并执行未完成的迁移,防止迁移中断后无法恢复
16+
17+
---
18+
119
# [3.1] - 2023-08
220

321
- 合并了后端和前端仓库,优化了项目目录

backend/src/module/core/program.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
first_run,
1010
from_30_to_31,
1111
from_31_to_32,
12+
run_migrations,
1213
start_up,
1314
)
1415

@@ -58,6 +59,10 @@ async def startup(self):
5859
logger.info("[Core] Database migrated from 3.0 to 3.1.")
5960
await from_31_to_32()
6061
logger.info("[Core] Database updated.")
62+
else:
63+
# Always check schema version and run pending migrations,
64+
# in case a previous migration was interrupted or failed.
65+
run_migrations()
6166
if not self.img_cache:
6267
logger.info("[Core] No image cache exists, create image cache.")
6368
await cache_image()

backend/src/module/database/combine.py

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@
1414

1515
logger = logging.getLogger(__name__)
1616

17+
# Increment this when adding new migrations to MIGRATIONS list.
18+
CURRENT_SCHEMA_VERSION = 1
19+
20+
# Each migration is a tuple of (version, description, list of SQL statements).
21+
# Migrations are applied in order. A migration at index i brings the schema
22+
# from version i to version i+1.
23+
MIGRATIONS = [
24+
(
25+
1,
26+
"add air_weekday column to bangumi",
27+
["ALTER TABLE bangumi ADD COLUMN air_weekday INTEGER"],
28+
),
29+
]
30+
1731

1832
class Database(Session):
1933
def __init__(self, engine=e):
@@ -26,20 +40,64 @@ def __init__(self, engine=e):
2640

2741
def create_table(self):
2842
SQLModel.metadata.create_all(self.engine)
29-
self._migrate_columns()
43+
self._ensure_schema_version_table()
44+
45+
def _ensure_schema_version_table(self):
46+
"""Create the schema_version table if it doesn't exist."""
47+
with self.engine.connect() as conn:
48+
conn.execute(text(
49+
"CREATE TABLE IF NOT EXISTS schema_version ("
50+
" id INTEGER PRIMARY KEY,"
51+
" version INTEGER NOT NULL"
52+
")"
53+
))
54+
conn.commit()
55+
56+
def _get_schema_version(self) -> int:
57+
"""Get the current schema version from the database."""
58+
inspector = inspect(self.engine)
59+
if "schema_version" not in inspector.get_table_names():
60+
return 0
61+
with self.engine.connect() as conn:
62+
result = conn.execute(text("SELECT version FROM schema_version WHERE id = 1"))
63+
row = result.fetchone()
64+
return row[0] if row else 0
65+
66+
def _set_schema_version(self, version: int):
67+
"""Update the schema version in the database."""
68+
with self.engine.connect() as conn:
69+
conn.execute(text(
70+
"INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, :version)"
71+
), {"version": version})
72+
conn.commit()
3073

31-
def _migrate_columns(self):
32-
"""Add new columns to existing tables if they don't exist."""
74+
def run_migrations(self):
75+
"""Run pending schema migrations based on the stored schema version."""
76+
self._ensure_schema_version_table()
77+
current = self._get_schema_version()
78+
if current >= CURRENT_SCHEMA_VERSION:
79+
return
3380
inspector = inspect(self.engine)
34-
if "bangumi" in inspector.get_table_names():
35-
columns = [col["name"] for col in inspector.get_columns("bangumi")]
36-
if "air_weekday" not in columns:
81+
tables = inspector.get_table_names()
82+
for version, description, statements in MIGRATIONS:
83+
if version <= current:
84+
continue
85+
# Check if migration is actually needed (column may already exist)
86+
needs_run = True
87+
if "bangumi" in tables and version == 1:
88+
columns = [col["name"] for col in inspector.get_columns("bangumi")]
89+
if "air_weekday" in columns:
90+
needs_run = False
91+
if needs_run:
3792
with self.engine.connect() as conn:
38-
conn.execute(
39-
text("ALTER TABLE bangumi ADD COLUMN air_weekday INTEGER")
40-
)
93+
for stmt in statements:
94+
conn.execute(text(stmt))
4195
conn.commit()
42-
logger.info("[Database] Migrated: added air_weekday column to bangumi table.")
96+
logger.info(f"[Database] Migration v{version}: {description}")
97+
else:
98+
logger.debug(f"[Database] Migration v{version} skipped (already applied): {description}")
99+
self._set_schema_version(CURRENT_SCHEMA_VERSION)
100+
logger.info(f"[Database] Schema version is now {CURRENT_SCHEMA_VERSION}.")
43101

44102
def drop_table(self):
45103
SQLModel.metadata.drop_all(self.engine)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ async def logout(self):
5454
await self._client.aclose()
5555
self._client = None
5656

57+
async def torrents_files(self, torrent_hash: str):
58+
return []
59+
5760
async def add_torrents(self, torrent_urls, torrent_files, save_path, category):
5861
import base64
5962
options = {"dir": save_path}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ async def torrents_info(self, status_filter, category, tag=None):
107107
resp = await self._client.get(self._url("torrents/info"), params=params)
108108
return resp.json()
109109

110+
@qb_connect_failed_wait
111+
async def torrents_files(self, torrent_hash: str):
112+
resp = await self._client.get(
113+
self._url("torrents/files"), params={"hash": torrent_hash}
114+
)
115+
return resp.json()
116+
110117
async def add_torrents(self, torrent_urls, torrent_files, save_path, category):
111118
data = {
112119
"savepath": save_path,

backend/src/module/downloader/download_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ async def get_torrent_info(self, category="Bangumi", status_filter="completed",
107107
status_filter=status_filter, category=category, tag=tag
108108
)
109109

110+
async def get_torrent_files(self, torrent_hash: str):
111+
return await self.client.torrents_files(torrent_hash=torrent_hash)
112+
110113
async def rename_torrent_file(self, _hash, old_path, new_path) -> bool:
111114
logger.info(f"{old_path} >> {new_path}")
112115
return await self.client.torrents_rename_file(

backend/src/module/downloader/path.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ def __init__(self):
1818
pass
1919

2020
@staticmethod
21-
def check_files(info):
21+
def check_files(files: list[dict]):
2222
media_list = []
2323
subtitle_list = []
24-
for f in info.files:
25-
file_name = f.name
24+
for f in files:
25+
file_name = f["name"]
2626
suffix = Path(file_name).suffix
2727
if suffix.lower() in [".mp4", ".mkv"]:
2828
media_list.append(file_name)

backend/src/module/manager/renamer.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,18 @@ async def rename(self) -> list[Notification]:
143143
torrents_info = await self.get_torrent_info()
144144
renamed_info: list[Notification] = []
145145
for info in torrents_info:
146-
media_list, subtitle_list = self.check_files(info)
147-
bangumi_name, season = self._path_to_bangumi(info.save_path)
146+
torrent_hash = info["hash"]
147+
torrent_name = info["name"]
148+
save_path = info["save_path"]
149+
files = await self.get_torrent_files(torrent_hash)
150+
media_list, subtitle_list = self.check_files(files)
151+
bangumi_name, season = self._path_to_bangumi(save_path)
148152
kwargs = {
149-
"torrent_name": info.name,
153+
"torrent_name": torrent_name,
150154
"bangumi_name": bangumi_name,
151155
"method": rename_method,
152156
"season": season,
153-
"_hash": info.hash,
157+
"_hash": torrent_hash,
154158
}
155159
# Rename single media file
156160
if len(media_list) == 1:
@@ -166,9 +170,9 @@ async def rename(self) -> list[Notification]:
166170
await self.rename_collection(media_list=media_list, **kwargs)
167171
if len(subtitle_list) > 0:
168172
await self.rename_subtitles(subtitle_list=subtitle_list, **kwargs)
169-
await self.set_category(info.hash, "BangumiCollection")
173+
await self.set_category(torrent_hash, "BangumiCollection")
170174
else:
171-
logger.warning(f"[Renamer] {info.name} has no media file")
175+
logger.warning(f"[Renamer] {torrent_name} has no media file")
172176
logger.debug("[Renamer] Rename process finished.")
173177
return renamed_info
174178

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .cross_version import cache_image, from_30_to_31, from_31_to_32
1+
from .cross_version import cache_image, from_30_to_31, from_31_to_32, run_migrations
22
from .data_migration import data_migration
33
from .startup import first_run, start_up
44
from .version_check import version_check

backend/src/module/update/cross_version.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,17 @@ async def from_30_to_31():
3838
async def from_31_to_32():
3939
"""Migrate database schema from 3.1.x to 3.2.x."""
4040
with RSSEngine() as db:
41-
db.create_table() # Handles adding new columns (e.g., air_weekday)
41+
db.create_table()
42+
db.run_migrations()
4243
logger.info("[Migration] 3.1 -> 3.2 migration completed.")
4344

4445

46+
def run_migrations():
47+
"""Check schema version and run any pending migrations."""
48+
with RSSEngine() as db:
49+
db.run_migrations()
50+
51+
4552
async def cache_image():
4653
with RSSEngine() as db:
4754
bangumis = db.bangumi.search_all()

0 commit comments

Comments
 (0)