From 679bd2621c074dd96216c84a1d74c52de7ffdf2b Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Fri, 3 Jul 2026 14:23:19 +0200 Subject: [PATCH 1/3] feat: manage bangumi torrents and orphan torrents (port of #1020) Port zhushanwen321's torrent-management feature (#1020) from the 3.2 sync architecture to the 3.3 async design. Backend: - TorrentDatabase: search_orphans / count_orphans / delete_orphans / delete_obj - bangumi API: list/delete endpoints for per-bangumi torrents and orphan torrents (bangumi_id IS NULL); literal /torrents/orphans routes registered before /{bangumi_id}/torrents WebUI: - Others card on the bangumi list with orphan count badge - Torrent list pages (per-bangumi and orphans) with multi-select, batch delete, and clear-all confirm dialog The PR's mikan_parser and rss/analyser fixes are not ported: 3.3-dev already handles failed poster downloads and missing elements. Co-authored-by: zhushanwen321 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs --- backend/src/module/api/bangumi.py | 133 ++++++- backend/src/module/database/torrent.py | 37 ++ backend/src/test/test_api_bangumi_torrents.py | 188 ++++++++++ backend/src/test/test_database.py | 75 ++++ webui/src/api/bangumi.ts | 73 ++++ webui/src/components/ab-torrent-list-page.vue | 349 ++++++++++++++++++ webui/src/hooks/useTorrentList.ts | 62 ++++ webui/src/i18n/en.json | 22 ++ webui/src/i18n/zh-CN.json | 22 ++ .../index/bangumi-torrents/[id]/index.vue | 23 ++ .../index/bangumi-torrents/orphans/index.vue | 20 + webui/src/pages/index/bangumi.vue | 117 +++++- webui/types/dts/auto-imports.d.ts | 1 + webui/types/dts/components.d.ts | 1 + webui/types/dts/router-type.d.ts | 2 + webui/types/torrent.ts | 5 +- 16 files changed, 1127 insertions(+), 3 deletions(-) create mode 100644 backend/src/test/test_api_bangumi_torrents.py create mode 100644 webui/src/components/ab-torrent-list-page.vue create mode 100644 webui/src/hooks/useTorrentList.ts create mode 100644 webui/src/pages/index/bangumi-torrents/[id]/index.vue create mode 100644 webui/src/pages/index/bangumi-torrents/orphans/index.vue 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..1ef2e038 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,42 @@ 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() + count = result.rowcount + 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-torrent-list-page.vue b/webui/src/components/ab-torrent-list-page.vue new file mode 100644 index 00000000..ecd52cd0 --- /dev/null +++ b/webui/src/components/ab-torrent-list-page.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/webui/src/hooks/useTorrentList.ts b/webui/src/hooks/useTorrentList.ts new file mode 100644 index 00000000..3a0b96c2 --- /dev/null +++ b/webui/src/hooks/useTorrentList.ts @@ -0,0 +1,62 @@ +import type { Torrent } from '#/torrent'; + +export function useTorrentList(loadFn: () => Promise) { + const { t } = useI18n(); + const message = useMessage(); + const torrents = ref([]); + const selectedIds = ref>(new Set()); + const showConfirmClear = ref(false); + + async function load() { + try { + torrents.value = await loadFn(); + } catch { + torrents.value = []; + message.error(t('homepage.torrents.load_failed')); + } + selectedIds.value = new Set(); + } + + const allSelected = computed( + () => + torrents.value.length > 0 && + selectedIds.value.size === torrents.value.length + ); + + function toggleAll() { + selectedIds.value = allSelected.value + ? new Set() + : new Set(torrents.value.map((t) => t.id)); + } + + function toggleOne(id: number) { + const next = new Set(selectedIds.value); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + selectedIds.value = next; + } + + async function runDelete(fn: () => Promise, label: string) { + try { + await fn(); + message.success(label); + await load(); + } catch { + message.error(t('homepage.torrents.delete_failed')); + } + } + + return { + torrents, + selectedIds, + showConfirmClear, + load, + allSelected, + toggleAll, + toggleOne, + runDelete, + }; +} diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 994d6a62..b6a33d80 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -250,6 +250,28 @@ "season": "Season", "year": "Year", "yes_btn": "Yes" + }, + "others": { + "title": "Others" + }, + "torrents": { + "title": "Torrents", + "delete": "Delete", + "deleteAll": "Delete All", + "downloaded": "Downloaded", + "empty": "No torrents", + "confirm_clear": "Are you sure you want to delete all torrents? This cannot be undone.", + "select_all": "Select All", + "deselect_all": "Deselect All", + "delete_selected": "Delete ({count})", + "deleted_one": "Deleted torrent {id}", + "deleted_count": "Deleted {count} torrents", + "cleared_all": "Cleared all torrents", + "load_failed": "Failed to load torrent list", + "delete_failed": "Failed to delete", + "source": { + "manual": "Manual" + } } }, "log": { diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index 23844773..5a767865 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -250,6 +250,28 @@ "season": "季度", "year": "年份", "yes_btn": "是" + }, + "others": { + "title": "未匹配种子" + }, + "torrents": { + "title": "种子列表", + "delete": "删除", + "deleteAll": "清空所有", + "downloaded": "已下载", + "empty": "暂无种子", + "confirm_clear": "确定要清空所有种子吗?此操作不可撤销。", + "select_all": "全选", + "deselect_all": "取消全选", + "delete_selected": "删除 ({count})", + "deleted_one": "已删除种子 {id}", + "deleted_count": "已删除 {count} 条种子", + "cleared_all": "已清空所有种子", + "load_failed": "加载种子列表失败", + "delete_failed": "删除失败", + "source": { + "manual": "手动/订阅" + } } }, "log": { diff --git a/webui/src/pages/index/bangumi-torrents/[id]/index.vue b/webui/src/pages/index/bangumi-torrents/[id]/index.vue new file mode 100644 index 00000000..e9834342 --- /dev/null +++ b/webui/src/pages/index/bangumi-torrents/[id]/index.vue @@ -0,0 +1,23 @@ + + + diff --git a/webui/src/pages/index/bangumi-torrents/orphans/index.vue b/webui/src/pages/index/bangumi-torrents/orphans/index.vue new file mode 100644 index 00000000..a526b121 --- /dev/null +++ b/webui/src/pages/index/bangumi-torrents/orphans/index.vue @@ -0,0 +1,20 @@ + + + diff --git a/webui/src/pages/index/bangumi.vue b/webui/src/pages/index/bangumi.vue index 0b062f97..abbe5ada 100644 --- a/webui/src/pages/index/bangumi.vue +++ b/webui/src/pages/index/bangumi.vue @@ -23,10 +23,27 @@ const skeletonCount = 8; // Number of skeleton cards to show const refreshing = ref(false); +// Orphan torrents count for the Others card +const orphanCount = ref(0); + +async function loadOrphanCount() { + try { + orphanCount.value = await apiBangumi.getOrphanTorrentCount(); + } catch { + orphanCount.value = 0; + } +} + +const router = useRouter(); + +function goToOrphans() { + router.push('/bangumi-torrents/orphans'); +} + async function onRefresh() { refreshing.value = true; try { - await getAll(); + await Promise.all([getAll(), loadOrphanCount()]); } finally { refreshing.value = false; } @@ -34,6 +51,7 @@ async function onRefresh() { onActivated(() => { getAll(); + loadOrphanCount(); }); // Group bangumi by official_title + season @@ -212,6 +230,25 @@ function groupNeedsReview(group: BangumiGroup): boolean { + + +
+
+ ? +
{{ orphanCount }}
+
+
+
{{ $t('homepage.others.title') }}
+
+
@@ -402,6 +439,84 @@ 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-badge { + position: absolute; + top: -8px; + right: -8px; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + background: var(--color-primary); + color: #fff; + font-size: 12px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + box-shadow: 0 2px 6px rgba(124, 77, 255, 0.4); +} + +.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 3513d14b..e798a381 100644 --- a/webui/types/dts/auto-imports.d.ts +++ b/webui/types/dts/auto-imports.d.ts @@ -131,6 +131,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; } From 7c1a7938b10031b2e36425b6fa311a69293219e0 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Fri, 3 Jul 2026 14:26:40 +0200 Subject: [PATCH 2/3] fix(types): annotate CursorResult.rowcount for mypy in delete_orphans Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs --- backend/src/module/database/torrent.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/module/database/torrent.py b/backend/src/module/database/torrent.py index 1ef2e038..a7d72a15 100644 --- a/backend/src/module/database/torrent.py +++ b/backend/src/module/database/torrent.py @@ -154,7 +154,8 @@ class TorrentDatabase: delete(Torrent).where(Torrent.bangumi_id.is_(None)) # type: ignore[union-attr] ) await self.session.commit() - count = result.rowcount + # 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 From d91686486bb9504199af807ead8bc513a93aa2be Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 10:58:58 +0200 Subject: [PATCH 3/3] refactor(webui): rework torrent-management UI to Soft Ink ab-* layer Post-rebase rework addressing review findings on #1051 (the branch predated the component-system migration): - Replace retired ab-popup confirm with the useConfirm() promise hook - Replace NButton and bare