Skip to content

Commit a98a162

Browse files
EstrellaXDclaude
andcommitted
feat: fix search, poster serving, and add hover overlay UI for cards
- Fix search store exports to match component expectations (inputValue, bangumiList, onSearch) and transform data to SearchResult format - Fix poster endpoint path check that incorrectly blocked all requests - Add resolvePosterUrl utility to handle both external URLs and local paths - Move tags into hover overlay on homepage cards and calendar cards - Show title and tags on poster hover with dark semi-transparent styling - Add downloader API, store, and page - Update backend to async patterns and uv migration changes - Remove .claude/settings.local.json from tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0408ecd commit a98a162

52 files changed

Lines changed: 2283 additions & 1741 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/settings.local.json

Lines changed: 0 additions & 87 deletions
This file was deleted.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,7 @@ dev-dist
216216

217217
# test file
218218
test.*
219+
220+
# local config
221+
/backend/config/
222+
.claude/settings.local.json

backend/src/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ def create_app() -> FastAPI:
4040

4141
@app.get("/posters/{path:path}", tags=["posters"])
4242
def posters(path: str):
43-
# only allow access to files in the posters directory
44-
if not path.startswith("posters/"):
43+
# prevent path traversal
44+
if ".." in path:
4545
return HTMLResponse(status_code=403)
4646
return FileResponse(f"data/posters/{path}")
4747

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,35 @@
1+
import asyncio
2+
import functools
13
import logging
2-
import threading
3-
import time
44

55
from .timeout import timeout
66

77
logger = logging.getLogger(__name__)
8-
lock = threading.Lock()
8+
_lock = asyncio.Lock()
99

1010

1111
def qb_connect_failed_wait(func):
12-
def wrapper(*args, **kwargs):
12+
@functools.wraps(func)
13+
async def wrapper(*args, **kwargs):
1314
times = 0
1415
while times < 5:
1516
try:
16-
return func(*args, **kwargs)
17+
return await func(*args, **kwargs)
1718
except Exception as e:
1819
logger.debug(f"URL: {args[0]}")
1920
logger.warning(e)
2021
logger.warning("Cannot connect to qBittorrent. Wait 5 min and retry...")
21-
time.sleep(300)
22+
await asyncio.sleep(300)
2223
times += 1
2324

2425
return wrapper
2526

2627

2728
def api_failed(func):
28-
def wrapper(*args, **kwargs):
29+
@functools.wraps(func)
30+
async def wrapper(*args, **kwargs):
2931
try:
30-
return func(*args, **kwargs)
32+
return await func(*args, **kwargs)
3133
except Exception as e:
3234
logger.debug(f"URL: {args[0]}")
3335
logger.warning("Wrong API response.")
@@ -37,8 +39,9 @@ def wrapper(*args, **kwargs):
3739

3840

3941
def locked(func):
40-
def wrapper(*args, **kwargs):
41-
with lock:
42-
return func(*args, **kwargs)
42+
@functools.wraps(func)
43+
async def wrapper(*args, **kwargs):
44+
async with _lock:
45+
return await func(*args, **kwargs)
4346

4447
return wrapper

backend/src/module/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .auth import router as auth_router
44
from .bangumi import router as bangumi_router
55
from .config import router as config_router
6+
from .downloader import router as downloader_router
67
from .log import router as log_router
78
from .passkey import router as passkey_router
89
from .program import router as program_router
@@ -19,5 +20,6 @@
1920
v1.include_router(program_router)
2021
v1.include_router(bangumi_router)
2122
v1.include_router(config_router)
23+
v1.include_router(downloader_router)
2224
v1.include_router(rss_router)
2325
v1.include_router(search_router)

backend/src/module/api/bangumi.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ async def update_rule(
4545
data: BangumiUpdate,
4646
):
4747
with TorrentManager() as manager:
48-
resp = manager.update_rule(bangumi_id, data)
48+
resp = await manager.update_rule(bangumi_id, data)
4949
return u_response(resp)
5050

5151

@@ -56,7 +56,7 @@ async def update_rule(
5656
)
5757
async def delete_rule(bangumi_id: str, file: bool = False):
5858
with TorrentManager() as manager:
59-
resp = manager.delete_rule(bangumi_id, file)
59+
resp = await manager.delete_rule(bangumi_id, file)
6060
return u_response(resp)
6161

6262

@@ -68,7 +68,7 @@ async def delete_rule(bangumi_id: str, file: bool = False):
6868
async def delete_many_rule(bangumi_id: list, file: bool = False):
6969
with TorrentManager() as manager:
7070
for i in bangumi_id:
71-
resp = manager.delete_rule(i, file)
71+
resp = await manager.delete_rule(i, file)
7272
return u_response(resp)
7373

7474

@@ -79,7 +79,7 @@ async def delete_many_rule(bangumi_id: list, file: bool = False):
7979
)
8080
async def disable_rule(bangumi_id: str, file: bool = False):
8181
with TorrentManager() as manager:
82-
resp = manager.disable_rule(bangumi_id, file)
82+
resp = await manager.disable_rule(bangumi_id, file)
8383
return u_response(resp)
8484

8585

@@ -91,7 +91,7 @@ async def disable_rule(bangumi_id: str, file: bool = False):
9191
async def disable_many_rule(bangumi_id: list, file: bool = False):
9292
with TorrentManager() as manager:
9393
for i in bangumi_id:
94-
resp = manager.disable_rule(i, file)
94+
resp = await manager.disable_rule(i, file)
9595
return u_response(resp)
9696

9797

@@ -111,19 +111,19 @@ async def enable_rule(bangumi_id: str):
111111
response_model=APIResponse,
112112
dependencies=[Depends(get_current_user)],
113113
)
114-
async def refresh_poster():
114+
async def refresh_poster_all():
115115
with TorrentManager() as manager:
116-
resp = manager.refresh_poster()
116+
resp = await manager.refresh_poster()
117117
return u_response(resp)
118118

119119
@router.get(
120120
path="/refresh/poster/{bangumi_id}",
121121
response_model=APIResponse,
122122
dependencies=[Depends(get_current_user)],
123123
)
124-
async def refresh_poster(bangumi_id: int):
124+
async def refresh_poster_one(bangumi_id: int):
125125
with TorrentManager() as manager:
126-
resp = manager.refind_poster(bangumi_id)
126+
resp = await manager.refind_poster(bangumi_id)
127127
return u_response(resp)
128128

129129

@@ -134,7 +134,7 @@ async def refresh_poster(bangumi_id: int):
134134
)
135135
async def refresh_calendar():
136136
with TorrentManager() as manager:
137-
resp = manager.refresh_calendar()
137+
resp = await manager.refresh_calendar()
138138
return u_response(resp)
139139

140140

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from fastapi import APIRouter, Depends
2+
from pydantic import BaseModel
3+
4+
from module.downloader import DownloadClient
5+
from module.security.api import get_current_user
6+
7+
router = APIRouter(prefix="/downloader", tags=["downloader"])
8+
9+
10+
class TorrentHashesRequest(BaseModel):
11+
hashes: list[str]
12+
13+
14+
class TorrentDeleteRequest(BaseModel):
15+
hashes: list[str]
16+
delete_files: bool = False
17+
18+
19+
@router.get("/torrents", dependencies=[Depends(get_current_user)])
20+
async def get_torrents():
21+
async with DownloadClient() as client:
22+
return await client.get_torrent_info(category="Bangumi", status_filter=None)
23+
24+
25+
@router.post("/torrents/pause", dependencies=[Depends(get_current_user)])
26+
async def pause_torrents(req: TorrentHashesRequest):
27+
hashes = "|".join(req.hashes)
28+
async with DownloadClient() as client:
29+
await client.pause_torrent(hashes)
30+
return {"msg_en": "Torrents paused", "msg_zh": "种子已暂停"}
31+
32+
33+
@router.post("/torrents/resume", dependencies=[Depends(get_current_user)])
34+
async def resume_torrents(req: TorrentHashesRequest):
35+
hashes = "|".join(req.hashes)
36+
async with DownloadClient() as client:
37+
await client.resume_torrent(hashes)
38+
return {"msg_en": "Torrents resumed", "msg_zh": "种子已恢复"}
39+
40+
41+
@router.post("/torrents/delete", dependencies=[Depends(get_current_user)])
42+
async def delete_torrents(req: TorrentDeleteRequest):
43+
hashes = "|".join(req.hashes)
44+
async with DownloadClient() as client:
45+
await client.delete_torrent(hashes, delete_files=req.delete_files)
46+
return {"msg_en": "Torrents deleted", "msg_zh": "种子已删除"}

backend/src/module/api/rss.py

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def get_rss():
2525
)
2626
async def add_rss(rss: RSSItem):
2727
with RSSEngine() as engine:
28-
result = engine.add_rss(rss.url, rss.name, rss.aggregate, rss.parser)
28+
result = await engine.add_rss(rss.url, rss.name, rss.aggregate, rss.parser)
2929
return u_response(result)
3030

3131

@@ -133,12 +133,13 @@ async def update_rss(
133133
dependencies=[Depends(get_current_user)],
134134
)
135135
async def refresh_all():
136-
with RSSEngine() as engine, DownloadClient() as client:
137-
engine.refresh_rss(client)
138-
return JSONResponse(
139-
status_code=200,
140-
content={"msg_en": "Refresh all RSS successfully.", "msg_zh": "刷新 RSS 成功。"},
141-
)
136+
async with DownloadClient() as client:
137+
with RSSEngine() as engine:
138+
await engine.refresh_rss(client)
139+
return JSONResponse(
140+
status_code=200,
141+
content={"msg_en": "Refresh all RSS successfully.", "msg_zh": "刷新 RSS 成功。"},
142+
)
142143

143144

144145
@router.get(
@@ -147,12 +148,13 @@ async def refresh_all():
147148
dependencies=[Depends(get_current_user)],
148149
)
149150
async def refresh_rss(rss_id: int):
150-
with RSSEngine() as engine, DownloadClient() as client:
151-
engine.refresh_rss(client, rss_id)
152-
return JSONResponse(
153-
status_code=200,
154-
content={"msg_en": "Refresh RSS successfully.", "msg_zh": "刷新 RSS 成功。"},
155-
)
151+
async with DownloadClient() as client:
152+
with RSSEngine() as engine:
153+
await engine.refresh_rss(client, rss_id)
154+
return JSONResponse(
155+
status_code=200,
156+
content={"msg_en": "Refresh RSS successfully.", "msg_zh": "刷新 RSS 成功。"},
157+
)
156158

157159

158160
@router.get(
@@ -175,7 +177,7 @@ async def get_torrent(
175177
"/analysis", response_model=Bangumi, dependencies=[Depends(get_current_user)]
176178
)
177179
async def analysis(rss: RSSItem):
178-
data = analyser.link_to_data(rss)
180+
data = await analyser.link_to_data(rss)
179181
if isinstance(data, Bangumi):
180182
return data
181183
else:
@@ -186,15 +188,14 @@ async def analysis(rss: RSSItem):
186188
"/collect", response_model=APIResponse, dependencies=[Depends(get_current_user)]
187189
)
188190
async def download_collection(data: Bangumi):
189-
with SeasonCollector() as collector:
190-
resp = collector.collect_season(data, data.rss_link)
191+
async with SeasonCollector() as collector:
192+
resp = await collector.collect_season(data, data.rss_link)
191193
return u_response(resp)
192194

193195

194196
@router.post(
195197
"/subscribe", response_model=APIResponse, dependencies=[Depends(get_current_user)]
196198
)
197199
async def subscribe(data: Bangumi, rss: RSSItem):
198-
with SeasonCollector() as collector:
199-
resp = collector.subscribe_season(data, parser=rss.parser)
200-
return u_response(resp)
200+
resp = await SeasonCollector.subscribe_season(data, parser=rss.parser)
201+
return u_response(resp)

backend/src/module/api/search.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@ async def search_torrents(site: str = "mikan", keywords: str = Query(None)):
1818
if not keywords:
1919
return []
2020
keywords = keywords.split(" ")
21-
with SearchTorrent() as st:
22-
return EventSourceResponse(
23-
content=st.analyse_keyword(keywords=keywords, site=site),
24-
)
21+
22+
async def event_generator():
23+
async with SearchTorrent() as st:
24+
async for item in st.analyse_keyword(keywords=keywords, site=site):
25+
yield item
26+
27+
return EventSourceResponse(content=event_generator())
2528

2629

2730
@router.get(

0 commit comments

Comments
 (0)