Merge feat/torrent-management: bangumi/orphan torrent management (#1051)

3.3 port of #1020 (orphan-torrent DB methods, 7 API endpoints, torrent
management pages), rebased onto 3.3-dev and reworked to the Soft Ink
ab-* component system per code review. Merged locally because the
rebased branch could not be force-pushed to the PR head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
This commit is contained in:
Estrella Pan
2026-07-07 11:34:47 +02:00
17 changed files with 1062 additions and 5 deletions

View File

@@ -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}。",
)
)

View File

@@ -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))

View File

@@ -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

View File

@@ -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() == []

View File

@@ -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<Torrent[]>(
`api/v1/bangumi/${bangumiId}/torrents`
);
return data;
},
/**
* 删除指定番剧关联的所有种子记录
*/
async deleteAllTorrents(bangumiId: number) {
const { data } = await axios.delete<ApiSuccess>(
`api/v1/bangumi/${bangumiId}/torrents`
);
return data;
},
/**
* 删除指定番剧下的单条种子记录
*/
async deleteTorrent(bangumiId: number, torrentId: number) {
const { data } = await axios.delete<ApiSuccess>(
`api/v1/bangumi/${bangumiId}/torrents/${torrentId}`
);
return data;
},
/**
* 获取所有孤儿种子(未关联番剧的种子记录)
*/
async getOrphanTorrents() {
const { data } = await axios.get<Torrent[]>(
'api/v1/bangumi/torrents/orphans'
);
return data;
},
/**
* 获取孤儿种子数量
*/
async getOrphanTorrentCount() {
const { data } = await axios.get<number>(
'api/v1/bangumi/torrents/orphans/count'
);
return data;
},
/**
* 删除所有孤儿种子
*/
async deleteOrphanTorrents() {
const { data } = await axios.delete<ApiSuccess>(
'api/v1/bangumi/torrents/orphans'
);
return data;
},
/**
* 删除单条孤儿种子
*/
async deleteOrphanTorrent(torrentId: number) {
const { data } = await axios.delete<ApiSuccess>(
`api/v1/bangumi/torrents/orphans/${torrentId}`
);
return data;
},
};

View File

@@ -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() {
</ab-modal>
<template #footer>
<ab-button size="sm" variant="ghost" @click="goToTorrents">
{{ $t('homepage.rule.view_torrents') }}
</ab-button>
<ab-button v-if="localRule.archived" size="sm" @click="emitUnarchive">
{{ $t('homepage.rule.unarchive') }}
</ab-button>

View File

@@ -0,0 +1,257 @@
<script lang="ts" setup>
import { Close, Delete, FullSelection } from '@icon-park/vue-next';
import { useConfirm } from '@/hooks/useConfirm';
import type { Torrent } from '#/torrent';
import { useTorrentList } from '@/hooks/useTorrentList';
const props = defineProps<{
title: string;
loadFn: () => Promise<Torrent[]>;
deleteOne: (id: number) => Promise<unknown>;
deleteAll: () => Promise<unknown>;
}>();
const {
torrents,
selectedIds,
load,
allSelected,
toggleAll,
toggleOne,
runDelete,
} = useTorrentList(props.loadFn);
const { t } = useI18n();
const { confirm } = useConfirm();
async function handleDeleteOne(id: number) {
await runDelete(
() => props.deleteOne(id),
t('homepage.torrents.deleted_one', { id })
);
}
async function handleDeleteSelected() {
const ids = [...selectedIds.value];
await runDelete(
() => Promise.all(ids.map((id) => props.deleteOne(id))),
t('homepage.torrents.deleted_count', { count: ids.length })
);
}
async function handleClearAll() {
const ok = await confirm({
title: t('homepage.torrents.deleteAll'),
body: t('homepage.torrents.confirm_clear'),
confirmText: t('homepage.torrents.deleteAll'),
danger: true,
});
if (!ok) return;
await runDelete(() => props.deleteAll(), t('homepage.torrents.cleared_all'));
}
onActivated(load);
</script>
<template>
<div class="page-torrents">
<div class="toolbar">
<h2 class="toolbar-title">{{ title }}</h2>
<div class="toolbar-actions">
<ab-button v-if="torrents.length > 0" size="sm" @click="toggleAll">
<template #icon>
<FullSelection :size="14" />
</template>
{{
allSelected
? $t('homepage.torrents.deselect_all')
: $t('homepage.torrents.select_all')
}}
</ab-button>
<ab-button
v-if="selectedIds.size > 0"
size="sm"
variant="danger"
@click="handleDeleteSelected"
>
<template #icon>
<Delete :size="14" />
</template>
{{ $t('homepage.torrents.delete_selected', { count: selectedIds.size }) }}
</ab-button>
<ab-button
v-if="torrents.length > 0"
size="sm"
variant="danger"
@click="handleClearAll"
>
<template #icon>
<Delete :size="14" />
</template>
{{ $t('homepage.torrents.deleteAll') }}
</ab-button>
</div>
</div>
<div v-if="torrents.length === 0" class="torrent-empty">
<ab-empty :title="$t('homepage.torrents.empty')" />
</div>
<div v-else class="torrent-scroll">
<div
v-for="torrent in torrents"
:key="torrent.id"
class="torrent-item"
:class="{ 'torrent-item--selected': selectedIds.has(torrent.id) }"
@click="toggleOne(torrent.id)"
>
<div
class="torrent-checkbox"
:class="{ 'torrent-checkbox--checked': selectedIds.has(torrent.id) }"
>
<svg
v-if="selectedIds.has(torrent.id)"
viewBox="0 0 16 16"
width="12"
height="12"
>
<path d="M6.5 11.5L3 8l1-1 2.5 2.5L12 4l1 1z" fill="currentColor" />
</svg>
</div>
<div class="torrent-info">
<span class="torrent-name">{{ torrent.name }}</span>
<div class="torrent-tags">
<ab-tag
v-if="torrent.downloaded"
type="success"
:title="$t('homepage.torrents.downloaded')"
/>
<ab-tag v-if="torrent.rss_id" type="info" title="RSS" />
<ab-tag
v-else
type="neutral"
:title="$t('homepage.torrents.source.manual')"
/>
</div>
</div>
<ab-icon-button
size="sm"
class="torrent-delete"
:label="$t('homepage.torrents.delete')"
@click.stop="handleDeleteOne(torrent.id)"
>
<Close :size="14" />
</ab-icon-button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.page-torrents {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
flex-shrink: 0;
gap: 8px;
flex-wrap: wrap;
}
.toolbar-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.torrent-empty {
padding: 12px;
}
.torrent-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 12px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.torrent-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: var(--radius-sm);
background: var(--color-surface-hover);
cursor: pointer;
transition: background-color var(--transition-fast);
&:hover {
background: color-mix(in srgb, var(--color-surface-hover) 80%, var(--color-primary));
}
&--selected {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
outline: 1px solid var(--color-primary);
}
}
.torrent-checkbox {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 4px;
border: 2px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
transition: all var(--transition-fast);
color: var(--color-white);
&--checked {
background: var(--color-primary);
border-color: var(--color-primary);
}
}
.torrent-info {
flex: 1;
min-width: 0;
}
.torrent-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--color-text);
}
.torrent-tags {
display: flex;
gap: 4px;
margin-top: 4px;
}
.torrent-delete {
margin-left: 8px;
}
</style>

View File

@@ -0,0 +1,62 @@
import type { Torrent } from '#/torrent';
export function useTorrentList(loadFn: () => Promise<Torrent[]>) {
const { t } = useI18n();
const message = useMessage();
const torrents = ref<Torrent[]>([]);
const selectedIds = ref<Set<number>>(new Set());
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<unknown>, label: string) {
try {
await fn();
message.success(label);
} catch {
message.error(t('homepage.torrents.delete_failed'));
} finally {
// 部分失败(Promise.all 扇出)时也要刷新,避免已删除的行残留
await load();
}
}
return {
torrents,
selectedIds,
load,
allSelected,
toggleAll,
toggleOne,
runDelete,
};
}

View File

@@ -249,7 +249,30 @@
"unnamed": "Unnamed Rule",
"season": "Season",
"year": "Year",
"yes_btn": "Yes"
"yes_btn": "Yes",
"view_torrents": "View torrents"
},
"others": {
"title": "Unmatched torrents"
},
"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": {

View File

@@ -249,7 +249,30 @@
"unnamed": "未命名规则",
"season": "季度",
"year": "年份",
"yes_btn": "是"
"yes_btn": "是",
"view_torrents": "查看种子"
},
"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": {

View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
import AbTorrentListPage from '@/components/ab-torrent-list-page.vue';
import { apiBangumi } from '@/api/bangumi';
definePage({
name: 'Bangumi Torrents',
});
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
// KeepAlive 会复用同一实例,路由参数必须保持响应式;非法 id 归一为 null
const bangumiId = computed(() => {
const id = Number((route.params as Record<string, string>).id);
return Number.isInteger(id) && id > 0 ? id : null;
});
// 非法 id 不请求 API,直接回番剧列表(仅当当前仍在本页时,
// 避免 KeepAlive 缓存期间路由切换触发误跳转)
watch(
bangumiId,
(id) => {
if (id === null && route.name === 'Bangumi Torrents') {
router.replace('/bangumi');
}
},
{ immediate: true }
);
// id 收窄后一次性构造 props,切换参数时 :key 触发重新挂载并加载
const pageProps = computed(() => {
const id = bangumiId.value;
if (id === null) return null;
return {
title: `${t('homepage.torrents.title')} #${id}`,
loadFn: () => apiBangumi.getTorrents(id),
deleteOne: (torrentId: number) => apiBangumi.deleteTorrent(id, torrentId),
deleteAll: () => apiBangumi.deleteAllTorrents(id),
};
});
</script>
<template>
<AbTorrentListPage v-if="pageProps" :key="bangumiId ?? -1" v-bind="pageProps" />
</template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import AbTorrentListPage from '@/components/ab-torrent-list-page.vue';
import { apiBangumi } from '@/api/bangumi';
definePage({
name: 'Orphan Torrents',
});
const { t } = useI18n();
const title = computed(() => t('homepage.others.title'));
</script>
<template>
<AbTorrentListPage
:title="title"
:load-fn="() => apiBangumi.getOrphanTorrents()"
:delete-one="(id: number) => apiBangumi.deleteOrphanTorrent(id)"
:delete-all="() => apiBangumi.deleteOrphanTorrents()"
/>
</template>

View File

@@ -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 {
</template>
</div>
</div>
<!-- Others card for orphan torrents -->
<div
v-if="orphanCount > 0"
key="__others__"
class="others-card"
role="button"
tabindex="0"
@click="goToOrphans"
@keydown.enter="goToOrphans"
>
<div class="others-poster">
<span class="others-icon">?</span>
<ab-badge :count="orphanCount" class="others-count" />
</div>
<div class="others-info">
<div class="others-title">{{ $t('homepage.others.title') }}</div>
</div>
</div>
</transition-group>
<!-- Archived section -->
@@ -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;

View File

@@ -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']

View File

@@ -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']

View File

@@ -41,6 +41,8 @@ declare module 'vue-router/auto/routes' {
export interface RouteNamedMap {
'Index': RouteRecordInfo<'Index', '/', Record<never, never>, Record<never, never>>,
'Bangumi List': RouteRecordInfo<'Bangumi List', '/bangumi', Record<never, never>, Record<never, never>>,
'Bangumi Torrents': RouteRecordInfo<'Bangumi Torrents', '/bangumi-torrents/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'Orphan Torrents': RouteRecordInfo<'Orphan Torrents', '/bangumi-torrents/orphans', Record<never, never>, Record<never, never>>,
'Calendar': RouteRecordInfo<'Calendar', '/calendar', Record<never, never>, Record<never, never>>,
'Config': RouteRecordInfo<'Config', '/config', Record<never, never>, Record<never, never>>,
'Downloader': RouteRecordInfo<'Downloader', '/downloader', Record<never, never>, Record<never, never>>,

View File

@@ -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;
}