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, };