diff --git a/backend/src/module/api/bangumi.py b/backend/src/module/api/bangumi.py index bf6e577fe..a4426cdc5 100644 --- a/backend/src/module/api/bangumi.py +++ b/backend/src/module/api/bangumi.py @@ -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, ) @@ -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}。", + ) + ) diff --git a/backend/src/module/database/torrent.py b/backend/src/module/database/torrent.py index d4fb54306..be9e28499 100644 --- a/backend/src/module/database/torrent.py +++ b/backend/src/module/database/torrent.py @@ -1,5 +1,6 @@ import logging +from sqlalchemy import delete, func from sqlmodel import Session, select from module.models import Torrent @@ -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 diff --git a/backend/src/module/parser/analyser/mikan_parser.py b/backend/src/module/parser/analyser/mikan_parser.py index 109daaaf6..7e4508041 100644 --- a/backend/src/module/parser/analyser/mikan_parser.py +++ b/backend/src/module/parser/analyser/mikan_parser.py @@ -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__': diff --git a/backend/src/module/rss/analyser.py b/backend/src/module/rss/analyser.py index cbd7a9afd..0bb8cdb97 100644 --- a/backend/src/module/rss/analyser.py +++ b/backend/src/module/rss/analyser.py @@ -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 diff --git a/webui/src/api/bangumi.ts b/webui/src/api/bangumi.ts index 74ac80ce3..a8cdf2731 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 = { /** @@ -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( + `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 000000000..2b8dd71fd --- /dev/null +++ b/webui/src/components/ab-torrent-list-page.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/webui/src/hooks/useTorrentList.ts b/webui/src/hooks/useTorrentList.ts new file mode 100644 index 000000000..3800abf85 --- /dev/null +++ b/webui/src/hooks/useTorrentList.ts @@ -0,0 +1,60 @@ +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 dbaa4ef1d..5c0050686 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -154,6 +154,9 @@ "step3_desc": "AutoBangumi will automatically download and rename new episodes for you.", "add_rss_btn": "Add RSS Feed" }, + "others": { + "title": "Others" + }, "rule": { "apply": "Apply", "delete": "Delete", @@ -181,6 +184,25 @@ "season": "Season", "year": "Year", "yes_btn": "Yes" + }, + "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 5081dc19f..2cf1c9171 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -154,6 +154,9 @@ "step3_desc": "AutoBangumi 将自动下载并重命名新剧集。", "add_rss_btn": "添加 RSS 订阅" }, + "others": { + "title": "未匹配种子" + }, "rule": { "apply": "应用", "delete": "删除", @@ -181,6 +184,25 @@ "season": "季度", "year": "年份", "yes_btn": "是" + }, + "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 000000000..dbc4c152b --- /dev/null +++ b/webui/src/pages/index/bangumi-torrents/[id]/index.vue @@ -0,0 +1,22 @@ + + + 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 000000000..a526b1212 --- /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 d454afa67..6b361fa0f 100644 --- a/webui/src/pages/index/bangumi.vue +++ b/webui/src/pages/index/bangumi.vue @@ -15,10 +15,27 @@ const skeletonCount = 8; // Number of skeleton cards to show const refreshing = ref(false); +// Orphan torrents count for 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; } @@ -26,6 +43,7 @@ async function onRefresh() { onActivated(() => { getAll(); + loadOrphanCount(); }); // Group bangumi by official_title + season @@ -175,6 +193,25 @@ function groupNeedsReview(group: BangumiGroup): boolean { + + +
+
+ ? +
{{ orphanCount }}
+
+
+
{{ $t('homepage.others.title') }}
+
+
@@ -340,6 +377,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/torrent.ts b/webui/types/torrent.ts index ffda8e80a..262979a72 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; }