diff --git a/backend/src/module/api/bangumi.py b/backend/src/module/api/bangumi.py index 2785376d..443246f3 100644 --- a/backend/src/module/api/bangumi.py +++ b/backend/src/module/api/bangumi.py @@ -8,7 +8,7 @@ from module.conf import settings from module.database import Database, get_db from module.downloader import DownloadClient from module.manager import Renamer, TorrentManager -from module.models import APIResponse, Bangumi, BangumiUpdate, ResponseModel +from module.models import APIResponse, Bangumi, BangumiUpdate, ResponseModel, Torrent from module.parser.analyser.offset_detector import ( OffsetSuggestion as DetectorSuggestion, ) @@ -493,3 +493,134 @@ async def set_weekday(bangumi_id: int, request: SetWeekdayRequest): "msg_zh": f"未找到番剧 {bangumi_id}。", }, ) + + +# --------------------------------------------------------------------------- +# Torrent management (#1020) +# 孤儿种子:bangumi_id 为 NULL 的种子记录(解析失败、取消关联或脏数据残留)。 +# /torrents/orphans 使用字面量路径,注册在 /{bangumi_id}/torrents 之前以避免歧义。 +# --------------------------------------------------------------------------- + + +@router.get( + path="/torrents/orphans", + response_model=list[Torrent], + dependencies=[Depends(get_current_user)], +) +async def get_orphan_torrents(db: Database = Depends(get_db)): + """List all torrent records not associated with any bangumi.""" + return await db.torrent.search_orphans() + + +@router.get( + path="/torrents/orphans/count", + response_model=int, + dependencies=[Depends(get_current_user)], +) +async def get_orphan_torrent_count(db: Database = Depends(get_db)): + """Count torrent records not associated with any bangumi.""" + return await db.torrent.count_orphans() + + +@router.delete( + path="/torrents/orphans", + response_model=APIResponse, + dependencies=[Depends(get_current_user)], +) +async def delete_orphan_torrents(db: Database = Depends(get_db)): + """Delete all torrent records not associated with any bangumi.""" + count = await db.torrent.delete_orphans() + return u_response( + ResponseModel( + status=True, + status_code=200, + msg_en=f"Deleted {count} orphan torrents.", + msg_zh=f"已删除 {count} 条未匹配种子。", + ) + ) + + +@router.delete( + path="/torrents/orphans/{torrent_id}", + response_model=APIResponse, + dependencies=[Depends(get_current_user)], +) +async def delete_orphan_torrent(torrent_id: int, db: Database = Depends(get_db)): + """Delete a single orphan torrent record.""" + torrent = await db.torrent.search(torrent_id) + if torrent is None or torrent.bangumi_id is not None: + return JSONResponse( + status_code=404, + content={ + "status": False, + "msg_en": f"Orphan torrent {torrent_id} not found.", + "msg_zh": f"未找到孤儿种子 {torrent_id}。", + }, + ) + await db.torrent.delete_obj(torrent) + return u_response( + ResponseModel( + status=True, + status_code=200, + msg_en=f"Deleted torrent {torrent_id}.", + msg_zh=f"已删除种子 {torrent_id}。", + ) + ) + + +@router.get( + path="/{bangumi_id}/torrents", + response_model=list[Torrent], + dependencies=[Depends(get_current_user)], +) +async def get_bangumi_torrents(bangumi_id: int, db: Database = Depends(get_db)): + """List all torrent records associated with a bangumi.""" + return await db.torrent.search_by_bangumi_id(bangumi_id) + + +@router.delete( + path="/{bangumi_id}/torrents", + response_model=APIResponse, + dependencies=[Depends(get_current_user)], +) +async def delete_bangumi_torrents(bangumi_id: int, db: Database = Depends(get_db)): + """Delete all torrent records associated with a bangumi.""" + count = await db.torrent.delete_by_bangumi_id(bangumi_id) + return u_response( + ResponseModel( + status=True, + status_code=200, + msg_en=f"Deleted {count} torrents for bangumi {bangumi_id}.", + msg_zh=f"已删除番剧 {bangumi_id} 的 {count} 条种子。", + ) + ) + + +@router.delete( + path="/{bangumi_id}/torrents/{torrent_id}", + response_model=APIResponse, + dependencies=[Depends(get_current_user)], +) +async def delete_bangumi_torrent( + bangumi_id: int, torrent_id: int, db: Database = Depends(get_db) +): + """Delete a single torrent record under a bangumi.""" + torrent = await db.torrent.search(torrent_id) + if torrent is None or torrent.bangumi_id != bangumi_id: + return JSONResponse( + status_code=404, + content={ + "status": False, + "msg_en": f"Torrent {torrent_id} not found under bangumi {bangumi_id}.", + "msg_zh": f"番剧 {bangumi_id} 下未找到种子 {torrent_id}。", + }, + ) + await db.torrent.delete_obj(torrent) + return u_response( + ResponseModel( + status=True, + status_code=200, + msg_en=f"Deleted torrent {torrent_id}.", + msg_zh=f"已删除种子 {torrent_id}。", + ) + ) diff --git a/backend/src/module/database/torrent.py b/backend/src/module/database/torrent.py index 375885c4..a7d72a15 100644 --- a/backend/src/module/database/torrent.py +++ b/backend/src/module/database/torrent.py @@ -1,6 +1,7 @@ import logging from collections import defaultdict +from sqlalchemy import delete, func from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import and_, select, true @@ -128,6 +129,43 @@ class TorrentDatabase: ) return count + async def search_orphans(self) -> list[Torrent]: + """Find all torrent records not associated with any bangumi.""" + result = await self.session.execute( + select(Torrent).where(Torrent.bangumi_id.is_(None)) # type: ignore[union-attr] + ) + return list(result.scalars().all()) + + async def count_orphans(self) -> int: + """Count torrent records not associated with any bangumi.""" + result = await self.session.execute( + select(func.count()) + .select_from(Torrent) + .where(Torrent.bangumi_id.is_(None)) # type: ignore[union-attr] + ) + return result.scalar_one() + + async def delete_orphans(self) -> int: + """Delete all torrent records not associated with any bangumi. + + Returns the number of deleted records. + """ + result = await self.session.execute( + delete(Torrent).where(Torrent.bangumi_id.is_(None)) # type: ignore[union-attr] + ) + await self.session.commit() + # execute(delete()) 返回 CursorResult,mypy 只能推断出基类 Result[Any] + count = result.rowcount # type: ignore[attr-defined] + if count > 0: + logger.debug("Deleted %s orphan torrents.", count) + return count + + async def delete_obj(self, torrent: Torrent) -> None: + """Delete a single torrent record.""" + await self.session.delete(torrent) + await self.session.commit() + logger.debug("Deleted torrent %s.", torrent.id) + async def search_by_url(self, url: str) -> Torrent | None: """Find torrent by URL.""" result = await self.session.execute(select(Torrent).where(Torrent.url == url)) diff --git a/backend/src/test/test_api_bangumi_torrents.py b/backend/src/test/test_api_bangumi_torrents.py new file mode 100644 index 00000000..52a3ebd3 --- /dev/null +++ b/backend/src/test/test_api_bangumi_torrents.py @@ -0,0 +1,188 @@ +"""Tests for Bangumi torrent-management API endpoints (#1020 port).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from module.api import v1 +from module.database import get_db +from module.models import Torrent +from module.security.api import get_current_user + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_db(): + """A stand-in Database whose repos can be configured with AsyncMocks.""" + return MagicMock() + + +@pytest.fixture +def app(mock_db): + """Create a FastAPI app with v1 routes for testing.""" + app = FastAPI() + app.include_router(v1, prefix="/api") + + async def _override_get_db(): + yield mock_db + + app.dependency_overrides[get_db] = _override_get_db + return app + + +@pytest.fixture +def authed_client(app): + """TestClient with auth dependency overridden.""" + + async def mock_user(): + return "testuser" + + app.dependency_overrides[get_current_user] = mock_user + client = TestClient(app) + yield client + app.dependency_overrides.clear() + + +@pytest.fixture +def unauthed_client(app): + """TestClient without auth (no override).""" + return TestClient(app) + + +def _torrent(_id: int, bangumi_id: int | None) -> Torrent: + return Torrent( + id=_id, + name=f"torrent_{_id}", + url=f"https://example.com/{_id}", + bangumi_id=bangumi_id, + ) + + +# --------------------------------------------------------------------------- +# Auth requirement +# --------------------------------------------------------------------------- + + +class TestAuthRequired: + @patch("module.security.api.DEV_AUTH_BYPASS", False) + def test_get_orphans_unauthorized(self, unauthed_client): + """GET /bangumi/torrents/orphans without auth returns 401.""" + response = unauthed_client.get("/api/v1/bangumi/torrents/orphans") + assert response.status_code == 401 + + @patch("module.security.api.DEV_AUTH_BYPASS", False) + def test_delete_orphans_unauthorized(self, unauthed_client): + """DELETE /bangumi/torrents/orphans without auth returns 401.""" + response = unauthed_client.delete("/api/v1/bangumi/torrents/orphans") + assert response.status_code == 401 + + @patch("module.security.api.DEV_AUTH_BYPASS", False) + def test_get_bangumi_torrents_unauthorized(self, unauthed_client): + """GET /bangumi/1/torrents without auth returns 401.""" + response = unauthed_client.get("/api/v1/bangumi/1/torrents") + assert response.status_code == 401 + + +# --------------------------------------------------------------------------- +# Orphan torrents +# --------------------------------------------------------------------------- + + +class TestOrphanTorrents: + def test_get_orphans_returns_list(self, authed_client, mock_db): + """GET /bangumi/torrents/orphans returns orphan torrent list.""" + mock_db.torrent.search_orphans = AsyncMock( + return_value=[_torrent(1, None), _torrent(2, None)] + ) + response = authed_client.get("/api/v1/bangumi/torrents/orphans") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["name"] == "torrent_1" + + def test_get_orphan_count(self, authed_client, mock_db): + """GET /bangumi/torrents/orphans/count returns the count.""" + mock_db.torrent.count_orphans = AsyncMock(return_value=3) + response = authed_client.get("/api/v1/bangumi/torrents/orphans/count") + assert response.status_code == 200 + assert response.json() == 3 + + def test_delete_orphans_returns_count_message(self, authed_client, mock_db): + """DELETE /bangumi/torrents/orphans deletes all orphans.""" + mock_db.torrent.delete_orphans = AsyncMock(return_value=2) + response = authed_client.delete("/api/v1/bangumi/torrents/orphans") + assert response.status_code == 200 + assert "2" in response.json()["msg_en"] + mock_db.torrent.delete_orphans.assert_awaited_once() + + def test_delete_single_orphan(self, authed_client, mock_db): + """DELETE /bangumi/torrents/orphans/{id} deletes one orphan.""" + mock_db.torrent.search = AsyncMock(return_value=_torrent(5, None)) + mock_db.torrent.delete_obj = AsyncMock() + response = authed_client.delete("/api/v1/bangumi/torrents/orphans/5") + assert response.status_code == 200 + mock_db.torrent.delete_obj.assert_awaited_once() + + def test_delete_single_orphan_not_found(self, authed_client, mock_db): + """DELETE /bangumi/torrents/orphans/{id} returns 404 for missing torrent.""" + mock_db.torrent.search = AsyncMock(return_value=None) + response = authed_client.delete("/api/v1/bangumi/torrents/orphans/99") + assert response.status_code == 404 + + def test_delete_single_orphan_rejects_matched_torrent(self, authed_client, mock_db): + """DELETE /bangumi/torrents/orphans/{id} returns 404 if torrent has a bangumi.""" + mock_db.torrent.search = AsyncMock(return_value=_torrent(5, bangumi_id=1)) + mock_db.torrent.delete_obj = AsyncMock() + response = authed_client.delete("/api/v1/bangumi/torrents/orphans/5") + assert response.status_code == 404 + mock_db.torrent.delete_obj.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Per-bangumi torrents +# --------------------------------------------------------------------------- + + +class TestBangumiTorrents: + def test_get_bangumi_torrents(self, authed_client, mock_db): + """GET /bangumi/{id}/torrents returns that bangumi's torrents.""" + mock_db.torrent.search_by_bangumi_id = AsyncMock(return_value=[_torrent(1, 7)]) + response = authed_client.get("/api/v1/bangumi/7/torrents") + assert response.status_code == 200 + assert len(response.json()) == 1 + mock_db.torrent.search_by_bangumi_id.assert_awaited_once_with(7) + + def test_delete_bangumi_torrents(self, authed_client, mock_db): + """DELETE /bangumi/{id}/torrents deletes all torrents of a bangumi.""" + mock_db.torrent.delete_by_bangumi_id = AsyncMock(return_value=4) + response = authed_client.delete("/api/v1/bangumi/7/torrents") + assert response.status_code == 200 + assert "4" in response.json()["msg_en"] + mock_db.torrent.delete_by_bangumi_id.assert_awaited_once_with(7) + + def test_delete_single_torrent(self, authed_client, mock_db): + """DELETE /bangumi/{id}/torrents/{tid} deletes one torrent.""" + mock_db.torrent.search = AsyncMock(return_value=_torrent(3, 7)) + mock_db.torrent.delete_obj = AsyncMock() + response = authed_client.delete("/api/v1/bangumi/7/torrents/3") + assert response.status_code == 200 + mock_db.torrent.delete_obj.assert_awaited_once() + + def test_delete_single_torrent_wrong_bangumi(self, authed_client, mock_db): + """DELETE /bangumi/{id}/torrents/{tid} returns 404 if owner mismatches.""" + mock_db.torrent.search = AsyncMock(return_value=_torrent(3, 8)) + mock_db.torrent.delete_obj = AsyncMock() + response = authed_client.delete("/api/v1/bangumi/7/torrents/3") + assert response.status_code == 404 + mock_db.torrent.delete_obj.assert_not_awaited() + + def test_delete_single_torrent_not_found(self, authed_client, mock_db): + """DELETE /bangumi/{id}/torrents/{tid} returns 404 for missing torrent.""" + mock_db.torrent.search = AsyncMock(return_value=None) + response = authed_client.delete("/api/v1/bangumi/7/torrents/99") + assert response.status_code == 404 diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index ed46cbea..f3c63f5e 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -1019,3 +1019,78 @@ class TestRSSDeleteWithTorrents: rss_db = RSSDatabase(db_session) result = await rss_db.delete(999) assert result is True + + +class TestOrphanTorrents: + """Tests for TorrentDatabase orphan-torrent operations (bangumi_id IS NULL).""" + + async def test_search_orphans_returns_only_unmatched(self, db_session): + db = TorrentDatabase(db_session) + await _ensure_bangumi(db_session, 50) + await db.add( + Torrent(name="matched", url="https://example.com/m", bangumi_id=50) + ) + await db.add( + Torrent(name="orphan_a", url="https://example.com/a", bangumi_id=None) + ) + await db.add( + Torrent(name="orphan_b", url="https://example.com/b", bangumi_id=None) + ) + + orphans = await db.search_orphans() + assert sorted(t.name for t in orphans) == ["orphan_a", "orphan_b"] + + async def test_count_orphans(self, db_session): + db = TorrentDatabase(db_session) + await _ensure_bangumi(db_session, 51) + await db.add( + Torrent(name="matched", url="https://example.com/m", bangumi_id=51) + ) + await db.add( + Torrent(name="orphan", url="https://example.com/o", bangumi_id=None) + ) + + assert await db.count_orphans() == 1 + + async def test_count_orphans_empty(self, db_session): + db = TorrentDatabase(db_session) + assert await db.count_orphans() == 0 + + async def test_delete_orphans_removes_all_and_returns_count(self, db_session): + db = TorrentDatabase(db_session) + await _ensure_bangumi(db_session, 52) + await db.add( + Torrent(name="matched", url="https://example.com/m", bangumi_id=52) + ) + await db.add( + Torrent(name="orphan_a", url="https://example.com/a", bangumi_id=None) + ) + await db.add( + Torrent(name="orphan_b", url="https://example.com/b", bangumi_id=None) + ) + + count = await db.delete_orphans() + assert count == 2 + remaining = await db.search_all() + assert len(remaining) == 1 + assert remaining[0].name == "matched" + + async def test_delete_orphans_no_match_returns_zero(self, db_session): + db = TorrentDatabase(db_session) + await _ensure_bangumi(db_session, 53) + await db.add( + Torrent(name="matched", url="https://example.com/m", bangumi_id=53) + ) + + assert await db.delete_orphans() == 0 + assert len(await db.search_all()) == 1 + + async def test_delete_obj_removes_single_torrent(self, db_session): + db = TorrentDatabase(db_session) + await db.add( + Torrent(name="orphan", url="https://example.com/o", bangumi_id=None) + ) + torrent = (await db.search_all())[0] + + await db.delete_obj(torrent) + assert await db.search_all() == [] diff --git a/webui/src/api/bangumi.ts b/webui/src/api/bangumi.ts index 727963e4..cca6cca2 100644 --- a/webui/src/api/bangumi.ts +++ b/webui/src/api/bangumi.ts @@ -7,6 +7,7 @@ import type { OffsetSuggestion, } from '#/bangumi'; import type { ApiSuccess } from '#/api'; +import type { Torrent } from '#/torrent'; export const apiBangumi = { /** @@ -208,4 +209,76 @@ export const apiBangumi = { ); return data; }, + + // ── Torrent management ── + + /** + * 获取指定番剧关联的所有种子记录 + */ + async getTorrents(bangumiId: number) { + const { data } = await axios.get( + `api/v1/bangumi/${bangumiId}/torrents` + ); + return data; + }, + + /** + * 删除指定番剧关联的所有种子记录 + */ + async deleteAllTorrents(bangumiId: number) { + const { data } = await axios.delete( + `api/v1/bangumi/${bangumiId}/torrents` + ); + return data; + }, + + /** + * 删除指定番剧下的单条种子记录 + */ + async deleteTorrent(bangumiId: number, torrentId: number) { + const { data } = await axios.delete( + `api/v1/bangumi/${bangumiId}/torrents/${torrentId}` + ); + return data; + }, + + /** + * 获取所有孤儿种子(未关联番剧的种子记录) + */ + async getOrphanTorrents() { + const { data } = await axios.get( + 'api/v1/bangumi/torrents/orphans' + ); + return data; + }, + + /** + * 获取孤儿种子数量 + */ + async getOrphanTorrentCount() { + const { data } = await axios.get( + 'api/v1/bangumi/torrents/orphans/count' + ); + return data; + }, + + /** + * 删除所有孤儿种子 + */ + async deleteOrphanTorrents() { + const { data } = await axios.delete( + 'api/v1/bangumi/torrents/orphans' + ); + return data; + }, + + /** + * 删除单条孤儿种子 + */ + async deleteOrphanTorrent(torrentId: number) { + const { data } = await axios.delete( + `api/v1/bangumi/torrents/orphans/${torrentId}` + ); + return data; + }, }; diff --git a/webui/src/components/ab-edit-rule.vue b/webui/src/components/ab-edit-rule.vue index aea549c2..9aae0de7 100644 --- a/webui/src/components/ab-edit-rule.vue +++ b/webui/src/components/ab-edit-rule.vue @@ -145,6 +145,14 @@ async function dismissReview() { const close = () => (show.value = false); +// 种子管理页入口:关闭弹窗后跳转到该规则的种子列表 +const router = useRouter(); + +function goToTorrents() { + close(); + router.push(`/bangumi-torrents/${rule.value.id}`); +} + onKeyStroke('Escape', () => { if (!show.value) return; // Inner delete dialog closes first, then the modal itself. @@ -368,6 +376,9 @@ function emitUnarchive() { + + +
+
+ ? + +
+
+
{{ $t('homepage.others.title') }}
+
+
@@ -402,6 +439,72 @@ function groupNeedsReview(group: BangumiGroup): boolean { } } +// Others card for orphan torrents +.others-card { + width: 150px; + cursor: pointer; + user-select: none; + + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 4px; + border-radius: var(--radius-md); + } +} + +.others-poster { + position: relative; + aspect-ratio: 5 / 7; + border-radius: var(--radius-md); + overflow: visible; + box-shadow: var(--shadow-md); + display: flex; + align-items: center; + justify-content: center; + background: var(--color-surface-hover); + border: 2px dashed var(--color-border); + transition: box-shadow var(--transition-fast), transform var(--transition-fast); + + .others-card:hover &, + .others-card:focus-visible & { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); + } + + @include forTouch { + .others-card:hover & { + transform: none; + } + } +} + +.others-icon { + font-size: 48px; + font-weight: 700; + color: var(--color-text-muted); +} + +.others-count { + position: absolute; + top: -8px; + right: -8px; + z-index: 10; +} + +.others-info { + padding: 8px 2px 4px; +} + +.others-title { + font-size: 14px; + font-weight: 500; + line-height: 1.4; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .bangumi-group-wrapper { position: relative; overflow: visible; diff --git a/webui/types/dts/auto-imports.d.ts b/webui/types/dts/auto-imports.d.ts index aad3ddb0..471668f1 100644 --- a/webui/types/dts/auto-imports.d.ts +++ b/webui/types/dts/auto-imports.d.ts @@ -132,6 +132,7 @@ declare global { const useSearchStore: typeof import('../../src/store/search')['useSearchStore'] const useSetupStore: typeof import('../../src/store/setup')['useSetupStore'] const useSlots: typeof import('vue')['useSlots'] + const useTorrentList: typeof import('../../src/hooks/useTorrentList')['useTorrentList'] const vi: typeof import('vitest')['vi'] const vitest: typeof import('vitest')['vitest'] const watch: typeof import('vue')['watch'] diff --git a/webui/types/dts/components.d.ts b/webui/types/dts/components.d.ts index 7910b80a..c814b169 100644 --- a/webui/types/dts/components.d.ts +++ b/webui/types/dts/components.d.ts @@ -52,6 +52,7 @@ declare module '@vue/runtime-core' { AbToolbar: typeof import('./../../src/components/basic/ab-toolbar.vue')['default'] AbTooltip: typeof import('./../../src/components/basic/ab-tooltip.vue')['default'] AbTopbar: typeof import('./../../src/components/layout/ab-topbar.vue')['default'] + AbTorrentListPage: typeof import('./../../src/components/ab-torrent-list-page.vue')['default'] AdvancedSection: typeof import('./../../src/components/advanced-section.vue')['default'] BangumiFilterField: typeof import('./../../src/components/bangumi-filter-field.vue')['default'] BangumiInfoTags: typeof import('./../../src/components/bangumi-info-tags.vue')['default'] diff --git a/webui/types/dts/router-type.d.ts b/webui/types/dts/router-type.d.ts index b3023e7b..c033ad82 100644 --- a/webui/types/dts/router-type.d.ts +++ b/webui/types/dts/router-type.d.ts @@ -41,6 +41,8 @@ declare module 'vue-router/auto/routes' { export interface RouteNamedMap { 'Index': RouteRecordInfo<'Index', '/', Record, Record>, 'Bangumi List': RouteRecordInfo<'Bangumi List', '/bangumi', Record, Record>, + 'Bangumi Torrents': RouteRecordInfo<'Bangumi Torrents', '/bangumi-torrents/:id', { id: ParamValue }, { id: ParamValue }>, + 'Orphan Torrents': RouteRecordInfo<'Orphan Torrents', '/bangumi-torrents/orphans', Record, Record>, 'Calendar': RouteRecordInfo<'Calendar', '/calendar', Record, Record>, 'Config': RouteRecordInfo<'Config', '/config', Record, Record>, 'Downloader': RouteRecordInfo<'Downloader', '/downloader', Record, Record>, diff --git a/webui/types/torrent.ts b/webui/types/torrent.ts index ffda8e80..262979a7 100644 --- a/webui/types/torrent.ts +++ b/webui/types/torrent.ts @@ -2,6 +2,9 @@ export interface Torrent { id: number; name: string; url: string; - homepage: string; + homepage: string | null; downloaded: boolean; + bangumi_id: number | null; + rss_id: number | null; + qb_hash: string | null; }