Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 23 additions & 1 deletion backend/src/module/api/rss.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@
from module.database import Database, get_db
from module.downloader import DownloadClient
from module.manager import SeasonCollector
from module.models import APIResponse, Bangumi, Movie, RSSItem, RSSUpdate, Torrent
from module.models import (
APIResponse,
Bangumi,
Movie,
RSSItem,
RSSPreviewResponse,
RSSUpdate,
Torrent,
)
from module.models.api import RssLink
from module.rss import RSSAnalyser, RSSEngine
from module.security.api import get_current_user

Expand Down Expand Up @@ -180,6 +189,19 @@ async def get_torrent(
return await engine.get_rss_torrents(rss_id)


@router.post(
path="/preview",
response_model=RSSPreviewResponse,
dependencies=[Depends(get_current_user)],
)
async def preview_rss(
rss: RssLink,
db: Database = Depends(get_db),
):
engine = RSSEngine(db)
return await engine.preview_rss(rss.rss_link)


# Old API
analyser = RSSAnalyser()

Expand Down
9 changes: 7 additions & 2 deletions backend/src/module/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
RenameOperationState,
)
from .response import APIResponse, ResponseModel
from .rss import RSSItem, RSSUpdate
from .torrent import EpisodeFile, SubtitleFile, Torrent, TorrentUpdate
from .rss import RSSItem, RSSPreviewItem, RSSPreviewResponse, RSSUpdate
from .torrent import (
EpisodeFile,
SubtitleFile,
Torrent,
TorrentUpdate,
)
from .user import User, UserLogin, UserUpdate
12 changes: 12 additions & 0 deletions backend/src/module/models/rss.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Optional

from pydantic import BaseModel
from sqlmodel import Field, SQLModel


Expand All @@ -21,3 +22,14 @@ class RSSUpdate(SQLModel):
aggregate: Optional[bool] = Field(True, alias="aggregate")
parser: Optional[str] = Field("mikan", alias="parser")
enabled: Optional[bool] = Field(True, alias="enabled")


class RSSPreviewItem(BaseModel):
name: str = Field(...)
url: str = Field(...)
homepage: str | None = Field(None)


class RSSPreviewResponse(BaseModel):
items: list[RSSPreviewItem] = Field(...)
global_filter: list[str] = Field(default_factory=list)
69 changes: 55 additions & 14 deletions backend/src/module/rss/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@
from module.database import Database
from module.database.bangumi import _groups_are_similar, match_bangumi_in_list
from module.downloader import AddResult, DownloadClient
from module.models import Bangumi, Movie, ResponseModel, RSSItem, Torrent
from module.models import (
Bangumi,
Movie,
ResponseModel,
RSSItem,
RSSPreviewItem,
RSSPreviewResponse,
Torrent,
)
from module.network import RequestContent
from module.notification.events import (
DownloadFailureEvent,
Expand Down Expand Up @@ -82,6 +90,22 @@ async def get_rss_torrents(self, rss_id: int) -> list[Torrent]:
else:
return []

async def preview_rss(self, rss_link: str) -> RSSPreviewResponse:
async with RequestContent() as req:
torrents = await req.get_torrents(rss_link, _filter="")

return RSSPreviewResponse(
items=[
RSSPreviewItem(
name=torrent.name,
url=torrent.url,
homepage=torrent.homepage,
)
for torrent in torrents
],
global_filter=list(settings.rss_parser.filter),
)

async def add_rss(
self,
rss_link: str,
Expand Down Expand Up @@ -158,21 +182,38 @@ async def _pull_rss_with_status(
logger.warning(f"Failed to fetch RSS {rss_item.name}: {e}")
return [], str(e)

@staticmethod
def _compile_filter_terms(
filter_str: str, *, ignore_case: bool
) -> re.Pattern:
filter_terms = filter_str.split(",")
terms = [term for term in filter_terms if term]
if not terms:
# 如果没有任何过滤条件,返回一个匹配任意字符串的正则表达式
# 例如,外部使用: _compile_filter_terms(",,,".split(","), ignore_case=True)
# 等于没有约束,此时返回此正则表达式兜底
logger.warning(
"Filter %r is empty, using common matching",
filter_str,
)
return re.compile(".*")

flags = re.IGNORECASE if ignore_case else 0
raw_pattern = "|".join(terms)
try:
return re.compile(raw_pattern, flags)
except re.error:
escaped = "|".join(re.escape(term) for term in terms)
logger.warning(
"Filter %r contains invalid regex, using literal matching",
raw_pattern,
)
return re.compile(escaped, flags)

def _get_filter_pattern(self, filter_str: str) -> re.Pattern:
if filter_str not in self._filter_cache:
raw_pattern = filter_str.replace(",", "|")
try:
self._filter_cache[filter_str] = re.compile(raw_pattern, re.IGNORECASE)
except re.error:
# Filter contains invalid regex chars (e.g. unmatched '[')
# Fall back to escaping each term for literal matching
terms = filter_str.split(",")
escaped = "|".join(re.escape(t) for t in terms)
self._filter_cache[filter_str] = re.compile(escaped, re.IGNORECASE)
logger.warning(
f"Filter '{filter_str}' contains invalid regex, "
f"using literal matching"
)
pattern = self._compile_filter_terms(filter_str, ignore_case=True)
self._filter_cache[filter_str] = pattern
Comment on lines 213 to +216
return self._filter_cache[filter_str]

def match_torrent(
Expand Down
52 changes: 51 additions & 1 deletion backend/src/test/test_api_rss.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@

from module.api import v1
from module.database import get_db
from module.models import Bangumi, Movie, ResponseModel, RSSItem, RSSUpdate, Torrent
from module.models import (
Bangumi,
Movie,
RSSPreviewItem,
RSSPreviewResponse,
ResponseModel,
RSSItem,
RSSUpdate,
Torrent,
)
from module.security.api import get_current_user
from test.factories import make_bangumi, make_rss_item, make_torrent

Expand Down Expand Up @@ -286,6 +295,47 @@ def test_get_torrents(self, authed_client):
assert len(data) == 2


# ---------------------------------------------------------------------------
# POST /rss/preview
# ---------------------------------------------------------------------------


class TestPreviewRss:
def test_preview_rss(self, authed_client):
"""POST /rss/preview returns preview rows for an arbitrary feed URL."""
preview = RSSPreviewResponse(
items=[
RSSPreviewItem(
name="[Sub] Test Anime - 01 [1080p].mkv",
url="https://example.com/1.torrent",
homepage="https://example.com/1",
),
RSSPreviewItem(
name="[Sub] Test Anime - 01 [720p].mkv",
url="https://example.com/2.torrent",
homepage=None,
),
],
global_filter=["720"],
)
with patch("module.api.rss.RSSEngine") as MockEngine:
mock_eng = MockEngine.return_value
mock_eng.preview_rss = AsyncMock(return_value=preview)

response = authed_client.post(
"/api/v1/rss/preview",
json={"rss_link": "https://mikanani.me/RSS/Search?searchstr=test"},
)

assert response.status_code == 200
data = response.json()
assert data["global_filter"] == ["720"]
assert [row["name"] for row in data["items"]] == [
"[Sub] Test Anime - 01 [1080p].mkv",
"[Sub] Test Anime - 01 [720p].mkv",
]


# ---------------------------------------------------------------------------
# POST /rss/analysis
# ---------------------------------------------------------------------------
Expand Down
74 changes: 74 additions & 0 deletions backend/src/test/test_rss_engine_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,37 @@ async def test_empty_feed_returns_empty(self, rss_engine):
assert result == []


# ---------------------------------------------------------------------------
# preview_rss
# ---------------------------------------------------------------------------


class TestPreviewRss:
async def test_returns_raw_items_and_global_filter(self, rss_engine):
"""preview_rss should return raw items plus the configured global filter."""
torrents = [
make_torrent(name="[Sub] Test Anime - 01 [1080p].mkv"),
make_torrent(name="[Sub] Test Anime - 01 [720p].mkv"),
make_torrent(name="[Sub] Test Anime - 01 [1080p][HEVC].mkv"),
]

with (
patch(
"module.rss.engine.RequestContent.get_torrents",
new=AsyncMock(return_value=torrents),
),
patch.object(settings.rss_parser, "filter", ["720"]),
):
preview = await rss_engine.preview_rss(
"https://mikanani.me/RSS/Search?searchstr=test"
)

assert preview.global_filter == ["720"]
assert [item.name for item in preview.items] == [
torrent.name for torrent in torrents
]


# ---------------------------------------------------------------------------
# match_torrent
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -622,6 +653,49 @@ def feed_torrent():


class TestDownloadBangumi:
async def test_applies_global_and_rule_filters_before_download(self, rss_engine):
bangumi = make_bangumi(
official_title="Mushoku Tensei",
rss_link="https://example.com/rss",
filter="HEVC",
)
await rss_engine.db.bangumi.add(bangumi)
torrents = [
Torrent(
name="[Sub] Mushoku Tensei - 12 [1080p].mkv",
url="https://example.com/ep12.torrent",
),
Torrent(
name="[Sub] Mushoku Tensei - 12 [720p].mkv",
url="https://example.com/ep12-720.torrent",
),
Torrent(
name="[Sub] Mushoku Tensei - 12 [1080p][HEVC].mkv",
url="https://example.com/ep12-hevc.torrent",
),
]
with (
patch("module.rss.engine.RequestContent") as MockReq,
patch("module.rss.engine.DownloadClient") as MockClient,
patch.object(settings.rss_parser, "filter", ["720"]),
):
req = AsyncMock()
req.get_torrents = AsyncMock(return_value=torrents)
MockReq.return_value.__aenter__ = AsyncMock(return_value=req)
MockReq.return_value.__aexit__ = AsyncMock(return_value=False)
client = AsyncMock()
client.add_torrent = AsyncMock(return_value=AddResult.ADDED)
MockClient.return_value.__aenter__ = AsyncMock(return_value=client)
MockClient.return_value.__aexit__ = AsyncMock(return_value=False)

resp = await rss_engine.download_bangumi(bangumi)

assert resp.status is True
passed_torrents = client.add_torrent.call_args.args[0]
assert [torrent.name for torrent in passed_torrents] == [
"[Sub] Mushoku Tensei - 12 [1080p].mkv"
]

async def test_success_persists_torrents_with_bangumi_id(self, rss_engine):
"""成功下载后种子行必须关联 bangumi_id,否则会被记成孤儿。"""
bangumi = make_bangumi(
Expand Down
14 changes: 14 additions & 0 deletions webui/src/api/__tests__/rss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,20 @@ describe('RSS API Logic', () => {
await apiRSS.getTorrent(5);
expect(axios.get).toHaveBeenCalledWith('api/v1/rss/torrent/5');
});

it('should POST api/v1/rss/preview with the rss link payload', async () => {
(axios.post as any).mockResolvedValue({
data: { items: [], global_filter: [] },
});
await apiRSS.preview('https://mikanani.me/RSS/Search?searchstr=test');
expect(axios.post).toHaveBeenCalledWith(
'api/v1/rss/preview',
{ rss_link: 'https://mikanani.me/RSS/Search?searchstr=test' },
{
silent: true,
}
);
});
});

describe('update payload', () => {
Expand Down
13 changes: 12 additions & 1 deletion webui/src/api/rss.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RSS } from '#/rss';
import type { RSS, RSSPreviewResponse } from '#/rss';
import type { Torrent } from '#/torrent';
import type { ApiSuccess } from '#/api';

Expand Down Expand Up @@ -75,4 +75,15 @@ export const apiRSS = {
const { data } = await axios.get<Torrent[]>(`api/v1/rss/torrent/${rss_id}`);
return data!;
},

async preview(rssLink: string) {
const { data } = await axios.post<RSSPreviewResponse>(
'api/v1/rss/preview',
{ rss_link: rssLink },
{
silent: true,
}
);
return data!;
},
};
Loading