mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-11 14:36:56 +08:00
Merge pull request #1070 from EstrellaXD/fix/issues-1067-1068
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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=(",", ":"))
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
49
webui/src/components/basic/__tests__/ab-bottom-sheet.test.ts
Normal file
49
webui/src/components/basic/__tests__/ab-bottom-sheet.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,6 +22,10 @@ const props = withDefaults(
|
||||
/** 隐藏右上角 X(confirm 类弹窗: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')"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user