Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
717ad11
Merge pull request #1006 from EstrellaXD/3.2-dev
EstrellaXD Mar 1, 2026
55c467a
docs: add mikan_parser fix design spec
zhushanwen321 Apr 6, 2026
ce4aa68
docs: address spec review feedback for mikan_parser fix
zhushanwen321 Apr 6, 2026
dea4062
fix(parser): add None safety to mikan_parser and prevent downgrade ov…
zhushanwen321 Apr 6, 2026
7f413d5
docs: add torrent management design spec
zhushanwen321 Apr 6, 2026
7d73f2c
docs: address spec review feedback for torrent management
zhushanwen321 Apr 6, 2026
5914c20
docs: add torrent management implementation plan
zhushanwen321 Apr 6, 2026
238777a
docs: address plan review feedback for torrent management
zhushanwen321 Apr 6, 2026
5848b95
feat(database): add torrent query and delete methods for management
zhushanwen321 Apr 6, 2026
b2eea97
feat(api): add torrent management endpoints for bangumi
zhushanwen321 Apr 6, 2026
62f5985
feat(webui): add torrent management API functions and Torrent type
zhushanwen321 Apr 6, 2026
6784932
feat(webui): add torrent list component
zhushanwen321 Apr 6, 2026
8866f1b
feat(i18n): add torrent management translation keys
zhushanwen321 Apr 6, 2026
bac82c6
feat(webui): add torrent list pages for bangumi and orphans
zhushanwen321 Apr 6, 2026
d380d6b
feat(webui): add Others card to bangumi list for orphan torrents
zhushanwen321 Apr 6, 2026
ba96634
fix: address code review issues for torrent management
zhushanwen321 Apr 6, 2026
cd07ee1
fix(webui): move torrent pages out of bangumi nested routes
zhushanwen321 Apr 6, 2026
152d53f
fix(webui): torrent management UX improvements
zhushanwen321 Apr 6, 2026
3512857
fix(webui): fix use-before-define in ab-torrent-list
zhushanwen321 Apr 7, 2026
138e5ec
refactor: simplify torrent management code
zhushanwen321 Apr 7, 2026
515fb30
chore: remove planning docs
zhushanwen321 Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion backend/src/module/api/bangumi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from module.conf import settings
from module.database import Database
from module.manager import TorrentManager
from module.models import APIResponse, Bangumi, BangumiUpdate
from module.models import APIResponse, Bangumi, BangumiUpdate, ResponseModel, Torrent
from module.parser.analyser.offset_detector import (
OffsetSuggestion as DetectorSuggestion,
)
Expand Down Expand Up @@ -381,3 +381,128 @@ async def set_weekday(bangumi_id: int, request: SetWeekdayRequest):
"msg_zh": f"未找到番剧 {bangumi_id}。",
},
)


# ── Torrent Management ──
# orphans 端点必须在 {id}/torrents 之前注册,避免路由冲突

@router.get(
"/torrents/orphans",
response_model=list[Torrent],
dependencies=[Depends(get_current_user)],
)
async def get_orphan_torrents():
with TorrentManager() as manager:
return manager.torrent.search_orphans()


@router.get(
"/torrents/orphans/count",
response_model=int,
dependencies=[Depends(get_current_user)],
)
async def get_orphan_torrent_count():
with TorrentManager() as manager:
return manager.torrent.count_orphans()


@router.delete(
"/torrents/orphans",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def delete_orphan_torrents():
with TorrentManager() as manager:
count = manager.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(
"/torrents/orphans/{torrent_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def delete_single_orphan_torrent(torrent_id: int):
with TorrentManager() as manager:
torrent = manager.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}。",
},
)
manager.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(
"/{bangumi_id}/torrents",
response_model=list[Torrent],
dependencies=[Depends(get_current_user)],
)
async def get_bangumi_torrents(bangumi_id: int):
with TorrentManager() as manager:
return manager.torrent.search_by_bangumi_id(bangumi_id)


@router.delete(
"/{bangumi_id}/torrents",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def delete_bangumi_torrents(bangumi_id: int):
with TorrentManager() as manager:
count = manager.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(
"/{bangumi_id}/torrents/{torrent_id}",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def delete_single_torrent(bangumi_id: int, torrent_id: int):
with TorrentManager() as manager:
torrent = manager.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}。",
},
)
manager.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}。",
)
)
43 changes: 43 additions & 0 deletions backend/src/module/database/torrent.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging

from sqlalchemy import delete, func
from sqlmodel import Session, select

from module.models import Torrent
Expand Down Expand Up @@ -101,3 +102,45 @@ def update_qb_hash(self, torrent_id: int, qb_hash: str) -> bool:
logger.debug("Updated qb_hash for torrent %s: %s", torrent_id, qb_hash)
return True
return False

def search_by_bangumi_id(self, bangumi_id: int) -> list[Torrent]:
result = self.session.execute(
select(Torrent).where(Torrent.bangumi_id == bangumi_id)
)
return list(result.scalars().all())

def search_orphans(self) -> list[Torrent]:
result = self.session.execute(
select(Torrent).where(Torrent.bangumi_id.is_(None))
)
return list(result.scalars().all())

def count_orphans(self) -> int:
result = self.session.execute(
select(func.count()).select_from(Torrent).where(Torrent.bangumi_id.is_(None))
)
return result.scalar_one()

def delete_one(self, torrent_id: int) -> bool:
torrent = self.search(torrent_id)
if torrent is None:
return False
self.session.delete(torrent)
self.session.commit()
logger.debug("Deleted torrent %s.", torrent_id)
return True

def delete_obj(self, torrent: Torrent) -> None:
self.session.delete(torrent)
self.session.commit()
logger.debug("Deleted torrent %s.", torrent.id)

def delete_orphans(self) -> int:
result = self.session.execute(
delete(Torrent).where(Torrent.bangumi_id.is_(None))
)
self.session.commit()
count = result.rowcount
if count > 0:
logger.debug("Deleted %s orphan torrents.", count)
return count
56 changes: 39 additions & 17 deletions backend/src/module/parser/analyser/mikan_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,48 @@ async def mikan_parser(homepage: str):
if homepage in _mikan_cache:
return _mikan_cache[homepage]
root_path = parse_url(homepage).host
if not root_path:
logger.warning("[Mikan] Invalid homepage URL: %s", homepage)
return ("", "")
async with RequestContent() as req:
content = await req.get_html(homepage)
if not content:
logger.warning("[Mikan] Failed to fetch homepage: %s", homepage)
return ("", "")
soup = BeautifulSoup(content, "html.parser")
poster_div = soup.find("div", {"class": "bangumi-poster"}).get("style")
official_title = soup.select_one(
'p.bangumi-title a[href^="/Home/Bangumi/"]'
).text
official_title = re.sub(r"第.*季", "", official_title).strip()
if poster_div:
poster_path = poster_div.split("url('")[1].split("')")[0]
poster_path = poster_path.split("?")[0]
img = await req.get_content(f"https://{root_path}{poster_path}")
suffix = poster_path.split(".")[-1]
poster_link = save_image(img, suffix)
result = (poster_link, official_title)
_mikan_cache[homepage] = result
return result
result = ("", "")
_mikan_cache[homepage] = result
return result

poster_link = ""
poster_div = soup.find("div", {"class": "bangumi-poster"})
if poster_div is None:
logger.warning("[Mikan] No poster div found on: %s", homepage)
else:
poster_style = poster_div.get("style")
if poster_style and "url('" in poster_style:
try:
poster_path = poster_style.split("url('")[1].split("')")[0]
poster_path = poster_path.split("?")[0]
img = await req.get_content(f"https://{root_path}{poster_path}")
if img:
suffix = poster_path.rsplit(".", 1)[-1] if "." in poster_path else "jpg"
poster_link = save_image(img, suffix)
else:
logger.warning("[Mikan] Failed to download poster from: %s", homepage)
except (IndexError, ValueError) as e:
logger.warning("[Mikan] Failed to parse poster style on %s: %s", homepage, e)
else:
logger.warning("[Mikan] Poster div has no style or url() on: %s", homepage)

official_title = ""
title_elem = soup.select_one('p.bangumi-title a[href^="/Home/Bangumi/"]')
if title_elem is None:
logger.warning("[Mikan] No official title found on: %s", homepage)
else:
official_title = re.sub(r"第.*季", "", title_elem.text).strip()

# 只缓存成功结果(失败不缓存,下次 rss_loop 会重试)
if poster_link and official_title:
_mikan_cache[homepage] = (poster_link, official_title)
return (poster_link, official_title)


if __name__ == '__main__':
Expand Down
7 changes: 5 additions & 2 deletions backend/src/module/rss/analyser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ class RSSAnalyser(TitleParser):
async def official_title_parser(self, bangumi: Bangumi, rss: RSSItem, torrent: Torrent):
if rss.parser == "mikan":
try:
bangumi.poster_link, bangumi.official_title = await self.mikan_parser(
poster_link, official_title = await self.mikan_parser(
torrent.homepage
)
if official_title:
bangumi.official_title = official_title
if poster_link:
bangumi.poster_link = poster_link
except AttributeError:
logger.warning("[Parser] Mikan torrent has no homepage info.")
pass
elif rss.parser == "tmdb":
tmdb_title, season, year, poster_link = await self.tmdb_parser(
bangumi.official_title, bangumi.season, settings.rss_parser.language
Expand Down
52 changes: 52 additions & 0 deletions webui/src/api/bangumi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
OffsetSuggestion,
} from '#/bangumi';
import type { ApiSuccess } from '#/api';
import type { Torrent } from '#/torrent';

export const apiBangumi = {
/**
Expand Down Expand Up @@ -246,4 +247,55 @@ export const apiBangumi = {
air_weekday: bangumi.air_weekday ?? null,
})) as BangumiRule[];
},

// ── 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;
},
};
Loading