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>
This commit is contained in:
Estrella Pan
2026-01-23 21:20:12 +01:00
parent 0408ecdd61
commit a98a162500
52 changed files with 2269 additions and 1727 deletions

View File

@@ -2,9 +2,8 @@ import logging
import time
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import func
from sqlmodel import and_, delete, false, or_, select
from sqlmodel import Session, and_, delete, false, or_, select
from module.models import Bangumi, BangumiUpdate
@@ -23,32 +22,32 @@ def _invalidate_bangumi_cache():
class BangumiDatabase:
def __init__(self, session: AsyncSession):
def __init__(self, session: Session):
self.session = session
async def add(self, data: Bangumi) -> bool:
def add(self, data: Bangumi) -> bool:
statement = select(Bangumi).where(Bangumi.title_raw == data.title_raw)
result = await self.session.execute(statement)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi:
return False
self.session.add(data)
await self.session.commit()
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Insert {data.official_title} into database.")
return True
async def add_all(self, datas: list[Bangumi]):
def add_all(self, datas: list[Bangumi]):
self.session.add_all(datas)
await self.session.commit()
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Insert {len(datas)} bangumi into database.")
async def update(self, data: Bangumi | BangumiUpdate, _id: int = None) -> bool:
def update(self, data: Bangumi | BangumiUpdate, _id: int = None) -> bool:
if _id and isinstance(data, BangumiUpdate):
db_data = await self.session.get(Bangumi, _id)
db_data = self.session.get(Bangumi, _id)
elif isinstance(data, Bangumi):
db_data = await self.session.get(Bangumi, data.id)
db_data = self.session.get(Bangumi, data.id)
else:
return False
if not db_data:
@@ -57,70 +56,70 @@ class BangumiDatabase:
for key, value in bangumi_data.items():
setattr(db_data, key, value)
self.session.add(db_data)
await self.session.commit()
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Update {data.official_title}")
return True
async def update_all(self, datas: list[Bangumi]):
def update_all(self, datas: list[Bangumi]):
self.session.add_all(datas)
await self.session.commit()
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Update {len(datas)} bangumi.")
async def update_rss(self, title_raw: str, rss_set: str):
def update_rss(self, title_raw: str, rss_set: str):
statement = select(Bangumi).where(Bangumi.title_raw == title_raw)
result = await self.session.execute(statement)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi:
bangumi.rss_link = rss_set
bangumi.added = False
self.session.add(bangumi)
await self.session.commit()
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Update {title_raw} rss_link to {rss_set}.")
async def update_poster(self, title_raw: str, poster_link: str):
def update_poster(self, title_raw: str, poster_link: str):
statement = select(Bangumi).where(Bangumi.title_raw == title_raw)
result = await self.session.execute(statement)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi:
bangumi.poster_link = poster_link
self.session.add(bangumi)
await self.session.commit()
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Update {title_raw} poster_link to {poster_link}.")
async def delete_one(self, _id: int):
def delete_one(self, _id: int):
statement = select(Bangumi).where(Bangumi.id == _id)
result = await self.session.execute(statement)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi:
await self.session.delete(bangumi)
await self.session.commit()
self.session.delete(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Delete bangumi id: {_id}.")
async def delete_all(self):
def delete_all(self):
statement = delete(Bangumi)
await self.session.execute(statement)
await self.session.commit()
self.session.execute(statement)
self.session.commit()
_invalidate_bangumi_cache()
async def search_all(self) -> list[Bangumi]:
def search_all(self) -> list[Bangumi]:
global _bangumi_cache, _bangumi_cache_time
now = time.time()
if _bangumi_cache is not None and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL:
return _bangumi_cache
statement = select(Bangumi)
result = await self.session.execute(statement)
result = self.session.execute(statement)
_bangumi_cache = list(result.scalars().all())
_bangumi_cache_time = now
return _bangumi_cache
async def search_id(self, _id: int) -> Optional[Bangumi]:
def search_id(self, _id: int) -> Optional[Bangumi]:
statement = select(Bangumi).where(Bangumi.id == _id)
result = await self.session.execute(statement)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi is None:
logger.warning(f"[Database] Cannot find bangumi id: {_id}.")
@@ -129,19 +128,19 @@ class BangumiDatabase:
logger.debug(f"[Database] Find bangumi id: {_id}.")
return bangumi
async def match_poster(self, bangumi_name: str) -> str:
def match_poster(self, bangumi_name: str) -> str:
statement = select(Bangumi).where(
func.instr(bangumi_name, Bangumi.official_title) > 0
)
result = await self.session.execute(statement)
result = self.session.execute(statement)
data = result.scalar_one_or_none()
if data:
return data.poster_link
else:
return ""
async def match_list(self, torrent_list: list, rss_link: str) -> list:
match_datas = await self.search_all()
def match_list(self, torrent_list: list, rss_link: str) -> list:
match_datas = self.search_all()
if not match_datas:
return torrent_list
# Build index for faster lookup
@@ -162,29 +161,29 @@ class BangumiDatabase:
unmatched.append(torrent)
# Batch commit all rss_link updates
if rss_updated:
await self.session.commit()
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(f"[Database] Batch updated rss_link for {len(rss_updated)} bangumi.")
return unmatched
async def match_torrent(self, torrent_name: str) -> Optional[Bangumi]:
def match_torrent(self, torrent_name: str) -> Optional[Bangumi]:
statement = select(Bangumi).where(
and_(
func.instr(torrent_name, Bangumi.title_raw) > 0,
Bangumi.deleted == false(),
)
)
result = await self.session.execute(statement)
result = self.session.execute(statement)
return result.scalar_one_or_none()
async def not_complete(self) -> list[Bangumi]:
def not_complete(self) -> list[Bangumi]:
condition = select(Bangumi).where(
and_(Bangumi.eps_collect == false(), Bangumi.deleted == false())
)
result = await self.session.execute(condition)
result = self.session.execute(condition)
return list(result.scalars().all())
async def not_added(self) -> list[Bangumi]:
def not_added(self) -> list[Bangumi]:
conditions = select(Bangumi).where(
or_(
Bangumi.added == 0,
@@ -192,20 +191,20 @@ class BangumiDatabase:
Bangumi.save_path is None,
)
)
result = await self.session.execute(conditions)
result = self.session.execute(conditions)
return list(result.scalars().all())
async def disable_rule(self, _id: int):
def disable_rule(self, _id: int):
statement = select(Bangumi).where(Bangumi.id == _id)
result = await self.session.execute(statement)
result = self.session.execute(statement)
bangumi = result.scalar_one_or_none()
if bangumi:
bangumi.deleted = True
self.session.add(bangumi)
await self.session.commit()
self.session.commit()
logger.debug(f"[Database] Disable rule {bangumi.title_raw}.")
async def search_rss(self, rss_link: str) -> list[Bangumi]:
def search_rss(self, rss_link: str) -> list[Bangumi]:
statement = select(Bangumi).where(func.instr(rss_link, Bangumi.rss_link) > 0)
result = await self.session.execute(statement)
result = self.session.execute(statement)
return list(result.scalars().all())

View File

@@ -1,7 +1,6 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import and_, delete, select
from sqlmodel import Session, and_, delete, select
from module.models import RSSItem, RSSUpdate
@@ -9,12 +8,12 @@ logger = logging.getLogger(__name__)
class RSSDatabase:
def __init__(self, session: AsyncSession):
def __init__(self, session: Session):
self.session = session
async def add(self, data: RSSItem) -> bool:
def add(self, data: RSSItem) -> bool:
statement = select(RSSItem).where(RSSItem.url == data.url)
result = await self.session.execute(statement)
result = self.session.execute(statement)
db_data = result.scalar_one_or_none()
if db_data:
logger.debug(f"RSS Item {data.url} already exists.")
@@ -22,26 +21,26 @@ class RSSDatabase:
else:
logger.debug(f"RSS Item {data.url} not exists, adding...")
self.session.add(data)
await self.session.commit()
await self.session.refresh(data)
self.session.commit()
self.session.refresh(data)
return True
async def add_all(self, data: list[RSSItem]):
def add_all(self, data: list[RSSItem]):
if not data:
return
urls = [item.url for item in data]
statement = select(RSSItem.url).where(RSSItem.url.in_(urls))
result = await self.session.execute(statement)
result = self.session.execute(statement)
existing_urls = set(result.scalars().all())
new_items = [item for item in data if item.url not in existing_urls]
if new_items:
self.session.add_all(new_items)
await self.session.commit()
self.session.commit()
logger.debug(f"Batch inserted {len(new_items)} RSS items.")
async def update(self, _id: int, data: RSSUpdate) -> bool:
def update(self, _id: int, data: RSSUpdate) -> bool:
statement = select(RSSItem).where(RSSItem.id == _id)
result = await self.session.execute(statement)
result = self.session.execute(statement)
db_data = result.scalar_one_or_none()
if not db_data:
return False
@@ -49,61 +48,61 @@ class RSSDatabase:
for key, value in dict_data.items():
setattr(db_data, key, value)
self.session.add(db_data)
await self.session.commit()
self.session.commit()
return True
async def enable(self, _id: int) -> bool:
def enable(self, _id: int) -> bool:
statement = select(RSSItem).where(RSSItem.id == _id)
result = await self.session.execute(statement)
result = self.session.execute(statement)
db_data = result.scalar_one_or_none()
if not db_data:
return False
db_data.enabled = True
self.session.add(db_data)
await self.session.commit()
self.session.commit()
return True
async def disable(self, _id: int) -> bool:
def disable(self, _id: int) -> bool:
statement = select(RSSItem).where(RSSItem.id == _id)
result = await self.session.execute(statement)
result = self.session.execute(statement)
db_data = result.scalar_one_or_none()
if not db_data:
return False
db_data.enabled = False
self.session.add(db_data)
await self.session.commit()
self.session.commit()
return True
async def search_id(self, _id: int) -> RSSItem | None:
return await self.session.get(RSSItem, _id)
def search_id(self, _id: int) -> RSSItem | None:
return self.session.get(RSSItem, _id)
async def search_all(self) -> list[RSSItem]:
result = await self.session.execute(select(RSSItem))
def search_all(self) -> list[RSSItem]:
result = self.session.execute(select(RSSItem))
return list(result.scalars().all())
async def search_active(self) -> list[RSSItem]:
result = await self.session.execute(
def search_active(self) -> list[RSSItem]:
result = self.session.execute(
select(RSSItem).where(RSSItem.enabled)
)
return list(result.scalars().all())
async def search_aggregate(self) -> list[RSSItem]:
result = await self.session.execute(
def search_aggregate(self) -> list[RSSItem]:
result = self.session.execute(
select(RSSItem).where(and_(RSSItem.aggregate, RSSItem.enabled))
)
return list(result.scalars().all())
async def delete(self, _id: int) -> bool:
def delete(self, _id: int) -> bool:
condition = delete(RSSItem).where(RSSItem.id == _id)
try:
await self.session.execute(condition)
await self.session.commit()
self.session.execute(condition)
self.session.commit()
return True
except Exception as e:
logger.error(f"Delete RSS Item failed. Because: {e}")
return False
async def delete_all(self):
def delete_all(self):
condition = delete(RSSItem)
await self.session.execute(condition)
await self.session.commit()
self.session.execute(condition)
self.session.commit()

View File

@@ -1,7 +1,6 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from sqlmodel import Session, select
from module.models import Torrent
@@ -9,54 +8,54 @@ logger = logging.getLogger(__name__)
class TorrentDatabase:
def __init__(self, session: AsyncSession):
def __init__(self, session: Session):
self.session = session
async def add(self, data: Torrent):
def add(self, data: Torrent):
self.session.add(data)
await self.session.commit()
self.session.commit()
logger.debug(f"Insert {data.name} in database.")
async def add_all(self, datas: list[Torrent]):
def add_all(self, datas: list[Torrent]):
self.session.add_all(datas)
await self.session.commit()
self.session.commit()
logger.debug(f"Insert {len(datas)} torrents in database.")
async def update(self, data: Torrent):
def update(self, data: Torrent):
self.session.add(data)
await self.session.commit()
self.session.commit()
logger.debug(f"Update {data.name} in database.")
async def update_all(self, datas: list[Torrent]):
def update_all(self, datas: list[Torrent]):
self.session.add_all(datas)
await self.session.commit()
self.session.commit()
async def update_one_user(self, data: Torrent):
def update_one_user(self, data: Torrent):
self.session.add(data)
await self.session.commit()
self.session.commit()
logger.debug(f"Update {data.name} in database.")
async def search(self, _id: int) -> Torrent | None:
result = await self.session.execute(
def search(self, _id: int) -> Torrent | None:
result = self.session.execute(
select(Torrent).where(Torrent.id == _id)
)
return result.scalar_one_or_none()
async def search_all(self) -> list[Torrent]:
result = await self.session.execute(select(Torrent))
def search_all(self) -> list[Torrent]:
result = self.session.execute(select(Torrent))
return list(result.scalars().all())
async def search_rss(self, rss_id: int) -> list[Torrent]:
result = await self.session.execute(
def search_rss(self, rss_id: int) -> list[Torrent]:
result = self.session.execute(
select(Torrent).where(Torrent.rss_id == rss_id)
)
return list(result.scalars().all())
async def check_new(self, torrents_list: list[Torrent]) -> list[Torrent]:
def check_new(self, torrents_list: list[Torrent]) -> list[Torrent]:
if not torrents_list:
return []
urls = [t.url for t in torrents_list]
statement = select(Torrent.url).where(Torrent.url.in_(urls))
result = await self.session.execute(statement)
result = self.session.execute(statement)
existing_urls = set(result.scalars().all())
return [t for t in torrents_list if t.url not in existing_urls]

View File

@@ -1,8 +1,7 @@
import logging
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from sqlmodel import Session, select
from module.models import ResponseModel
from module.models.user import User, UserUpdate
@@ -12,21 +11,21 @@ logger = logging.getLogger(__name__)
class UserDatabase:
def __init__(self, session: AsyncSession):
def __init__(self, session: Session):
self.session = session
async def get_user(self, username: str) -> User:
def get_user(self, username: str) -> User:
statement = select(User).where(User.username == username)
result = await self.session.execute(statement)
user = result.scalar_one_or_none()
result = self.session.exec(statement)
user = result.first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
async def auth_user(self, user: User) -> ResponseModel:
def auth_user(self, user: User) -> ResponseModel:
statement = select(User).where(User.username == user.username)
result = await self.session.execute(statement)
db_user = result.scalar_one_or_none()
result = self.session.exec(statement)
db_user = result.first()
if not user.password:
return ResponseModel(
status_code=401,
@@ -55,10 +54,10 @@ class UserDatabase:
msg_zh="登录成功",
)
async def update_user(self, username: str, update_user: UserUpdate) -> User:
def update_user(self, username: str, update_user: UserUpdate) -> User:
statement = select(User).where(User.username == username)
result = await self.session.execute(statement)
db_user = result.scalar_one_or_none()
result = self.session.exec(statement)
db_user = result.first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
if update_user.username:
@@ -66,18 +65,18 @@ class UserDatabase:
if update_user.password:
db_user.password = get_password_hash(update_user.password)
self.session.add(db_user)
await self.session.commit()
self.session.commit()
return db_user
async def add_default_user(self):
def add_default_user(self):
statement = select(User)
try:
result = await self.session.execute(statement)
users = list(result.scalars().all())
result = self.session.exec(statement)
users = list(result.all())
except Exception:
users = []
if len(users) != 0:
return
user = User(username="admin", password=get_password_hash("adminadmin"))
self.session.add(user)
await self.session.commit()
self.session.commit()