From dcc60ce9a5bf186d10768de79c497897a4032b03 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Sat, 24 Jan 2026 18:26:57 +0100 Subject: [PATCH] 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 Co-Authored-By: Happy --- CHANGELOG.md | 18 +++++ backend/src/module/core/program.py | 5 ++ backend/src/module/database/combine.py | 78 ++++++++++++++++--- .../downloader/client/aria2_downloader.py | 3 + .../module/downloader/client/qb_downloader.py | 7 ++ .../src/module/downloader/download_client.py | 3 + backend/src/module/downloader/path.py | 6 +- backend/src/module/manager/renamer.py | 16 ++-- backend/src/module/update/__init__.py | 2 +- backend/src/module/update/cross_version.py | 9 ++- backend/src/module/update/startup.py | 4 +- backend/src/test/test_migration.py | 59 +++++++++++++- 12 files changed, 185 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6834d93c..5c3c1f2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +# [3.2.0-beta.2] - 2026-01-24 + +## Backend + +### Bugfixes + +- 修复从 3.1.x 升级后数据库缺少 `air_weekday` 列导致服务器错误的问题 (#956) +- 修复重命名模块中 `'dict' object has no attribute 'files'` 的错误 +- 新增 `schema_version` 表追踪数据库版本,确保迁移可靠执行 +- 修复 qBittorrent 下载器中缺少 `torrents_files` API 调用的问题 + +### Changes + +- 数据库迁移机制重构:使用 `schema_version` 表替代仅依赖应用版本号的迁移策略 +- 启动时始终检查并执行未完成的迁移,防止迁移中断后无法恢复 + +--- + # [3.1] - 2023-08 - 合并了后端和前端仓库,优化了项目目录 diff --git a/backend/src/module/core/program.py b/backend/src/module/core/program.py index 81c138ca..28da2a88 100644 --- a/backend/src/module/core/program.py +++ b/backend/src/module/core/program.py @@ -9,6 +9,7 @@ from module.update import ( first_run, from_30_to_31, from_31_to_32, + run_migrations, start_up, ) @@ -58,6 +59,10 @@ class Program(RenameThread, RSSThread): logger.info("[Core] Database migrated from 3.0 to 3.1.") await from_31_to_32() logger.info("[Core] Database updated.") + else: + # Always check schema version and run pending migrations, + # in case a previous migration was interrupted or failed. + run_migrations() if not self.img_cache: logger.info("[Core] No image cache exists, create image cache.") await cache_image() diff --git a/backend/src/module/database/combine.py b/backend/src/module/database/combine.py index d91d0eda..da2aa8a8 100644 --- a/backend/src/module/database/combine.py +++ b/backend/src/module/database/combine.py @@ -14,6 +14,20 @@ from .user import UserDatabase logger = logging.getLogger(__name__) +# Increment this when adding new migrations to MIGRATIONS list. +CURRENT_SCHEMA_VERSION = 1 + +# 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 +# from version i to version i+1. +MIGRATIONS = [ + ( + 1, + "add air_weekday column to bangumi", + ["ALTER TABLE bangumi ADD COLUMN air_weekday INTEGER"], + ), +] + class Database(Session): def __init__(self, engine=e): @@ -26,20 +40,64 @@ class Database(Session): def create_table(self): SQLModel.metadata.create_all(self.engine) - self._migrate_columns() + self._ensure_schema_version_table() - def _migrate_columns(self): - """Add new columns to existing tables if they don't exist.""" + def _ensure_schema_version_table(self): + """Create the schema_version table if it doesn't exist.""" + with self.engine.connect() as conn: + conn.execute(text( + "CREATE TABLE IF NOT EXISTS schema_version (" + " id INTEGER PRIMARY KEY," + " version INTEGER NOT NULL" + ")" + )) + conn.commit() + + def _get_schema_version(self) -> int: + """Get the current schema version from the database.""" inspector = inspect(self.engine) - if "bangumi" in inspector.get_table_names(): - columns = [col["name"] for col in inspector.get_columns("bangumi")] - if "air_weekday" not in columns: + if "schema_version" not in inspector.get_table_names(): + return 0 + with self.engine.connect() as conn: + result = conn.execute(text("SELECT version FROM schema_version WHERE id = 1")) + row = result.fetchone() + return row[0] if row else 0 + + def _set_schema_version(self, version: int): + """Update the schema version in the database.""" + with self.engine.connect() as conn: + conn.execute(text( + "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, :version)" + ), {"version": version}) + conn.commit() + + def run_migrations(self): + """Run pending schema migrations based on the stored schema version.""" + self._ensure_schema_version_table() + current = self._get_schema_version() + if current >= CURRENT_SCHEMA_VERSION: + return + inspector = inspect(self.engine) + tables = inspector.get_table_names() + for version, description, statements in MIGRATIONS: + if version <= current: + continue + # Check if migration is actually needed (column may already exist) + needs_run = True + if "bangumi" in tables and version == 1: + columns = [col["name"] for col in inspector.get_columns("bangumi")] + if "air_weekday" in columns: + needs_run = False + if needs_run: with self.engine.connect() as conn: - conn.execute( - text("ALTER TABLE bangumi ADD COLUMN air_weekday INTEGER") - ) + for stmt in statements: + conn.execute(text(stmt)) conn.commit() - logger.info("[Database] Migrated: added air_weekday column to bangumi table.") + logger.info(f"[Database] Migration v{version}: {description}") + else: + logger.debug(f"[Database] Migration v{version} skipped (already applied): {description}") + self._set_schema_version(CURRENT_SCHEMA_VERSION) + logger.info(f"[Database] Schema version is now {CURRENT_SCHEMA_VERSION}.") def drop_table(self): SQLModel.metadata.drop_all(self.engine) diff --git a/backend/src/module/downloader/client/aria2_downloader.py b/backend/src/module/downloader/client/aria2_downloader.py index 2cfd65ae..05e53545 100644 --- a/backend/src/module/downloader/client/aria2_downloader.py +++ b/backend/src/module/downloader/client/aria2_downloader.py @@ -54,6 +54,9 @@ class Aria2Downloader: await self._client.aclose() self._client = None + async def torrents_files(self, torrent_hash: str): + return [] + async def add_torrents(self, torrent_urls, torrent_files, save_path, category): import base64 options = {"dir": save_path} diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index da5130b4..cbeff7b0 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -107,6 +107,13 @@ class QbDownloader: resp = await self._client.get(self._url("torrents/info"), params=params) return resp.json() + @qb_connect_failed_wait + async def torrents_files(self, torrent_hash: str): + resp = await self._client.get( + self._url("torrents/files"), params={"hash": torrent_hash} + ) + return resp.json() + async def add_torrents(self, torrent_urls, torrent_files, save_path, category): data = { "savepath": save_path, diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index f92aedc8..4d23ebf9 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -107,6 +107,9 @@ class DownloadClient(TorrentPath): status_filter=status_filter, category=category, tag=tag ) + async def get_torrent_files(self, torrent_hash: str): + return await self.client.torrents_files(torrent_hash=torrent_hash) + async def rename_torrent_file(self, _hash, old_path, new_path) -> bool: logger.info(f"{old_path} >> {new_path}") return await self.client.torrents_rename_file( diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index 07b7d7dc..b7739990 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -18,11 +18,11 @@ class TorrentPath: pass @staticmethod - def check_files(info): + def check_files(files: list[dict]): media_list = [] subtitle_list = [] - for f in info.files: - file_name = f.name + for f in files: + file_name = f["name"] suffix = Path(file_name).suffix if suffix.lower() in [".mp4", ".mkv"]: media_list.append(file_name) diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index cef9474a..e399c79e 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -143,14 +143,18 @@ class Renamer(DownloadClient): torrents_info = await self.get_torrent_info() renamed_info: list[Notification] = [] for info in torrents_info: - media_list, subtitle_list = self.check_files(info) - bangumi_name, season = self._path_to_bangumi(info.save_path) + torrent_hash = info["hash"] + torrent_name = info["name"] + save_path = info["save_path"] + files = await self.get_torrent_files(torrent_hash) + media_list, subtitle_list = self.check_files(files) + bangumi_name, season = self._path_to_bangumi(save_path) kwargs = { - "torrent_name": info.name, + "torrent_name": torrent_name, "bangumi_name": bangumi_name, "method": rename_method, "season": season, - "_hash": info.hash, + "_hash": torrent_hash, } # Rename single media file if len(media_list) == 1: @@ -166,9 +170,9 @@ class Renamer(DownloadClient): await self.rename_collection(media_list=media_list, **kwargs) if len(subtitle_list) > 0: await self.rename_subtitles(subtitle_list=subtitle_list, **kwargs) - await self.set_category(info.hash, "BangumiCollection") + await self.set_category(torrent_hash, "BangumiCollection") else: - logger.warning(f"[Renamer] {info.name} has no media file") + logger.warning(f"[Renamer] {torrent_name} has no media file") logger.debug("[Renamer] Rename process finished.") return renamed_info diff --git a/backend/src/module/update/__init__.py b/backend/src/module/update/__init__.py index db6d261f..e44d53f0 100644 --- a/backend/src/module/update/__init__.py +++ b/backend/src/module/update/__init__.py @@ -1,4 +1,4 @@ -from .cross_version import cache_image, from_30_to_31, from_31_to_32 +from .cross_version import cache_image, from_30_to_31, from_31_to_32, run_migrations from .data_migration import data_migration from .startup import first_run, start_up from .version_check import version_check diff --git a/backend/src/module/update/cross_version.py b/backend/src/module/update/cross_version.py index fcd24790..56cf49ad 100644 --- a/backend/src/module/update/cross_version.py +++ b/backend/src/module/update/cross_version.py @@ -38,10 +38,17 @@ async def from_30_to_31(): async def from_31_to_32(): """Migrate database schema from 3.1.x to 3.2.x.""" with RSSEngine() as db: - db.create_table() # Handles adding new columns (e.g., air_weekday) + db.create_table() + db.run_migrations() logger.info("[Migration] 3.1 -> 3.2 migration completed.") +def run_migrations(): + """Check schema version and run any pending migrations.""" + with RSSEngine() as db: + db.run_migrations() + + async def cache_image(): with RSSEngine() as db: bangumis = db.bangumi.search_all() diff --git a/backend/src/module/update/startup.py b/backend/src/module/update/startup.py index ecdde5e3..455ec34f 100644 --- a/backend/src/module/update/startup.py +++ b/backend/src/module/update/startup.py @@ -1,7 +1,7 @@ import logging -from module.rss import RSSEngine from module.conf import POSTERS_PATH +from module.rss import RSSEngine logger = logging.getLogger(__name__) @@ -9,11 +9,13 @@ logger = logging.getLogger(__name__) def start_up(): with RSSEngine() as engine: engine.create_table() + engine.run_migrations() engine.user.add_default_user() def first_run(): with RSSEngine() as engine: engine.create_table() + engine.run_migrations() engine.user.add_default_user() POSTERS_PATH.mkdir(parents=True, exist_ok=True) diff --git a/backend/src/test/test_migration.py b/backend/src/test/test_migration.py index 32c9688f..5e7e0e2b 100644 --- a/backend/src/test/test_migration.py +++ b/backend/src/test/test_migration.py @@ -9,10 +9,9 @@ from sqlalchemy import inspect, text from sqlmodel import Session, SQLModel, create_engine from module.conf.config import Settings -from module.database.combine import Database +from module.database.combine import CURRENT_SCHEMA_VERSION, Database from module.models import Bangumi, RSSItem, Torrent, User - # --- Mock old 3.1.x config (as stored in config.json) --- OLD_31X_CONFIG = { "program": { @@ -313,6 +312,7 @@ class TestDatabaseMigration: # Run migration db = Database(engine) db.create_table() + db.run_migrations() # Verify air_weekday now exists inspector = inspect(engine) @@ -330,6 +330,7 @@ class TestDatabaseMigration: # Run migration db = Database(engine) db.create_table() + db.run_migrations() # Check data is preserved bangumis = db.bangumi.search_all() @@ -354,6 +355,7 @@ class TestDatabaseMigration: db = Database(engine) db.create_table() + db.run_migrations() users = db.user.get_user("admin") assert users is not None @@ -369,6 +371,7 @@ class TestDatabaseMigration: db = Database(engine) db.create_table() + db.run_migrations() rss = db.rss.search_id(1) assert rss is not None @@ -385,6 +388,7 @@ class TestDatabaseMigration: db = Database(engine) db.create_table() + db.run_migrations() torrent = db.torrent.search(1) assert torrent is not None @@ -402,7 +406,8 @@ class TestDatabaseMigration: # Run migration twice db = Database(engine) db.create_table() - db.create_table() # Should not fail + db.run_migrations() + db.run_migrations() # Should not fail bangumis = db.bangumi.search_all() assert len(bangumis) == 2 @@ -417,6 +422,7 @@ class TestDatabaseMigration: db = Database(engine) db.create_table() + db.run_migrations() new_bangumi = Bangumi( official_title="葬送的芙莉莲", @@ -447,9 +453,56 @@ class TestDatabaseMigration: db = Database(engine) db.create_table() + db.run_migrations() inspector = inspect(engine) tables = inspector.get_table_names() assert "passkey" in tables db.close() + + def test_schema_version_tracked(self): + """After migration, schema_version table should store current version.""" + engine = create_engine("sqlite://", echo=False) + self._create_old_31x_database(engine) + self._insert_old_data(engine) + + db = Database(engine) + db.create_table() + db.run_migrations() + + # Verify schema_version table exists and has correct version + inspector = inspect(engine) + assert "schema_version" in inspector.get_table_names() + assert db._get_schema_version() == CURRENT_SCHEMA_VERSION + + db.close() + + def test_schema_version_skips_applied_migrations(self): + """If schema version is current, run_migrations should be a no-op.""" + engine = create_engine("sqlite://", echo=False) + self._create_old_31x_database(engine) + self._insert_old_data(engine) + + db = Database(engine) + db.create_table() + db.run_migrations() + + # Set version to current - second run should skip + version_before = db._get_schema_version() + db.run_migrations() + version_after = db._get_schema_version() + assert version_before == version_after == CURRENT_SCHEMA_VERSION + + db.close() + + def test_schema_version_zero_for_old_db(self): + """Old database without schema_version table should report version 0.""" + engine = create_engine("sqlite://", echo=False) + self._create_old_31x_database(engine) + self._insert_old_data(engine) + + db = Database(engine) + assert db._get_schema_version() == 0 + + db.close()