mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 05:37:14 +08:00
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:
@@ -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}。",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
188
backend/src/test/test_api_bangumi_torrents.py
Normal file
188
backend/src/test/test_api_bangumi_torrents.py
Normal 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
|
||||
@@ -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() == []
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
257
webui/src/components/ab-torrent-list-page.vue
Normal file
257
webui/src/components/ab-torrent-list-page.vue
Normal 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>
|
||||
62
webui/src/hooks/useTorrentList.ts
Normal file
62
webui/src/hooks/useTorrentList.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
46
webui/src/pages/index/bangumi-torrents/[id]/index.vue
Normal file
46
webui/src/pages/index/bangumi-torrents/[id]/index.vue
Normal 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>
|
||||
20
webui/src/pages/index/bangumi-torrents/orphans/index.vue
Normal file
20
webui/src/pages/index/bangumi-torrents/orphans/index.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
1
webui/types/dts/auto-imports.d.ts
vendored
1
webui/types/dts/auto-imports.d.ts
vendored
@@ -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']
|
||||
|
||||
1
webui/types/dts/components.d.ts
vendored
1
webui/types/dts/components.d.ts
vendored
@@ -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']
|
||||
|
||||
2
webui/types/dts/router-type.d.ts
vendored
2
webui/types/dts/router-type.d.ts
vendored
@@ -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>>,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user