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

@@ -9,16 +9,17 @@ logger = logging.getLogger(__name__)
class SeasonCollector(DownloadClient):
def collect_season(self, bangumi: Bangumi, link: str = None):
async def collect_season(self, bangumi: Bangumi, link: str = None):
logger.info(
f"Start collecting {bangumi.official_title} Season {bangumi.season}..."
)
with SearchTorrent() as st, RSSEngine() as engine:
async with SearchTorrent() as st:
if not link:
torrents = st.search_season(bangumi)
torrents = await st.search_season(bangumi)
else:
torrents = st.get_torrents(link, bangumi.filter.replace(",", "|"))
if self.add_torrent(torrents, bangumi):
torrents = await st.get_torrents(link, bangumi.filter.replace(",", "|"))
with RSSEngine() as engine:
if await self.add_torrent(torrents, bangumi):
logger.info(
f"Collections of {bangumi.official_title} Season {bangumi.season} completed."
)
@@ -46,29 +47,29 @@ class SeasonCollector(DownloadClient):
)
@staticmethod
def subscribe_season(data: Bangumi, parser: str = "mikan"):
async def subscribe_season(data: Bangumi, parser: str = "mikan"):
with RSSEngine() as engine:
data.added = True
data.eps_collect = True
engine.add_rss(
await engine.add_rss(
rss_link=data.rss_link,
name=data.official_title,
aggregate=False,
parser=parser,
)
result = engine.download_bangumi(data)
result = await engine.download_bangumi(data)
engine.bangumi.add(data)
return result
def eps_complete():
async def eps_complete():
with RSSEngine() as engine:
datas = engine.bangumi.not_complete()
if datas:
logger.info("Start collecting full season...")
for data in datas:
if not data.eps_collect:
with SeasonCollector() as collector:
collector.collect_season(data)
async with SeasonCollector() as collector:
await collector.collect_season(data)
data.eps_collect = True
engine.bangumi.update_all(datas)

View File

@@ -48,7 +48,7 @@ class Renamer(DownloadClient):
logger.error(f"[Renamer] Unknown rename method: {method}")
return file_info.media_path
def rename_file(
async def rename_file(
self,
torrent_name: str,
media_path: str,
@@ -67,7 +67,7 @@ class Renamer(DownloadClient):
new_path = self.gen_path(ep, bangumi_name, method=method)
if media_path != new_path:
if new_path not in self.check_pool.keys():
if self.rename_torrent_file(
if await self.rename_torrent_file(
_hash=_hash, old_path=media_path, new_path=new_path
):
return Notification(
@@ -78,10 +78,10 @@ class Renamer(DownloadClient):
else:
logger.warning(f"[Renamer] {media_path} parse failed")
if settings.bangumi_manage.remove_bad_torrent:
self.delete_torrent(hashes=_hash)
await self.delete_torrent(hashes=_hash)
return None
def rename_collection(
async def rename_collection(
self,
media_list: list[str],
bangumi_name: str,
@@ -99,17 +99,17 @@ class Renamer(DownloadClient):
if ep:
new_path = self.gen_path(ep, bangumi_name, method=method)
if media_path != new_path:
renamed = self.rename_torrent_file(
renamed = await self.rename_torrent_file(
_hash=_hash, old_path=media_path, new_path=new_path
)
if not renamed:
logger.warning(f"[Renamer] {media_path} rename failed")
# Delete bad torrent.
if settings.bangumi_manage.remove_bad_torrent:
self.delete_torrent(_hash)
await self.delete_torrent(_hash)
break
def rename_subtitles(
async def rename_subtitles(
self,
subtitle_list: list[str],
torrent_name: str,
@@ -130,17 +130,17 @@ class Renamer(DownloadClient):
if sub:
new_path = self.gen_path(sub, bangumi_name, method=method)
if subtitle_path != new_path:
renamed = self.rename_torrent_file(
renamed = await self.rename_torrent_file(
_hash=_hash, old_path=subtitle_path, new_path=new_path
)
if not renamed:
logger.warning(f"[Renamer] {subtitle_path} rename failed")
def rename(self) -> list[Notification]:
async def rename(self) -> list[Notification]:
# Get torrent info
logger.debug("[Renamer] Start rename process.")
rename_method = settings.bangumi_manage.rename_method
torrents_info = self.get_torrent_info()
torrents_info = await self.get_torrent_info()
renamed_info: list[Notification] = []
for info in torrents_info:
media_list, subtitle_list = self.check_files(info)
@@ -154,19 +154,19 @@ class Renamer(DownloadClient):
}
# Rename single media file
if len(media_list) == 1:
notify_info = self.rename_file(media_path=media_list[0], **kwargs)
notify_info = await self.rename_file(media_path=media_list[0], **kwargs)
if notify_info:
renamed_info.append(notify_info)
# Rename subtitle file
if len(subtitle_list) > 0:
self.rename_subtitles(subtitle_list=subtitle_list, **kwargs)
await self.rename_subtitles(subtitle_list=subtitle_list, **kwargs)
# Rename collection
elif len(media_list) > 1:
logger.info("[Renamer] Start rename collection")
self.rename_collection(media_list=media_list, **kwargs)
await self.rename_collection(media_list=media_list, **kwargs)
if len(subtitle_list) > 0:
self.rename_subtitles(subtitle_list=subtitle_list, **kwargs)
self.set_category(info.hash, "BangumiCollection")
await self.rename_subtitles(subtitle_list=subtitle_list, **kwargs)
await self.set_category(info.hash, "BangumiCollection")
else:
logger.warning(f"[Renamer] {info.name} has no media file")
logger.debug("[Renamer] Rename process finished.")
@@ -177,12 +177,3 @@ class Renamer(DownloadClient):
pass
else:
self.delete_torrent(hashes=torrent_hash)
if __name__ == "__main__":
from module.conf import setup_logger
settings.log.debug_enable = True
setup_logger()
with Renamer() as renamer:
renamer.rename()

View File

@@ -11,17 +11,19 @@ logger = logging.getLogger(__name__)
class TorrentManager(Database):
@staticmethod
def __match_torrents_list(data: Bangumi | BangumiUpdate) -> list:
with DownloadClient() as client:
torrents = client.get_torrent_info(status_filter=None)
async def __match_torrents_list(data: Bangumi | BangumiUpdate) -> list:
async with DownloadClient() as client:
torrents = await client.get_torrent_info(status_filter=None)
return [
torrent.hash for torrent in torrents if torrent.save_path == data.save_path
torrent.get("hash", torrent.get("infohash_v1", ""))
for torrent in torrents
if torrent.get("save_path") == data.save_path
]
def delete_torrents(self, data: Bangumi, client: DownloadClient):
hash_list = self.__match_torrents_list(data)
async def delete_torrents(self, data: Bangumi, client: DownloadClient):
hash_list = await self.__match_torrents_list(data)
if hash_list:
client.delete_torrent(hash_list)
await client.delete_torrent(hash_list)
logger.info(f"Delete rule and torrents for {data.official_title}")
return ResponseModel(
status_code=200,
@@ -37,20 +39,21 @@ class TorrentManager(Database):
msg_zh=f"无法找到 {data.official_title} 的种子",
)
def delete_rule(self, _id: int | str, file: bool = False):
async def delete_rule(self, _id: int | str, file: bool = False):
data = self.bangumi.search_id(int(_id))
if isinstance(data, Bangumi):
with DownloadClient() as client:
async with DownloadClient() as client:
self.rss.delete(data.official_title)
self.bangumi.delete_one(int(_id))
torrent_message = None
if file:
torrent_message = self.delete_torrents(data, client)
torrent_message = await self.delete_torrents(data, client)
logger.info(f"[Manager] Delete rule for {data.official_title}")
return ResponseModel(
status_code=200,
status=True,
msg_en=f"Delete rule for {data.official_title}. {torrent_message.msg_en if file else ''}",
msg_zh=f"删除 {data.official_title} 规则。{torrent_message.msg_zh if file else ''}",
msg_en=f"Delete rule for {data.official_title}. {torrent_message.msg_en if file and torrent_message else ''}",
msg_zh=f"删除 {data.official_title} 规则。{torrent_message.msg_zh if file and torrent_message else ''}",
)
else:
return ResponseModel(
@@ -60,15 +63,14 @@ class TorrentManager(Database):
msg_zh=f"无法找到 id {_id}",
)
def disable_rule(self, _id: str | int, file: bool = False):
async def disable_rule(self, _id: str | int, file: bool = False):
data = self.bangumi.search_id(int(_id))
if isinstance(data, Bangumi):
with DownloadClient() as client:
# client.remove_rule(data.rule_name)
async with DownloadClient() as client:
data.deleted = True
self.bangumi.update(data)
if file:
torrent_message = self.delete_torrents(data, client)
torrent_message = await self.delete_torrents(data, client)
return torrent_message
logger.info(f"[Manager] Disable rule for {data.official_title}")
return ResponseModel(
@@ -105,15 +107,15 @@ class TorrentManager(Database):
msg_zh=f"无法找到 id {_id}",
)
def update_rule(self, bangumi_id, data: BangumiUpdate):
async def update_rule(self, bangumi_id, data: BangumiUpdate):
old_data: Bangumi = self.bangumi.search_id(bangumi_id)
if old_data:
# Move torrent
match_list = self.__match_torrents_list(old_data)
with DownloadClient() as client:
match_list = await self.__match_torrents_list(old_data)
async with DownloadClient() as client:
path = client._gen_save_path(data)
if match_list:
client.move_torrent(match_list, path)
await client.move_torrent(match_list, path)
data.save_path = path
self.bangumi.update(data, bangumi_id)
return ResponseModel(
@@ -131,11 +133,11 @@ class TorrentManager(Database):
msg_zh=f"无法找到 id {bangumi_id} 的数据",
)
def refresh_poster(self):
async def refresh_poster(self):
bangumis = self.bangumi.search_all()
for bangumi in bangumis:
if not bangumi.poster_link:
TitleParser().tmdb_poster_parser(bangumi)
await TitleParser().tmdb_poster_parser(bangumi)
self.bangumi.update_all(bangumis)
return ResponseModel(
status_code=200,
@@ -144,9 +146,9 @@ class TorrentManager(Database):
msg_zh="刷新海报链接成功。",
)
def refind_poster(self, bangumi_id: int):
async def refind_poster(self, bangumi_id: int):
bangumi = self.bangumi.search_id(bangumi_id)
TitleParser().tmdb_poster_parser(bangumi)
await TitleParser().tmdb_poster_parser(bangumi)
self.bangumi.update(bangumi)
return ResponseModel(
status_code=200,
@@ -155,9 +157,9 @@ class TorrentManager(Database):
msg_zh="刷新海报链接成功。",
)
def refresh_calendar(self):
async def refresh_calendar(self):
"""Fetch Bangumi.tv calendar and update air_weekday for all bangumi."""
calendar_items = fetch_bgm_calendar()
calendar_items = await fetch_bgm_calendar()
if not calendar_items:
return ResponseModel(
status_code=500,
@@ -204,8 +206,3 @@ class TorrentManager(Database):
)
else:
return data
if __name__ == "__main__":
with TorrentManager() as manager:
manager.refresh_poster()