From 2a757e8f2c413258147978dc5a29de48e7fbdf9c Mon Sep 17 00:00:00 2001 From: EstrellaXD Date: Fri, 23 Jan 2026 17:51:19 +0100 Subject: [PATCH] feat: add calendar view with Bangumi.tv schedule integration Add weekly broadcast schedule page showing subscribed anime grouped by day-of-week. Backend fetches air_weekday from Bangumi.tv calendar API and matches titles. Frontend displays responsive grid (desktop) and vertical list (mobile). Edit popup moved to parent layout to fix KeepAlive conflicts, and restyled with purple theme. Co-Authored-By: Claude Opus 4.5 --- backend/src/dev_server.py | 47 ++ backend/src/module/api/bangumi.py | 11 + backend/src/module/database/combine.py | 19 + backend/src/module/manager/torrent.py | 32 + backend/src/module/models/bangumi.py | 2 + .../module/parser/analyser/bgm_calendar.py | 88 +++ webui/src/api/bangumi.ts | 12 + webui/src/components/ab-popup.vue | 22 +- webui/src/components/layout/ab-sidebar.vue | 1 - webui/src/i18n/en.json | 65 +- webui/src/i18n/zh-CN.json | 65 +- webui/src/pages/index.vue | 11 + webui/src/pages/index/bangumi.vue | 159 ++++- webui/src/pages/index/calendar.vue | 631 +++++++++++++++++- webui/types/bangumi.ts | 2 + 15 files changed, 1147 insertions(+), 20 deletions(-) create mode 100644 backend/src/dev_server.py create mode 100644 backend/src/module/parser/analyser/bgm_calendar.py diff --git a/backend/src/dev_server.py b/backend/src/dev_server.py new file mode 100644 index 00000000..dda12bbd --- /dev/null +++ b/backend/src/dev_server.py @@ -0,0 +1,47 @@ +"""Minimal dev server that skips downloader check for UI testing.""" +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi import APIRouter + +from module.database.combine import Database +from module.database.engine import engine + +# Initialize DB + migrations + default user +with Database(engine) as db: + db.create_table() + db.user.add_default_user() + +# Build v1 router without program router (which blocks on downloader check) +from module.api.auth import router as auth_router +from module.api.bangumi import router as bangumi_router +from module.api.config import router as config_router +from module.api.log import router as log_router +from module.api.rss import router as rss_router +from module.api.search import router as search_router + +v1 = APIRouter(prefix="/v1") +v1.include_router(auth_router) +v1.include_router(bangumi_router) +v1.include_router(config_router) +v1.include_router(log_router) +v1.include_router(rss_router) +v1.include_router(search_router) + +# Stub status endpoint (real one lives in program router which blocks on downloader) +@v1.get("/status") +async def stub_status(): + return {"status": True, "version": "dev"} + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(v1, prefix="/api") + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=7892) diff --git a/backend/src/module/api/bangumi.py b/backend/src/module/api/bangumi.py index 3ddd62b0..d24f5319 100644 --- a/backend/src/module/api/bangumi.py +++ b/backend/src/module/api/bangumi.py @@ -127,6 +127,17 @@ async def refresh_poster(bangumi_id: int): return u_response(resp) +@router.get( + path="/refresh/calendar", + response_model=APIResponse, + dependencies=[Depends(get_current_user)], +) +async def refresh_calendar(): + with TorrentManager() as manager: + resp = manager.refresh_calendar() + return u_response(resp) + + @router.get( "/reset/all", response_model=APIResponse, dependencies=[Depends(get_current_user)] ) diff --git a/backend/src/module/database/combine.py b/backend/src/module/database/combine.py index 57979322..d91d0eda 100644 --- a/backend/src/module/database/combine.py +++ b/backend/src/module/database/combine.py @@ -1,3 +1,6 @@ +import logging + +from sqlalchemy import inspect, text from sqlmodel import Session, SQLModel from module.models import Bangumi, User @@ -9,6 +12,8 @@ from .rss import RSSDatabase from .torrent import TorrentDatabase from .user import UserDatabase +logger = logging.getLogger(__name__) + class Database(Session): def __init__(self, engine=e): @@ -21,6 +26,20 @@ class Database(Session): def create_table(self): SQLModel.metadata.create_all(self.engine) + self._migrate_columns() + + def _migrate_columns(self): + """Add new columns to existing tables if they don't exist.""" + inspector = inspect(self.engine) + if "bangumi" in inspector.get_table_names(): + columns = [col["name"] for col in inspector.get_columns("bangumi")] + if "air_weekday" not in columns: + with self.engine.connect() as conn: + conn.execute( + text("ALTER TABLE bangumi ADD COLUMN air_weekday INTEGER") + ) + conn.commit() + logger.info("[Database] Migrated: added air_weekday column to bangumi table.") def drop_table(self): SQLModel.metadata.drop_all(self.engine) diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py index cfe8d4a3..fd0cd715 100644 --- a/backend/src/module/manager/torrent.py +++ b/backend/src/module/manager/torrent.py @@ -4,6 +4,7 @@ from module.database import Database from module.downloader import DownloadClient from module.models import Bangumi, BangumiUpdate, ResponseModel from module.parser import TitleParser +from module.parser.analyser.bgm_calendar import fetch_bgm_calendar, match_weekday logger = logging.getLogger(__name__) @@ -154,6 +155,37 @@ class TorrentManager(Database): msg_zh="刷新海报链接成功。", ) + def refresh_calendar(self): + """Fetch Bangumi.tv calendar and update air_weekday for all bangumi.""" + calendar_items = fetch_bgm_calendar() + if not calendar_items: + return ResponseModel( + status_code=500, + status=False, + msg_en="Failed to fetch calendar data from Bangumi.tv.", + msg_zh="从 Bangumi.tv 获取放送表失败。", + ) + bangumis = self.bangumi.search_all() + updated = 0 + for bangumi in bangumis: + if bangumi.deleted: + continue + weekday = match_weekday( + bangumi.official_title, bangumi.title_raw, calendar_items + ) + if weekday is not None and weekday != bangumi.air_weekday: + bangumi.air_weekday = weekday + updated += 1 + if updated > 0: + self.bangumi.update_all(bangumis) + logger.info(f"[Manager] Calendar refresh: updated {updated} bangumi.") + return ResponseModel( + status_code=200, + status=True, + msg_en=f"Calendar refreshed. Updated {updated} anime.", + msg_zh=f"放送表已刷新,更新了 {updated} 部番剧。", + ) + def search_all_bangumi(self): datas = self.bangumi.search_all() if not datas: diff --git a/backend/src/module/models/bangumi.py b/backend/src/module/models/bangumi.py index ebdaa5ee..d9b44153 100644 --- a/backend/src/module/models/bangumi.py +++ b/backend/src/module/models/bangumi.py @@ -27,6 +27,7 @@ class Bangumi(SQLModel, table=True): rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名") save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径") deleted: bool = Field(False, alias="deleted", title="是否已删除") + air_weekday: Optional[int] = Field(default=None, alias="air_weekday", title="放送星期") class BangumiUpdate(SQLModel): @@ -50,6 +51,7 @@ class BangumiUpdate(SQLModel): rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名") save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径") deleted: bool = Field(False, alias="deleted", title="是否已删除") + air_weekday: Optional[int] = Field(default=None, alias="air_weekday", title="放送星期") class Notification(BaseModel): diff --git a/backend/src/module/parser/analyser/bgm_calendar.py b/backend/src/module/parser/analyser/bgm_calendar.py new file mode 100644 index 00000000..d74e4607 --- /dev/null +++ b/backend/src/module/parser/analyser/bgm_calendar.py @@ -0,0 +1,88 @@ +import logging + +from module.network import RequestContent + +logger = logging.getLogger(__name__) + +BGM_CALENDAR_URL = "https://api.bgm.tv/calendar" + + +def fetch_bgm_calendar() -> list[dict]: + """Fetch the current season's broadcast calendar from Bangumi.tv API. + + Returns a flat list of anime items with their air_weekday (0=Mon, ..., 6=Sun). + """ + with RequestContent() as req: + data = req.get_json(BGM_CALENDAR_URL) + + if not data: + logger.warning("[BGM Calendar] Failed to fetch calendar data.") + return [] + + items = [] + for day_group in data: + weekday_info = day_group.get("weekday", {}) + # Bangumi.tv uses 1=Mon, 2=Tue, ..., 7=Sun + # Convert to 0=Mon, 1=Tue, ..., 6=Sun + bgm_weekday = weekday_info.get("id") + if bgm_weekday is None: + continue + weekday = bgm_weekday - 1 # 1-7 → 0-6 + + for item in day_group.get("items", []): + items.append({ + "name": item.get("name", ""), # Japanese title + "name_cn": item.get("name_cn", ""), # Chinese title + "air_weekday": weekday, + }) + + logger.info(f"[BGM Calendar] Fetched {len(items)} airing anime from Bangumi.tv.") + return items + + +def match_weekday(official_title: str, title_raw: str, calendar_items: list[dict]) -> int | None: + """Match a bangumi against calendar items to find its air weekday. + + Matching strategy: + 1. Exact match on Chinese title (name_cn == official_title) + 2. Exact match on Japanese title (name == title_raw or official_title) + 3. Substring match (name_cn in official_title or vice versa) + 4. Substring match on Japanese title + """ + official_title_clean = official_title.strip() + title_raw_clean = title_raw.strip() + + for item in calendar_items: + name_cn = item["name_cn"].strip() + name = item["name"].strip() + + if not name_cn and not name: + continue + + # Exact match on Chinese title + if name_cn and name_cn == official_title_clean: + return item["air_weekday"] + + # Exact match on Japanese/original title + if name and (name == title_raw_clean or name == official_title_clean): + return item["air_weekday"] + + # Second pass: substring matching + for item in calendar_items: + name_cn = item["name_cn"].strip() + name = item["name"].strip() + + if not name_cn and not name: + continue + + # Chinese title substring (at least 4 chars to avoid false positives) + if name_cn and len(name_cn) >= 4: + if name_cn in official_title_clean or official_title_clean in name_cn: + return item["air_weekday"] + + # Japanese title substring + if name and len(name) >= 4: + if name in title_raw_clean or title_raw_clean in name: + return item["air_weekday"] + + return None diff --git a/webui/src/api/bangumi.ts b/webui/src/api/bangumi.ts index 43f90538..c7c057a2 100644 --- a/webui/src/api/bangumi.ts +++ b/webui/src/api/bangumi.ts @@ -13,6 +13,7 @@ export const apiBangumi = { ...bangumi, filter: bangumi.filter.split(','), rss_link: bangumi.rss_link.split(','), + air_weekday: bangumi.air_weekday ?? null, })); return result; }, @@ -30,6 +31,7 @@ export const apiBangumi = { ...data, filter: data.filter.split(','), rss_link: data.rss_link.split(','), + air_weekday: data.air_weekday ?? null, }; return result; }, @@ -134,4 +136,14 @@ export const apiBangumi = { ); return data; }, + + /** + * 从 Bangumi.tv 刷新放送日历数据 + */ + async refreshCalendar() { + const { data } = await axios.get( + 'api/v1/bangumi/refresh/calendar' + ); + return data; + }, }; diff --git a/webui/src/components/ab-popup.vue b/webui/src/components/ab-popup.vue index 04b0359e..b51c9271 100644 --- a/webui/src/components/ab-popup.vue +++ b/webui/src/components/ab-popup.vue @@ -75,8 +75,8 @@ function close() { .popup-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(2px); + background: rgba(108, 74, 182, 0.15); + backdrop-filter: blur(4px); } .popup-wrapper { @@ -93,4 +93,22 @@ function close() { padding: 16px; text-align: center; } + +:deep(.container-card) { + border: 1px solid var(--color-primary); + box-shadow: 0 8px 32px rgba(108, 74, 182, 0.18), 0 2px 8px rgba(0, 0, 0, 0.08); + border-radius: var(--radius-lg); + overflow: hidden; +} + +:deep(.container-header) { + background: var(--color-primary); + color: #fff; + border-bottom: none; + height: 38px; +} + +:deep(.container-body) { + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} diff --git a/webui/src/components/layout/ab-sidebar.vue b/webui/src/components/layout/ab-sidebar.vue index 32bd5e1c..c6b46210 100644 --- a/webui/src/components/layout/ab-sidebar.vue +++ b/webui/src/components/layout/ab-sidebar.vue @@ -49,7 +49,6 @@ const items = [ icon: Calendar, label: () => t('sidebar.calendar'), path: '/calendar', - hidden: true, }, { id: 3, diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 5fbd47ea..ea000c33 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -70,9 +70,29 @@ } }, "downloader": { - "hit": "Please set up the downloader" + "hit": "Please set up the downloader", + "empty": { + "title": "Downloader not configured", + "subtitle": "Connect your download client to manage torrents here", + "step1_title": "Open Config", + "step1_desc": "Navigate to the Config page and find the Downloader section.", + "step2_title": "Enter Connection Details", + "step2_desc": "Set your qBittorrent host address, username, and password.", + "step3_title": "Access Downloader", + "step3_desc": "Once configured, the downloader web UI will be embedded right here." + } }, "homepage": { + "empty": { + "title": "No subscriptions yet", + "subtitle": "Get started by adding your first RSS feed", + "step1_title": "Add RSS Feed", + "step1_desc": "Click the \"Add\" button in the top bar and paste an RSS link from your anime source.", + "step2_title": "Configure Downloader", + "step2_desc": "Go to Config and set up your downloader (e.g. qBittorrent) connection.", + "step3_title": "Sit Back & Enjoy", + "step3_desc": "AutoBangumi will automatically download and rename new episodes for you." + }, "rule": { "apply": "Apply", "delete": "Delete", @@ -141,7 +161,17 @@ "update_success": "Update Success!" }, "player": { - "hit": "Please set up the media player" + "hit": "Please set up the media player", + "empty": { + "title": "Media player not configured", + "subtitle": "Connect your media server to stream directly from here", + "step1_title": "Open Config", + "step1_desc": "Navigate to the Config page and find the Media Player section.", + "step2_title": "Set Player URL", + "step2_desc": "Enter the URL of your media server (Jellyfin, Emby, Plex, etc.).", + "step3_title": "Start Watching", + "step3_desc": "Your media player will be embedded here for easy access." + } }, "rss": { "delete": "Delete", @@ -153,6 +183,37 @@ "title": "RSS Item", "url": "Url" }, + "calendar": { + "title": "Schedule", + "subtitle": "This season's broadcast schedule", + "days": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday" + }, + "days_short": { + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun" + }, + "unknown": "Unknown", + "today": "Today", + "empty": "No anime", + "refresh": "Refresh schedule", + "no_data": "No schedule data available", + "empty_state": { + "title": "No Schedule Yet", + "subtitle": "Add anime from RSS to see your weekly schedule" + } + }, "sidebar": { "calendar": "Calendar", "config": "Config", diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index fcffe86c..1a8cbd17 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -70,9 +70,29 @@ } }, "downloader": { - "hit": "请设置下载器" + "hit": "请设置下载器", + "empty": { + "title": "下载器未配置", + "subtitle": "连接下载客户端以在此管理种子", + "step1_title": "打开设置", + "step1_desc": "前往设置页面,找到下载器设置部分。", + "step2_title": "输入连接信息", + "step2_desc": "设置 qBittorrent 的地址、用户名和密码。", + "step3_title": "访问下载器", + "step3_desc": "配置完成后,下载器界面将直接嵌入此处。" + } }, "homepage": { + "empty": { + "title": "暂无订阅", + "subtitle": "添加你的第一个 RSS 订阅开始使用", + "step1_title": "添加 RSS 订阅", + "step1_desc": "点击顶部栏的「添加」按钮,粘贴来自番剧源的 RSS 链接。", + "step2_title": "配置下载器", + "step2_desc": "前往设置页面,配置你的下载器(如 qBittorrent)连接信息。", + "step3_title": "坐享其成", + "step3_desc": "AutoBangumi 将自动下载并重命名新剧集。" + }, "rule": { "apply": "应用", "delete": "删除", @@ -141,7 +161,17 @@ "update_success": "更新成功!" }, "player": { - "hit": "请设置媒体播放器地址" + "hit": "请设置媒体播放器地址", + "empty": { + "title": "播放器未配置", + "subtitle": "连接媒体服务器以在此直接播放", + "step1_title": "打开设置", + "step1_desc": "前往设置页面,找到播放器设置部分。", + "step2_title": "设置播放器地址", + "step2_desc": "输入媒体服务器的 URL(Jellyfin、Emby、Plex 等)。", + "step3_title": "开始观看", + "step3_desc": "播放器将嵌入此处,方便随时访问。" + } }, "rss": { "delete": "删除", @@ -153,6 +183,37 @@ "title": "RSS 条目", "url": "链接" }, + "calendar": { + "title": "放送表", + "subtitle": "本季度放送时间表", + "days": { + "mon": "周一", + "tue": "周二", + "wed": "周三", + "thu": "周四", + "fri": "周五", + "sat": "周六", + "sun": "周日" + }, + "days_short": { + "mon": "一", + "tue": "二", + "wed": "三", + "thu": "四", + "fri": "五", + "sat": "六", + "sun": "日" + }, + "unknown": "未知", + "today": "今天", + "empty": "今日无番", + "refresh": "刷新放送表", + "no_data": "暂无放送数据", + "empty_state": { + "title": "暂无放送表", + "subtitle": "从 RSS 添加番剧后即可查看每周放送时间" + } + }, "sidebar": { "calendar": "番剧日历", "config": "设置", diff --git a/webui/src/pages/index.vue b/webui/src/pages/index.vue index 96670d1b..33537ab5 100644 --- a/webui/src/pages/index.vue +++ b/webui/src/pages/index.vue @@ -3,6 +3,9 @@ definePage({ name: 'Index', redirect: '/bangumi', }); + +const { editRule } = storeToRefs(useBangumiStore()); +const { updateRule, enableRule, ruleManage } = useBangumiStore(); diff --git a/webui/src/pages/index/bangumi.vue b/webui/src/pages/index/bangumi.vue index 4d3f0531..b1c97969 100644 --- a/webui/src/pages/index/bangumi.vue +++ b/webui/src/pages/index/bangumi.vue @@ -3,9 +3,8 @@ definePage({ name: 'Bangumi List', }); -const { bangumi, editRule } = storeToRefs(useBangumiStore()); -const { getAll, updateRule, enableRule, openEditPopup, ruleManage } = - useBangumiStore(); +const { bangumi } = storeToRefs(useBangumiStore()); +const { getAll, openEditPopup } = useBangumiStore(); const { isMobile } = useBreakpointQuery(); @@ -16,7 +15,43 @@ onActivated(() => { @@ -59,6 +85,115 @@ onActivated(() => { justify-content: center; } } + +.empty-guide { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + padding: 24px; +} + +.empty-guide-header { + text-align: center; + margin-bottom: 32px; +} + +.empty-guide-title { + font-size: 20px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 6px; +} + +.empty-guide-subtitle { + font-size: 14px; + color: var(--color-text-secondary); +} + +.empty-guide-steps { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 400px; + width: 100%; +} + +.empty-guide-step { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 14px 16px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-surface); + transition: background-color var(--transition-normal), + border-color var(--transition-normal); +} + +.empty-guide-step-number { + flex-shrink: 0; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--color-primary); + color: #fff; + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; +} + +.empty-guide-step-content { + flex: 1; + min-width: 0; +} + +.empty-guide-step-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text); + margin-bottom: 4px; +} + +.empty-guide-step-desc { + font-size: 13px; + color: var(--color-text-secondary); + line-height: 1.4; +} + +.anim-fade-in { + animation: fadeIn 0.5s ease both; +} + +.anim-slide-up { + animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-delay: var(--delay, 0s); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/webui/types/bangumi.ts b/webui/types/bangumi.ts index 03ba25c8..1deda8c2 100644 --- a/webui/types/bangumi.ts +++ b/webui/types/bangumi.ts @@ -21,6 +21,7 @@ export interface BangumiRule { subtitle: string; title_raw: string; year: string | null; + air_weekday: number | null; // 0=Mon, 1=Tue, ..., 6=Sun, null=Unknown } export interface BangumiAPI extends Omit { @@ -55,4 +56,5 @@ export const ruleTemplate: BangumiRule = { subtitle: '', title_raw: '', year: null, + air_weekday: null, };