Merge pull request #1070 from EstrellaXD/fix/issues-1067-1068

This commit is contained in:
Estrella Pan
2026-07-09 15:42:56 +02:00
8 changed files with 206 additions and 23 deletions

View File

@@ -2,6 +2,7 @@ import logging
from module.conf import settings
from module.database import Database
from module.database.bangumi import normalize_save_path
from module.downloader import DownloadClient
from module.downloader.path import gen_save_path
from module.downloader.rules import build_rss_rule
@@ -21,10 +22,11 @@ class TorrentManager:
async def __match_torrents_list(data: Bangumi | BangumiUpdate) -> list:
async with DownloadClient() as client:
torrents = await client.get_torrent_info(status_filter=None)
target_save_path = normalize_save_path(data.save_path)
return [
torrent.get("hash", torrent.get("infohash_v1", ""))
for torrent in torrents
if torrent.get("save_path") == data.save_path
if normalize_save_path(torrent.get("save_path")) == target_save_path
]
async def delete_torrents(self, data: Bangumi, client: DownloadClient):

View File

@@ -3,6 +3,7 @@ import logging
from collections import OrderedDict
from typing import TypeAlias
from module.conf import settings
from module.models import Bangumi, RSSItem, Torrent
from module.network import RequestContent
from module.parser.analyser.tmdb_parser import tmdb_parser
@@ -23,11 +24,14 @@ SEARCH_KEY = [
BangumiJSON: TypeAlias = str
# Cache for TMDB poster lookups by official_title. Bounded (LRU-ish,
# Cache for TMDB preview lookups by official_title. Bounded (LRU-ish,
# oldest-evicted) like _tmdb_cache/_mikan_cache — was previously a plain dict
# and grew unbounded for the life of the process.
# and grew unbounded for the life of the process. Values are keyed by parser
# language because the title is localized while the poster URL usually is not.
_POSTER_CACHE_MAX = 512
_poster_cache: "OrderedDict[str, str | None]" = OrderedDict()
_poster_cache: "OrderedDict[str, dict[str, tuple[str | None, str | None]]]" = (
OrderedDict()
)
def reset_cache() -> None:
@@ -44,23 +48,31 @@ class SearchTorrent:
async with RequestContent() as req:
return await req.get_torrents(rss_item.url)
async def _fetch_tmdb_poster(self, title: str) -> str | None:
"""Fetch poster from TMDB if not in cache."""
if title in _poster_cache:
async def _fetch_tmdb_preview(self, title: str) -> tuple[str | None, str | None]:
"""Fetch localized title and poster URL from TMDB for search previews."""
language = settings.rss_parser.language
if title in _poster_cache and language in _poster_cache[title]:
_poster_cache.move_to_end(title)
return _poster_cache[title]
return _poster_cache[title][language]
localized_title = None
poster_link = None
try:
tmdb_info = await tmdb_parser(title, "zh", test=True)
if tmdb_info and tmdb_info.poster_link:
tmdb_info = await tmdb_parser(title, language, test=True)
if tmdb_info:
localized_title = tmdb_info.title
poster_link = tmdb_info.poster_link
except Exception as e:
logger.debug("Failed to fetch TMDB poster for %s: %s", title, e)
logger.debug("Failed to fetch TMDB preview for %s: %s", title, e)
if len(_poster_cache) >= _POSTER_CACHE_MAX:
if title not in _poster_cache and len(_poster_cache) >= _POSTER_CACHE_MAX:
_poster_cache.popitem(last=False)
_poster_cache[title] = poster_link
_poster_cache.setdefault(title, {})[language] = (localized_title, poster_link)
return localized_title, poster_link
async def _fetch_tmdb_poster(self, title: str) -> str | None:
"""Fetch poster from TMDB if not in cache."""
_, poster_link = await self._fetch_tmdb_preview(title)
return poster_link
async def analyse_keyword(
@@ -85,12 +97,14 @@ class SearchTorrent:
if special_link not in exist_list:
bangumi.rss_link = special_link
exist_list.append(special_link)
# Fetch poster from TMDB if missing
if not bangumi.poster_link and bangumi.official_title:
tmdb_poster = await self._fetch_tmdb_poster(
# Fetch localized title and poster URL from TMDB if available.
if bangumi.official_title:
tmdb_title, tmdb_poster = await self._fetch_tmdb_preview(
bangumi.official_title
)
if tmdb_poster:
if tmdb_title:
bangumi.official_title = tmdb_title
if not bangumi.poster_link and tmdb_poster:
bangumi.poster_link = tmdb_poster
yield json.dumps(bangumi.dict(), separators=(",", ":"))

View File

@@ -1,10 +1,12 @@
"""Tests for TorrentManager.delete_rule RSS cleanup (#1053).
"""Tests for TorrentManager rule management.
删除番剧时应停用其独立订阅aggregate=False的孤儿 RSS 条目(停用而非删除:
无法区分搜索订阅与用户手动添加的独立订阅,删除会连带清掉其他番剧的种子
去重记录);聚合订阅与仍被其他番剧引用的订阅不受影响。
"""
from unittest.mock import AsyncMock, patch
from module.database import Database
from module.manager import TorrentManager
from test.factories import make_bangumi, make_rss_item
@@ -117,3 +119,34 @@ class TestDeleteRuleRSSCleanup:
await manager.delete_rule(1, file=False)
assert len(await db.torrent.search_rss(rss_item.id)) == 1
class TestTorrentMatching:
async def test_match_torrents_list_normalizes_save_path_separators(self):
"""qBittorrent-on-Windows returns backslashes; DB paths may use slashes."""
bangumi = make_bangumi(
save_path="D:/Downloads/Bangumi/Test Anime (2024)/Season 1"
)
mock_client = AsyncMock()
mock_client.get_torrent_info = AsyncMock(
return_value=[
{
"hash": "matched",
"save_path": "D:\\Downloads\\Bangumi\\Test Anime (2024)\\Season 1\\",
},
{
"hash": "other",
"save_path": "D:/Downloads/Bangumi/Other/Season 1",
},
]
)
with patch("module.manager.torrent.DownloadClient") as mock_download_client:
mock_download_client.return_value.__aenter__ = AsyncMock(
return_value=mock_client
)
mock_download_client.return_value.__aexit__ = AsyncMock(return_value=False)
hashes = await TorrentManager._TorrentManager__match_torrents_list(bangumi)
assert hashes == ["matched"]

View File

@@ -1,9 +1,12 @@
"""Tests for search providers: URL construction, keyword handling."""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from module.conf import settings
from module.models import Bangumi, RSSItem
from module.searcher.provider import search_url
@@ -181,7 +184,9 @@ class TestPosterCache:
def test_reset_cache_clears_poster_cache(self):
from module.searcher import searcher as searcher_module
searcher_module._poster_cache["Test Anime"] = "http://example.com/p.jpg"
searcher_module._poster_cache["Test Anime"] = {
"zh": (None, "http://example.com/p.jpg")
}
assert len(searcher_module._poster_cache) > 0
searcher_module.reset_cache()
@@ -207,3 +212,53 @@ class TestPosterCache:
# The oldest entry ("Title 0") was evicted; the rest remain.
assert "Title 0" not in searcher_module._poster_cache
assert "Title 3" in searcher_module._poster_cache
class TestSearchLocalization:
async def test_search_result_uses_configured_tmdb_language(self, monkeypatch):
"""Interactive search should not fall back to raw-parser zh/en titles for jp."""
from module.searcher.searcher import SearchTorrent
from test.factories import make_bangumi, make_torrent
monkeypatch.setattr(settings.rss_parser, "language", "jp")
search = SearchTorrent()
search.search_torrents = AsyncMock(
return_value=[make_torrent(name="[Group] English Raw - 01 [1080p]")]
)
raw_bangumi = make_bangumi(
official_title="中文标题",
title_raw="English Raw",
poster_link=None,
)
tmdb_info = SimpleNamespace(
title="日本語タイトル",
poster_link="https://image.tmdb.org/t/p/w780/poster.jpg",
)
with (
patch(
"module.searcher.searcher.search_url",
return_value=RSSItem(
url="https://example.com/rss",
parser="mikan",
aggregate=False,
),
),
patch(
"module.rss.analyser.TitleParser.raw_parser",
new=AsyncMock(return_value=raw_bangumi),
),
patch(
"module.searcher.searcher.tmdb_parser",
new=AsyncMock(return_value=tmdb_info),
) as mock_tmdb_parser,
):
results = [
json.loads(item)
async for item in search.analyse_keyword(["English", "Raw"])
]
assert results[0]["official_title"] == "日本語タイトル"
assert results[0]["poster_link"] == tmdb_info.poster_link
mock_tmdb_parser.assert_awaited_once_with("中文标题", "jp", test=True)

View File

@@ -220,6 +220,8 @@ function emitUnarchive() {
v-else
v-model:show="show"
:title="$t('homepage.rule.edit_rule')"
mobile-fullscreen
:avoid-keyboard="false"
@close="close"
>
<!-- Needs Review Warning Banner -->

View File

@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import AbBottomSheet from '../ab-bottom-sheet.vue';
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
function installVisualViewport() {
const viewport = {
height: 500,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: viewport,
});
return viewport;
}
async function mountSheet(props = {}) {
const wrapper = mount(AbBottomSheet, {
props: { show: true, title: 'Edit rule', ...props },
slots: { default: '<input id="title-input" />' },
attachTo: document.body,
});
await new Promise((resolve) => setTimeout(resolve));
return wrapper;
}
describe('ab-bottom-sheet', () => {
it('should render the fullscreen panel class when requested', async () => {
await mountSheet({ fullscreen: true, avoidKeyboard: false });
expect(
document.querySelector('.ab-bottom-sheet__panel--fullscreen')
).not.toBeNull();
});
it('should not translate the sheet for keyboard changes when avoidance is disabled', async () => {
const viewport = installVisualViewport();
await mountSheet({ avoidKeyboard: false });
expect(viewport.addEventListener).not.toHaveBeenCalled();
});
});

View File

@@ -14,10 +14,14 @@ const props = withDefaults(
title?: string;
closeable?: boolean;
maxHeight?: string;
fullscreen?: boolean;
avoidKeyboard?: boolean;
}>(),
{
closeable: true,
maxHeight: '85dvh',
fullscreen: false,
avoidKeyboard: true,
}
);
@@ -31,6 +35,9 @@ const dragHandleRef = ref<HTMLElement | null>(null);
const translateY = ref(0);
const isDragging = ref(false);
const keyboardHeight = ref(0);
const panelMaxHeight = computed(() =>
props.fullscreen ? '100dvh' : props.maxHeight
);
// Handle iOS Safari virtual keyboard using visualViewport API
function handleViewportResize() {
@@ -44,9 +51,9 @@ function handleViewportResize() {
// Set up visualViewport listeners when sheet is shown
watch(
() => props.show,
(isVisible) => {
if (isVisible && window.visualViewport) {
() => [props.show, props.avoidKeyboard] as const,
([isVisible, shouldAvoidKeyboard]) => {
if (isVisible && shouldAvoidKeyboard && window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize);
window.visualViewport.addEventListener('scroll', handleViewportResize);
handleViewportResize();
@@ -134,7 +141,8 @@ function close() {
<DialogPanel
ref="sheetRef"
class="ab-bottom-sheet__panel"
:style="[sheetStyle, { maxHeight }]"
:class="{ 'ab-bottom-sheet__panel--fullscreen': fullscreen }"
:style="[sheetStyle, { maxHeight: panelMaxHeight }]"
>
<!-- Drag handle -->
<div ref="dragHandleRef" class="ab-bottom-sheet__handle">
@@ -206,6 +214,18 @@ function close() {
@supports not (max-height: 1dvh) {
max-height: 85vh;
}
&--fullscreen {
height: 100dvh;
max-height: 100dvh;
border-radius: 0;
@include safeAreaTop(padding-top);
@supports not (height: 1dvh) {
height: 100vh;
max-height: 100vh;
}
}
}
&__handle {

View File

@@ -22,6 +22,10 @@ const props = withDefaults(
/** 隐藏右上角 Xconfirm 类弹窗Esc/遮罩仍可关闭) */
showClose?: boolean;
maxHeight?: string;
/** 移动端用整屏抽屉承载长表单 */
mobileFullscreen?: boolean;
/** 移动端软键盘出现时是否把抽屉整体上移 */
avoidKeyboard?: boolean;
}>(),
{
title: '',
@@ -29,6 +33,8 @@ const props = withDefaults(
closable: true,
showClose: true,
maxHeight: '85dvh',
mobileFullscreen: false,
avoidKeyboard: true,
}
);
@@ -53,6 +59,8 @@ function close() {
:title="title"
:closeable="closable"
:max-height="maxHeight"
:fullscreen="mobileFullscreen"
:avoid-keyboard="avoidKeyboard"
@update:show="show = $event"
@close="emit('close')"
>