fix(rss): cascade-delete torrents when deleting RSSItem (#1019)

Deleting an RSSItem failed with sqlite3.IntegrityError: FOREIGN KEY constraint failed when child torrents still referenced it, leaving the sidebar entry stuck in the UI.

- delete() now removes referencing torrents in the same transaction before removing the RSSItem
- Adds missing session.rollback() in the exception path
- rss_loop wraps each iteration to skip stale RSSItems deleted mid-loop via API
- Tests enable PRAGMA foreign_keys=ON to match production and gain 5 cascade-delete regression tests

Closes #1010
Closes #1017
This commit is contained in:
ZzzzSsssWwww
2026-04-19 18:50:09 +08:00
committed by GitHub
parent 117c24ce77
commit 6235892de2
3 changed files with 151 additions and 7 deletions

View File

@@ -31,7 +31,14 @@ class RSSThread(ProgramStatus):
# Analyse RSS
rss_list = engine.rss.search_aggregate()
for rss in rss_list:
await self.analyser.rss_to_data(rss, engine)
try:
await self.analyser.rss_to_data(rss, engine)
except Exception:
# RSS 可能在遍历期间被 API 删除,跳过即可
logger.debug(
"[RSSThread] Skipping RSS id=%s, likely deleted",
rss.id if hasattr(rss, "id") else "?",
)
# Run RSS Engine
await engine.refresh_rss(client)
if settings.bangumi_manage.eps_complete:

View File

@@ -2,7 +2,7 @@ import logging
from sqlmodel import Session, and_, delete, select
from module.models import RSSItem, RSSUpdate
from module.models import RSSItem, RSSUpdate, Torrent
logger = logging.getLogger(__name__)
@@ -107,16 +107,23 @@ class RSSDatabase:
return list(result.scalars().all())
def delete(self, _id: int) -> bool:
condition = delete(RSSItem).where(RSSItem.id == _id)
try:
self.session.execute(condition)
# 先删除引用该 RSS 的 torrent避免外键约束报错
self.session.execute(delete(Torrent).where(Torrent.rss_id == _id))
self.session.execute(delete(RSSItem).where(RSSItem.id == _id))
self.session.commit()
return True
except Exception as e:
self.session.rollback()
logger.error(f"Delete RSS Item failed. Because: {e}")
return False
def delete_all(self):
condition = delete(RSSItem)
self.session.execute(condition)
self.session.commit()
try:
# 先删除所有引用 RSS 的 torrent避免外键约束报错
self.session.execute(delete(Torrent).where(Torrent.rss_id != None)) # noqa: E711
self.session.execute(delete(RSSItem))
self.session.commit()
except Exception as e:
self.session.rollback()
logger.error(f"Delete all RSS Items failed. Because: {e}")

View File

@@ -1,6 +1,7 @@
import json
import pytest
from sqlalchemy import event
from sqlmodel import Session, SQLModel, create_engine
from module.database.bangumi import BangumiDatabase
@@ -12,6 +13,30 @@ from module.models import Bangumi, RSSItem, Torrent
engine = create_engine("sqlite://", echo=False)
@event.listens_for(engine, "connect")
def _enable_foreign_keys(dbapi_conn, connection_record):
"""匹配生产环境行为:启用 SQLite 外键约束。"""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def _ensure_bangumi(session, bangumi_id: int):
"""确保 bangumi 表中存在指定 id 的记录,满足外键约束。"""
if session.get(Bangumi, bangumi_id) is None:
session.add(Bangumi(
id=bangumi_id,
official_title=f"Stub Anime {bangumi_id}",
title_raw=f"Stub {bangumi_id}",
group_name="TestGroup",
dpi="1080p",
source="Web",
subtitle="CHT",
rss_link=f"stub_{bangumi_id}",
))
session.commit()
@pytest.fixture
def db_session():
SQLModel.metadata.create_all(engine)
@@ -189,6 +214,9 @@ def test_torrent_with_bangumi_id(db_session):
"""Test torrent with bangumi_id for offset lookup."""
db = TorrentDatabase(db_session)
# 父记录满足外键约束
_ensure_bangumi(db_session, 42)
# Create torrent linked to a bangumi
torrent = Torrent(
name="[SubGroup] Test Anime - 04 [1080p].mkv",
@@ -445,6 +473,7 @@ class TestDeleteByBangumiId:
def test_deletes_matching_torrents(self, db_session):
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 10)
for i in range(3):
db.add(Torrent(name=f"torrent_{i}", url=f"https://example.com/{i}", bangumi_id=10))
assert len(db.search_all()) == 3
@@ -455,6 +484,8 @@ class TestDeleteByBangumiId:
def test_leaves_other_bangumi_torrents(self, db_session):
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 20)
_ensure_bangumi(db_session, 30)
db.add(Torrent(name="keep", url="https://example.com/keep", bangumi_id=20))
db.add(Torrent(name="delete", url="https://example.com/delete", bangumi_id=30))
@@ -466,6 +497,7 @@ class TestDeleteByBangumiId:
def test_no_match_returns_zero(self, db_session):
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 5)
db.add(Torrent(name="unrelated", url="https://example.com/1", bangumi_id=5))
count = db.delete_by_bangumi_id(999)
@@ -474,6 +506,7 @@ class TestDeleteByBangumiId:
def test_skips_null_bangumi_id(self, db_session):
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 7)
db.add(Torrent(name="orphan", url="https://example.com/orphan", bangumi_id=None))
db.add(Torrent(name="target", url="https://example.com/target", bangumi_id=7))
@@ -486,6 +519,7 @@ class TestDeleteByBangumiId:
def test_check_new_finds_urls_after_cleanup(self, db_session):
"""Core scenario: after deleting torrent records, check_new should treat those URLs as new."""
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 42)
db.add(Torrent(name="ep01", url="https://mikan.me/t/001", bangumi_id=42))
db.add(Torrent(name="ep02", url="https://mikan.me/t/002", bangumi_id=42))
@@ -579,3 +613,99 @@ def test_match_list_with_aliases(db_session):
unmatched = db.match_list(torrents, "rss2")
assert len(unmatched) == 1
assert unmatched[0].name == "[OtherGroup] Different Anime - 01.mkv"
# ============================================================
# RSS Foreign Key Constraint Tests
# ============================================================
class TestRSSDeleteWithTorrents:
"""Regression tests: deleting RSSItem must cascade-delete referencing torrents."""
def test_delete_rss_with_torrents(self, db_session):
"""删除 RSSItem 时应自动清除引用它的 torrent 记录。"""
rss_db = RSSDatabase(db_session)
torrent_db = TorrentDatabase(db_session)
# 创建 RSS 和关联的 torrent
rss = RSSItem(url="https://mikanani.me/RSS/test", name="Test RSS")
rss_db.add(rss)
torrent_db.add(Torrent(name="ep01", url="https://example.com/1", rss_id=rss.id))
torrent_db.add(Torrent(name="ep02", url="https://example.com/2", rss_id=rss.id))
# 不关联此 RSS 的 torrent
torrent_db.add(Torrent(name="other", url="https://example.com/3", rss_id=None))
assert len(torrent_db.search_rss(rss.id)) == 2
# 删除 RSS不应报外键错误
result = rss_db.delete(rss.id)
assert result is True
# RSS 和关联 torrent 都应被删除
assert rss_db.search_id(rss.id) is None
assert len(torrent_db.search_rss(rss.id)) == 0
# 无关 torrent 不受影响
assert len(torrent_db.search_all()) == 1
def test_delete_rss_without_torrents(self, db_session):
"""删除没有关联 torrent 的 RSSItem 应正常工作。"""
rss_db = RSSDatabase(db_session)
rss = RSSItem(url="https://mikanani.me/RSS/empty", name="Empty RSS")
rss_db.add(rss)
result = rss_db.delete(rss.id)
assert result is True
assert rss_db.search_id(rss.id) is None
def test_delete_rss_cascades_in_transaction(self, db_session):
"""验证删除操作在同一事务中完成,要么全成功要么全回滚。"""
rss_db = RSSDatabase(db_session)
torrent_db = TorrentDatabase(db_session)
rss = RSSItem(url="https://mikanani.me/RSS/tx", name="TX Test")
rss_db.add(rss)
for i in range(5):
torrent_db.add(
Torrent(name=f"ep{i:02d}", url=f"https://example.com/{i}", rss_id=rss.id)
)
assert len(torrent_db.search_rss(rss.id)) == 5
rss_db.delete(rss.id)
# 全部清理干净
assert rss_db.search_id(rss.id) is None
assert len(torrent_db.search_rss(rss.id)) == 0
def test_delete_all_rss_with_torrents(self, db_session):
"""delete_all 应删除所有 RSS 及其关联 torrent。"""
rss_db = RSSDatabase(db_session)
torrent_db = TorrentDatabase(db_session)
rss1 = RSSItem(url="https://mikanani.me/RSS/a", name="RSS A")
rss2 = RSSItem(url="https://mikanani.me/RSS/b", name="RSS B")
rss_db.add(rss1)
rss_db.add(rss2)
torrent_db.add(Torrent(name="t1", url="https://example.com/1", rss_id=rss1.id))
torrent_db.add(Torrent(name="t2", url="https://example.com/2", rss_id=rss2.id))
# rss_id 为 None 的 torrent 应不受影响
torrent_db.add(Torrent(name="orphan", url="https://example.com/3", rss_id=None))
rss_db.delete_all()
assert len(rss_db.search_all()) == 0
# 只剩 rss_id 为 None 的
remaining = torrent_db.search_all()
assert len(remaining) == 1
assert remaining[0].name == "orphan"
def test_delete_nonexistent_rss(self, db_session):
"""删除不存在的 RSS 不应报错。"""
rss_db = RSSDatabase(db_session)
result = rss_db.delete(999)
assert result is True