Merge remote-tracking branch 'origin/refactor/4.0-final' into docs/4.0-site

This commit is contained in:
Estrella Pan
2026-10-08 17:44:57 +02:00
4 changed files with 85 additions and 18 deletions

View File

@@ -27,6 +27,7 @@
- **修复(在线更新)**:在线更新包带上 `ab_sdk`,4.0 镜像启动时与 `module` 一起替换;更新包要求镜像不低于 4.0.0-beta.1,已安装 3.3 的用户在 beta 通道不会应用 4.x 更新包(缺少 `ab_sdk` 与新依赖,应用后无法启动),需要拉取 4.0 镜像
- **修复(多下载器)**:删除规则并删除文件时,若某个下载器不可用,规则与种子记录会保留并提示稍后重试;此前规则和种子记录已先删除,该下载器上的种子之后无法再删
- **修复(多下载器)**:aria2 任务与番剧的关联按下载器实例区分,两个 aria2 实例分配到相同 gid 时不再互相覆盖,同一种子也可以分别加入两个实例;插件下载器的工厂参数 `DownloaderConnection` 新增 `instance_id`
- **修复(回退 3.3)**:4.x 改把运行版本写入 `config/version_v4.info`,`config/version.info` 保留 3.x 的最后记录。此前 3.3 读到 `4.0.0` 只比较 minor,会当作 3.0 数据运行 3.0→3.1 迁移并重建数据库。首次以 4.x 启动 3.x 数据时还会把数据库备份为 `data/data.db.v3.bak`,回退时与 `config.json.v3.bak` 一起还原
- **新增(插件目录界面)**:设置 → 插件 新增「插件目录」:浏览签名目录中的插件并安装、更新;目录安装的插件可在卡片上卸载(内置、本地、pip 与 LLM 提供商插件不提供卸载)。插件卡片标出已签名或未签名。目录不可达时在区内显示原因,不弹全局提示
- **修复(插件设置)**:经签名目录安装的插件在插件卡片上的来源显示为未翻译的 `config.plugins_set.source_catalog`
- **修复(插件安装)**:`DELETE /api/v1/plugins/{id}` 不再能卸载 LLM 提供商插件;安装通用插件后,LLM 插件列举不再反复输出 `Skipping broken plugin` 警告

View File

@@ -7,7 +7,10 @@ from .log import LOG_PATH, setup_logger
TMDB_API = "32b19d6a05b512190a056fa4e747cbbc"
DATA_PATH = "sqlite:///data/data.db"
LEGACY_DATA_PATH = Path("data/data.json")
VERSION_PATH = Path("config/version.info")
# 3.x 只比较 version.info 末行的 minor,读到 4.x 会当成 3.0 并重建数据库,
# 所以 4.x 另写一份记录,version.info 保持 3.x 的最后状态,回退时仍安全。
VERSION_PATH = Path("config/version_v4.info")
V3_VERSION_PATH = Path("config/version.info")
POSTERS_PATH = Path("data/posters")
PLATFORM = "Windows" if sys.platform == "win32" else "Unix"

View File

@@ -5,7 +5,13 @@ from pathlib import Path
import semver
from module.conf import DATA_PATH, LEGACY_DATA_PATH, VERSION, VERSION_PATH
from module.conf import (
DATA_PATH,
LEGACY_DATA_PATH,
V3_VERSION_PATH,
VERSION,
VERSION_PATH,
)
from module.conf.config import CONFIG_PATH
logger = logging.getLogger(__name__)
@@ -67,6 +73,30 @@ def _read_last_version() -> semver.Version | None:
return None
def _read_v3_version() -> semver.Version | None:
"""3.x 的记录只读不写:回退到 3.3 时它仍要看到自己的版本。"""
try:
lines = V3_VERSION_PATH.read_text().splitlines()
return semver.Version.parse(lines[-1].strip())
except (IndexError, ValueError) as e:
logger.warning(f"{V3_VERSION_PATH} is empty or malformed ({e}).")
return None
def _backup_v3_database() -> None:
"""首次以 4.x 启动 3.x 数据时备份数据库,供回退 3.3 时还原;已有备份不覆盖。"""
backup = DATABASE_PATH.with_name(DATABASE_PATH.name + ".v3.bak")
if backup.exists():
return
# 用 SQLite 备份接口而不是复制文件:WAL 中未合并的数据也要带上
with (
closing(sqlite3.connect(DATABASE_PATH)) as src,
closing(sqlite3.connect(backup)) as dst,
):
src.backup(dst)
logger.info(f"Backed up the 3.x database to {backup}.")
def version_check() -> semver.Version | None:
"""校验并记录版本,返回上一次运行的版本(未知时为 None)。
@@ -75,17 +105,25 @@ def version_check() -> semver.Version | None:
"""
if VERSION in ("DEV_VERSION", "local"):
return None
if not VERSION_PATH.exists():
if VERSION_PATH.exists():
last_ver = _read_last_version()
if last_ver is None:
return None
elif V3_VERSION_PATH.exists():
last_ver = _read_v3_version()
if last_ver is not None and (last_ver.major, last_ver.minor) < (
MIN_UPGRADE_FROM
):
raise UnsupportedUpgradeError(_upgrade_hint(str(last_ver)))
_backup_v3_database()
VERSION_PATH.write_text(VERSION + "\n")
return last_ver
else:
# 3.3 也不补跑 3.0 → 3.1 的数据迁移(缺 version.info 时跳过),只有 3.1.x 会
if _is_v30_database():
raise UnsupportedUpgradeError(_upgrade_hint("3.0", via="3.1.x"))
VERSION_PATH.write_text(VERSION + "\n")
return None
last_ver = _read_last_version()
if last_ver is None:
return None
if (last_ver.major, last_ver.minor) < MIN_UPGRADE_FROM:
raise UnsupportedUpgradeError(_upgrade_hint(str(last_ver)))
if semver.Version.parse(VERSION) > last_ver:
with VERSION_PATH.open("a") as f:
f.write(VERSION + "\n")

View File

@@ -18,11 +18,12 @@ def _make_db(path, *tables: str) -> None:
@pytest.fixture
def paths(tmp_path, monkeypatch):
version_path = tmp_path / "version.info"
version_path = tmp_path / "version_v4.info"
legacy_path = tmp_path / "data.json"
db_path = tmp_path / "data.db"
_make_db(db_path, "bangumi", "rssitem")
monkeypatch.setattr(vc, "VERSION_PATH", version_path)
monkeypatch.setattr(vc, "V3_VERSION_PATH", tmp_path / "version.info")
monkeypatch.setattr(vc, "LEGACY_DATA_PATH", legacy_path)
monkeypatch.setattr(vc, "DATABASE_PATH", db_path)
monkeypatch.setattr(vc, "VERSION", "4.0.0")
@@ -49,20 +50,42 @@ def test_missing_version_file_with_30_database_is_refused(paths, tmp_path, monke
assert not version_path.exists()
def test_upgrade_from_33_is_recorded(paths):
def test_upgrade_from_33_keeps_v3_record_and_backs_up_database(paths, tmp_path):
"""3.3 只比较 version.info 末行的 minor:写入 4.x 会被读成 3.0 并重建数据库。"""
version_path, _ = paths
version_path.write_text("3.2.7\n3.3.6\n")
v3_path = tmp_path / "version.info"
v3_path.write_text("3.2.7\n3.3.6\n")
last = vc.version_check()
assert str(last) == "3.3.6"
assert version_path.read_text().splitlines()[-1] == "4.0.0"
assert v3_path.read_text() == "3.2.7\n3.3.6\n"
assert version_path.read_text() == "4.0.0\n"
with closing(sqlite3.connect(tmp_path / "data.db.v3.bak")) as conn:
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master")}
assert tables == {"bangumi", "rssitem"}
def test_upgrade_from_33_prerelease_is_allowed(paths):
version_path, _ = paths
version_path.write_text("3.3.0-beta.2\n")
def test_upgrade_does_not_replace_existing_database_backup(paths, tmp_path):
(tmp_path / "version.info").write_text("3.3.6\n")
backup = tmp_path / "data.db.v3.bak"
backup.write_text("older")
vc.version_check()
assert backup.read_text() == "older"
def test_upgrade_from_33_prerelease_is_allowed(paths, tmp_path):
(tmp_path / "version.info").write_text("3.3.0-beta.2\n")
assert str(vc.version_check()) == "3.3.0-beta.2"
def test_later_4x_boot_makes_no_database_backup(paths, tmp_path):
version_path, _ = paths
(tmp_path / "version.info").write_text("3.3.6\n")
version_path.write_text("4.0.0-beta.1\n")
assert str(vc.version_check()) == "4.0.0-beta.1"
assert version_path.read_text() == "4.0.0-beta.1\n4.0.0\n"
assert not (tmp_path / "data.db.v3.bak").exists()
def test_same_version_is_not_duplicated(paths):
version_path, _ = paths
version_path.write_text("4.0.0\n")
@@ -78,15 +101,17 @@ def test_downgrade_is_not_recorded(paths):
@pytest.mark.parametrize("previous", ["3.2.7", "3.1.0", "2.6.4"])
def test_upgrade_from_pre_33_is_refused(paths, previous):
def test_upgrade_from_pre_33_is_refused(paths, tmp_path, previous):
version_path, _ = paths
version_path.write_text(previous + "\n")
v3_path = tmp_path / "version.info"
v3_path.write_text(previous + "\n")
with pytest.raises(vc.UnsupportedUpgradeError, match=previous) as exc:
vc.version_check()
# 本次启动已把 config.json 改写为 4.0 格式,提示要先还原备份再回到旧版本
assert "config.json.v3.bak" in str(exc.value)
# 拒绝时不改写记录,用户回退到 3.3.x 后仍能正常迁移
assert version_path.read_text() == previous + "\n"
assert v3_path.read_text() == previous + "\n"
assert not version_path.exists()
def test_legacy_data_json_is_refused(paths):