diff --git a/src/app/api/favorites/check-updates/route.ts b/src/app/api/favorites/check-updates/route.ts
new file mode 100644
index 0000000..917650c
--- /dev/null
+++ b/src/app/api/favorites/check-updates/route.ts
@@ -0,0 +1,159 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getStorage } from '@/lib/db';
+import { getAvailableApiSites } from '@/lib/config';
+import { getDetailFromApi } from '@/lib/downstream';
+import { Notification } from '@/lib/types';
+
+export const runtime = 'nodejs';
+
+export async function POST(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const storage = getStorage();
+ const username = authInfo.username;
+ const now = Date.now();
+
+ console.log(`用户 ${username} 请求检查收藏更新`);
+ console.log(`当前时间: ${new Date(now).toLocaleString('zh-CN')}`);
+ console.log(`开始检查收藏更新...`);
+
+ // 获取所有收藏
+ const favorites = await storage.getAllFavorites(username);
+ const favoriteKeys = Object.keys(favorites);
+
+ if (favoriteKeys.length === 0) {
+ return NextResponse.json({
+ message: '没有收藏',
+ updates: [],
+ });
+ }
+
+ // 获取可用的 API 站点
+ const apiSites = await getAvailableApiSites(username);
+
+ // 检查每个收藏的更新
+ const updates: Array<{
+ source: string;
+ id: string;
+ title: string;
+ old_episodes: number;
+ new_episodes: number;
+ }> = [];
+
+ // 限制并发请求数量,避免过载
+ const BATCH_SIZE = 5;
+ for (let i = 0; i < favoriteKeys.length; i += BATCH_SIZE) {
+ const batch = favoriteKeys.slice(i, i + BATCH_SIZE);
+
+ await Promise.all(
+ batch.map(async (key) => {
+ try {
+ const favorite = favorites[key];
+
+ // 跳过 live 类型的收藏
+ if (favorite.origin === 'live') {
+ return;
+ }
+
+ // 跳过已完结的收藏
+ if (favorite.is_completed) {
+ console.log(`跳过已完结的收藏: ${favorite.title}`);
+ return;
+ }
+
+ // 解析 source 和 id
+ const [source, id] = key.split('+');
+ if (!source || !id) {
+ return;
+ }
+
+ // 查找对应的 API 站点
+ const apiSite = apiSites.find((site) => site.key === source);
+ if (!apiSite) {
+ return;
+ }
+
+ // 获取最新详情
+ const detail = await getDetailFromApi(apiSite, id);
+
+ // 比较集数
+ const oldEpisodes = favorite.total_episodes;
+ const newEpisodes = detail.episodes.length;
+
+ console.log(`检查收藏: ${favorite.title} (${source}+${id})`);
+ console.log(` 旧集数: ${oldEpisodes}, 新集数: ${newEpisodes}`);
+ console.log(` 是否完结: ${favorite.is_completed}, 备注: ${favorite.vod_remarks}`);
+
+ if (newEpisodes > oldEpisodes) {
+ updates.push({
+ source,
+ id,
+ title: favorite.title,
+ old_episodes: oldEpisodes,
+ new_episodes: newEpisodes,
+ });
+
+ // 更新收藏的集数和完结状态
+ await storage.setFavorite(username, key, {
+ ...favorite,
+ total_episodes: newEpisodes,
+ is_completed: detail.vod_remarks
+ ? ['全', '完结', '大结局', 'end', '完'].some((keyword) =>
+ detail.vod_remarks!.toLowerCase().includes(keyword)
+ )
+ : false,
+ vod_remarks: detail.vod_remarks,
+ });
+ }
+ } catch (error) {
+ console.error(`检查收藏更新失败 (${key}):`, error);
+ // 继续处理其他收藏
+ }
+ })
+ );
+ }
+
+ console.log(`检查完成,发现 ${updates.length} 个更新`);
+
+ // 如果有更新,创建通知
+ if (updates.length > 0) {
+ for (const update of updates) {
+ const notification: Notification = {
+ id: `fav_update_${update.source}_${update.id}_${now}`,
+ type: 'favorite_update',
+ title: '收藏更新',
+ message: `《${update.title}》有新集数更新!从 ${update.old_episodes} 集更新到 ${update.new_episodes} 集`,
+ timestamp: now,
+ read: false,
+ metadata: {
+ source: update.source,
+ id: update.id,
+ title: update.title,
+ old_episodes: update.old_episodes,
+ new_episodes: update.new_episodes,
+ },
+ };
+
+ await storage.addNotification(username, notification);
+ }
+ }
+
+ return NextResponse.json({
+ message: updates.length > 0 ? `发现 ${updates.length} 个更新` : '没有更新',
+ updates,
+ checked: favoriteKeys.length,
+ });
+ } catch (error) {
+ console.error('检查收藏更新失败:', error);
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts
new file mode 100644
index 0000000..44531ee
--- /dev/null
+++ b/src/app/api/notifications/route.ts
@@ -0,0 +1,69 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getStorage } from '@/lib/db';
+
+export const runtime = 'nodejs';
+
+// GET: 获取所有通知
+export async function GET(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const storage = getStorage();
+ const notifications = await storage.getNotifications(authInfo.username);
+ const unreadCount = await storage.getUnreadNotificationCount(authInfo.username);
+
+ return NextResponse.json({
+ notifications,
+ unreadCount,
+ });
+ } catch (error) {
+ console.error('获取通知失败:', error);
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
+
+// POST: 标记通知为已读或删除通知
+export async function POST(request: NextRequest) {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { action, notificationId } = body;
+
+ const storage = getStorage();
+
+ if (action === 'mark_read' && notificationId) {
+ await storage.markNotificationAsRead(authInfo.username, notificationId);
+ return NextResponse.json({ message: '已标记为已读' });
+ }
+
+ if (action === 'delete' && notificationId) {
+ await storage.deleteNotification(authInfo.username, notificationId);
+ return NextResponse.json({ message: '已删除' });
+ }
+
+ if (action === 'clear_all') {
+ await storage.clearAllNotifications(authInfo.username);
+ return NextResponse.json({ message: '已清空所有通知' });
+ }
+
+ return NextResponse.json({ error: '无效的操作' }, { status: 400 });
+ } catch (error) {
+ console.error('操作通知失败:', error);
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index c89965b..a6afaf3 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -57,6 +57,55 @@ function HomeClient() {
}
}, [announcement]);
+ // 首次进入时检查收藏更新(带前端冷却检查)
+ useEffect(() => {
+ const checkFavoriteUpdates = async () => {
+ try {
+ // 检查冷却时间(前端 localStorage)
+ const COOLDOWN_TIME = 30 * 60 * 1000; // 30分钟
+ const lastCheckTime = localStorage.getItem('lastFavoriteCheckTime');
+ const now = Date.now();
+
+ if (lastCheckTime) {
+ const timeSinceLastCheck = now - parseInt(lastCheckTime, 10);
+ if (timeSinceLastCheck < COOLDOWN_TIME) {
+ const remainingMinutes = Math.ceil((COOLDOWN_TIME - timeSinceLastCheck) / 1000 / 60);
+ console.log(`收藏更新检查冷却中,还需等待 ${remainingMinutes} 分钟`);
+ return;
+ }
+ }
+
+ console.log('开始检查收藏更新...');
+ const response = await fetch('/api/favorites/check-updates', {
+ method: 'POST',
+ });
+
+ if (response.ok) {
+ // 更新本地检查时间
+ localStorage.setItem('lastFavoriteCheckTime', now.toString());
+
+ const data = await response.json();
+ if (data.updates && data.updates.length > 0) {
+ console.log(`发现 ${data.updates.length} 个收藏更新`);
+ // 触发通知更新事件
+ window.dispatchEvent(new Event('notificationsUpdated'));
+ } else {
+ console.log('没有收藏更新');
+ }
+ }
+ } catch (error) {
+ console.error('检查收藏更新失败:', error);
+ }
+ };
+
+ // 延迟3秒后检查,避免影响首页加载
+ const timer = setTimeout(() => {
+ checkFavoriteUpdates();
+ }, 3000);
+
+ return () => clearTimeout(timer);
+ }, []);
+
// 收藏夹数据
type FavoriteItem = {
id: string;
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
index 639e8bc..a0e0da2 100644
--- a/src/app/play/page.tsx
+++ b/src/app/play/page.tsx
@@ -602,6 +602,39 @@ function PlayPageClient() {
// 工具函数(Utils)
// -----------------------------------------------------------------------------
+ // 判断剧集是否已完结
+ const isSeriesCompleted = (detail: SearchResult | null): boolean => {
+ if (!detail) return false;
+
+ // 方法1:通过 vod_remarks 判断
+ if (detail.vod_remarks) {
+ const remarks = detail.vod_remarks.toLowerCase();
+ // 判定为完结的关键词
+ const completedKeywords = ['全', '完结', '大结局', 'end', '完'];
+ // 判定为连载的关键词
+ const ongoingKeywords = ['更新至', '连载', '第', '更新到'];
+
+ // 如果包含连载关键词,则为连载中
+ if (ongoingKeywords.some(keyword => remarks.includes(keyword))) {
+ return false;
+ }
+
+ // 如果包含完结关键词,则为已完结
+ if (completedKeywords.some(keyword => remarks.includes(keyword))) {
+ return true;
+ }
+ }
+
+ // 方法2:通过 vod_total 和实际集数对比判断
+ if (detail.vod_total && detail.vod_total > 0 && detail.episodes && detail.episodes.length > 0) {
+ // 如果实际集数 >= 总集数,则为已完结
+ return detail.episodes.length >= detail.vod_total;
+ }
+
+ // 无法判断,默认返回 false(连载中)
+ return false;
+ };
+
// 播放源优选函数
const preferBestSource = async (
sources: SearchResult[]
@@ -2758,11 +2791,13 @@ function PlayPageClient() {
await saveFavorite(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
- year: detailRef.current?.year,
+ year: detailRef.current?.year || 'unknown',
cover: detailRef.current?.poster || '',
total_episodes: detailRef.current?.episodes.length || 1,
save_time: Date.now(),
search_title: searchTitle,
+ is_completed: isSeriesCompleted(detailRef.current),
+ vod_remarks: detailRef.current?.vod_remarks,
});
setFavorited(true);
}
@@ -4288,14 +4323,28 @@ function PlayPageClient() {
{/* 第一行:影片标题 */}
-
- {videoTitle || '影片标题'}
- {totalEpisodes > 1 && (
-
- {` > ${
- detail?.episodes_titles?.[currentEpisodeIndex] ||
- `第 ${currentEpisodeIndex + 1} 集`
+
+
+ {videoTitle || '影片标题'}
+ {totalEpisodes > 1 && (
+
+ {` > ${
+ detail?.episodes_titles?.[currentEpisodeIndex] ||
+ `第 ${currentEpisodeIndex + 1} 集`
+ }`}
+
+ )}
+
+ {/* 完结状态标识 */}
+ {detail && totalEpisodes > 1 && (
+
+ {isSeriesCompleted(detail) ? '已完结' : '连载中'}
)}
diff --git a/src/components/NotificationPanel.tsx b/src/components/NotificationPanel.tsx
new file mode 100644
index 0000000..996cdba
--- /dev/null
+++ b/src/components/NotificationPanel.tsx
@@ -0,0 +1,243 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+'use client';
+
+import { Bell, Check, Trash2, X } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+
+import { Notification } from '@/lib/types';
+
+interface NotificationPanelProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const NotificationPanel: React.FC = ({
+ isOpen,
+ onClose,
+}) => {
+ const router = useRouter();
+ const [notifications, setNotifications] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ // 加载通知
+ const loadNotifications = async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/notifications');
+ if (response.ok) {
+ const data = await response.json();
+ setNotifications(data.notifications || []);
+ }
+ } catch (error) {
+ console.error('加载通知失败:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 标记为已读
+ const markAsRead = async (notificationId: string) => {
+ try {
+ const response = await fetch('/api/notifications', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'mark_read',
+ notificationId,
+ }),
+ });
+
+ if (response.ok) {
+ setNotifications((prev) =>
+ prev.map((n) =>
+ n.id === notificationId ? { ...n, read: true } : n
+ )
+ );
+ }
+ } catch (error) {
+ console.error('标记已读失败:', error);
+ }
+ };
+
+ // 删除通知
+ const deleteNotification = async (notificationId: string) => {
+ try {
+ const response = await fetch('/api/notifications', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'delete',
+ notificationId,
+ }),
+ });
+
+ if (response.ok) {
+ setNotifications((prev) => prev.filter((n) => n.id !== notificationId));
+ }
+ } catch (error) {
+ console.error('删除通知失败:', error);
+ }
+ };
+
+ // 清空所有通知
+ const clearAll = async () => {
+ try {
+ const response = await fetch('/api/notifications', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'clear_all',
+ }),
+ });
+
+ if (response.ok) {
+ setNotifications([]);
+ }
+ } catch (error) {
+ console.error('清空通知失败:', error);
+ }
+ };
+
+ // 处理通知点击
+ const handleNotificationClick = (notification: Notification) => {
+ // 标记为已读
+ if (!notification.read) {
+ markAsRead(notification.id);
+ }
+
+ // 根据通知类型跳转
+ if (notification.type === 'favorite_update' && notification.metadata) {
+ const { source, id } = notification.metadata;
+ router.push(`/play?source=${source}&id=${id}`);
+ onClose();
+ }
+ };
+
+ // 打开面板时加载通知
+ useEffect(() => {
+ if (isOpen) {
+ loadNotifications();
+ }
+ }, [isOpen]);
+
+ return (
+ <>
+ {/* 背景遮罩 */}
+
+
+ {/* 通知面板 */}
+
+ {/* 标题栏 */}
+
+
+
+
+ 通知中心
+
+ {notifications.length > 0 && (
+
+ {notifications.filter((n) => !n.read).length} 条未读
+
+ )}
+
+
+ {notifications.length > 0 && (
+
+ )}
+
+
+
+
+ {/* 通知列表 */}
+
+ {loading ? (
+
+ ) : notifications.length === 0 ? (
+
+ ) : (
+
+ {notifications.map((notification) => (
+
handleNotificationClick(notification)}
+ >
+ {/* 未读标识 */}
+ {!notification.read && (
+
+ )}
+
+ {/* 通知内容 */}
+
+
+
+ {notification.title}
+
+
+
+ {notification.message}
+
+
+ {new Date(notification.timestamp).toLocaleString('zh-CN')}
+
+
+
+ {/* 操作按钮 */}
+
+ {!notification.read && (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ >
+ );
+};
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx
index babbe84..b0af501 100644
--- a/src/components/UserMenu.tsx
+++ b/src/components/UserMenu.tsx
@@ -3,6 +3,7 @@
'use client';
import {
+ Bell,
Check,
ChevronDown,
Copy,
@@ -28,6 +29,7 @@ import { UpdateStatus } from '@/lib/version_check';
import { useVersionCheck } from './VersionCheckProvider';
import { VersionPanel } from './VersionPanel';
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
+import { NotificationPanel } from './NotificationPanel';
interface AuthInfo {
username?: string;
@@ -43,9 +45,11 @@ export const UserMenu: React.FC = () => {
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false);
+ const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
const [authInfo, setAuthInfo] = useState(null);
const [storageType, setStorageType] = useState('localstorage');
const [mounted, setMounted] = useState(false);
+ const [unreadCount, setUnreadCount] = useState(0);
// 订阅相关状态
const [subscribeEnabled, setSubscribeEnabled] = useState(false);
@@ -129,6 +133,36 @@ export const UserMenu: React.FC = () => {
setMounted(true);
}, []);
+ // 加载未读通知数量
+ const loadUnreadCount = async () => {
+ try {
+ const response = await fetch('/api/notifications');
+ if (response.ok) {
+ const data = await response.json();
+ setUnreadCount(data.unreadCount || 0);
+ }
+ } catch (error) {
+ console.error('加载未读通知数量失败:', error);
+ }
+ };
+
+ // 首次加载时检查未读通知数量
+ useEffect(() => {
+ loadUnreadCount();
+ }, []);
+
+ // 监听通知更新事件
+ useEffect(() => {
+ const handleNotificationsUpdated = () => {
+ loadUnreadCount();
+ };
+
+ window.addEventListener('notificationsUpdated', handleNotificationsUpdated);
+ return () => {
+ window.removeEventListener('notificationsUpdated', handleNotificationsUpdated);
+ };
+ }, []);
+
// 从运行时配置读取订阅是否启用
useEffect(() => {
if (typeof window !== 'undefined') {
@@ -600,6 +634,23 @@ export const UserMenu: React.FC = () => {
{/* 菜单项 */}
+ {/* 通知按钮 */}
+
+
{/* 设置按钮 */}
+ {/* 版本更新红点 */}
{updateStatus === UpdateStatus.HAS_UPDATE && (
)}
+ {/* 未读通知红点 */}
+ {unreadCount > 0 && (
+
+ )}
{/* 使用 Portal 将菜单面板渲染到 document.body */}
@@ -1390,6 +1446,20 @@ export const UserMenu: React.FC = () => {
isOpen={isOfflineDownloadPanelOpen}
onClose={() => setIsOfflineDownloadPanelOpen(false)}
/>
+
+ {/* 使用 Portal 将通知面板渲染到 document.body */}
+ {isNotificationPanelOpen &&
+ mounted &&
+ createPortal(
+ {
+ setIsNotificationPanelOpen(false);
+ loadUnreadCount(); // 关闭时刷新未读数量
+ }}
+ />,
+ document.body
+ )}
>
);
};
diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts
index 774c8fa..cf78f10 100644
--- a/src/lib/db.client.ts
+++ b/src/lib/db.client.ts
@@ -53,6 +53,8 @@ export interface Favorite {
save_time: number;
search_title?: string;
origin?: 'vod' | 'live';
+ is_completed?: boolean; // 是否已完结
+ vod_remarks?: string; // 视频备注信息
}
// ---- 缓存数据结构 ----
diff --git a/src/lib/db.ts b/src/lib/db.ts
index e838455..074c4cc 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -33,7 +33,7 @@ function createStorage(): IStorage {
// 单例存储实例
let storageInstance: IStorage | null = null;
-function getStorage(): IStorage {
+export function getStorage(): IStorage {
if (!storageInstance) {
storageInstance = createStorage();
}
diff --git a/src/lib/downstream.ts b/src/lib/downstream.ts
index f98f705..348b9e6 100644
--- a/src/lib/downstream.ts
+++ b/src/lib/downstream.ts
@@ -16,6 +16,7 @@ interface ApiSearchItem {
vod_content?: string;
vod_douban_id?: number;
type_name?: string;
+ vod_total?: number;
}
/**
@@ -114,6 +115,8 @@ async function searchWithCache(
desc: cleanHtmlTags(item.vod_content || ''),
type_name: item.type_name,
douban_id: item.vod_douban_id,
+ vod_remarks: item.vod_remarks,
+ vod_total: item.vod_total,
};
});
@@ -283,6 +286,8 @@ export async function getDetailFromApi(
desc: cleanHtmlTags(videoDetail.vod_content),
type_name: videoDetail.type_name,
douban_id: videoDetail.vod_douban_id,
+ vod_remarks: videoDetail.vod_remarks,
+ vod_total: videoDetail.vod_total,
};
}
@@ -363,5 +368,7 @@ async function handleSpecialSourceDetail(
desc: descText,
type_name: '',
douban_id: 0,
+ vod_remarks: undefined,
+ vod_total: undefined,
};
}
diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts
index 62c797a..98ba2a0 100644
--- a/src/lib/redis-base.db.ts
+++ b/src/lib/redis-base.db.ts
@@ -517,4 +517,85 @@ export abstract class BaseRedisStorage implements IStorage {
async deleteGlobalValue(key: string): Promise {
await this.withRetry(() => this.client.del(this.globalValueKey(key)));
}
+
+ // ---------- 通知相关 ----------
+ private notificationsKey(userName: string) {
+ return `u:${userName}:notifications`;
+ }
+
+ private lastFavoriteCheckKey(userName: string) {
+ return `u:${userName}:last_fav_check`;
+ }
+
+ async getNotifications(userName: string): Promise {
+ const val = await this.withRetry(() =>
+ this.client.get(this.notificationsKey(userName))
+ );
+ return val ? (JSON.parse(val) as import('./types').Notification[]) : [];
+ }
+
+ async addNotification(
+ userName: string,
+ notification: import('./types').Notification
+ ): Promise {
+ const notifications = await this.getNotifications(userName);
+ notifications.unshift(notification); // 新通知放在最前面
+ // 限制通知数量,最多保留100条
+ if (notifications.length > 100) {
+ notifications.splice(100);
+ }
+ await this.withRetry(() =>
+ this.client.set(this.notificationsKey(userName), JSON.stringify(notifications))
+ );
+ }
+
+ async markNotificationAsRead(
+ userName: string,
+ notificationId: string
+ ): Promise {
+ const notifications = await this.getNotifications(userName);
+ const notification = notifications.find((n) => n.id === notificationId);
+ if (notification) {
+ notification.read = true;
+ await this.withRetry(() =>
+ this.client.set(this.notificationsKey(userName), JSON.stringify(notifications))
+ );
+ }
+ }
+
+ async deleteNotification(
+ userName: string,
+ notificationId: string
+ ): Promise {
+ const notifications = await this.getNotifications(userName);
+ const filtered = notifications.filter((n) => n.id !== notificationId);
+ await this.withRetry(() =>
+ this.client.set(this.notificationsKey(userName), JSON.stringify(filtered))
+ );
+ }
+
+ async clearAllNotifications(userName: string): Promise {
+ await this.withRetry(() => this.client.del(this.notificationsKey(userName)));
+ }
+
+ async getUnreadNotificationCount(userName: string): Promise {
+ const notifications = await this.getNotifications(userName);
+ return notifications.filter((n) => !n.read).length;
+ }
+
+ async getLastFavoriteCheckTime(userName: string): Promise {
+ const val = await this.withRetry(() =>
+ this.client.get(this.lastFavoriteCheckKey(userName))
+ );
+ return val ? parseInt(val, 10) : 0;
+ }
+
+ async setLastFavoriteCheckTime(
+ userName: string,
+ timestamp: number
+ ): Promise {
+ await this.withRetry(() =>
+ this.client.set(this.lastFavoriteCheckKey(userName), timestamp.toString())
+ );
+ }
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 59e35d6..df20898 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -24,6 +24,8 @@ export interface Favorite {
save_time: number; // 记录保存时间(时间戳)
search_title: string; // 搜索时使用的标题
origin?: 'vod' | 'live';
+ is_completed?: boolean; // 是否已完结
+ vod_remarks?: string; // 视频备注信息
}
// 存储接口
@@ -96,6 +98,18 @@ export interface IStorage {
getGlobalValue(key: string): Promise;
setGlobalValue(key: string, value: string): Promise;
deleteGlobalValue(key: string): Promise;
+
+ // 通知相关
+ getNotifications(userName: string): Promise;
+ addNotification(userName: string, notification: Notification): Promise;
+ markNotificationAsRead(userName: string, notificationId: string): Promise;
+ deleteNotification(userName: string, notificationId: string): Promise;
+ clearAllNotifications(userName: string): Promise;
+ getUnreadNotificationCount(userName: string): Promise;
+
+ // 收藏更新检查相关
+ getLastFavoriteCheckTime(userName: string): Promise;
+ setLastFavoriteCheckTime(userName: string, timestamp: number): Promise;
}
// 搜索结果数据结构
@@ -112,6 +126,8 @@ export interface SearchResult {
desc?: string;
type_name?: string;
douban_id?: number;
+ vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等)
+ vod_total?: number; // 总集数
}
// 豆瓣数据结构
@@ -161,3 +177,32 @@ export interface EpisodeFilterRule {
export interface EpisodeFilterConfig {
rules: EpisodeFilterRule[]; // 过滤规则列表
}
+
+// 通知类型枚举
+export type NotificationType =
+ | 'favorite_update' // 收藏更新
+ | 'system' // 系统通知
+ | 'announcement'; // 公告
+
+// 通知数据结构
+export interface Notification {
+ id: string; // 通知ID
+ type: NotificationType; // 通知类型
+ title: string; // 通知标题
+ message: string; // 通知内容
+ timestamp: number; // 通知时间戳
+ read: boolean; // 是否已读
+ metadata?: Record; // 额外的元数据(如收藏更新的source、id等)
+}
+
+// 收藏更新检查结果
+export interface FavoriteUpdateCheck {
+ last_check_time: number; // 上次检查时间戳
+ updates: Array<{
+ source: string;
+ id: string;
+ title: string;
+ old_episodes: number;
+ new_episodes: number;
+ }>;
+}
diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts
index 00ed464..a9ab356 100644
--- a/src/lib/upstash.db.ts
+++ b/src/lib/upstash.db.ts
@@ -415,6 +415,87 @@ export class UpstashRedisStorage implements IStorage {
async deleteGlobalValue(key: string): Promise {
await withRetry(() => this.client.del(this.globalValueKey(key)));
}
+
+ // ---------- 通知相关 ----------
+ private notificationsKey(userName: string) {
+ return `u:${userName}:notifications`;
+ }
+
+ private lastFavoriteCheckKey(userName: string) {
+ return `u:${userName}:last_fav_check`;
+ }
+
+ async getNotifications(userName: string): Promise {
+ const val = await withRetry(() =>
+ this.client.get(this.notificationsKey(userName))
+ );
+ return val ? (val as import('./types').Notification[]) : [];
+ }
+
+ async addNotification(
+ userName: string,
+ notification: import('./types').Notification
+ ): Promise {
+ const notifications = await this.getNotifications(userName);
+ notifications.unshift(notification); // 新通知放在最前面
+ // 限制通知数量,最多保留100条
+ if (notifications.length > 100) {
+ notifications.splice(100);
+ }
+ await withRetry(() =>
+ this.client.set(this.notificationsKey(userName), notifications)
+ );
+ }
+
+ async markNotificationAsRead(
+ userName: string,
+ notificationId: string
+ ): Promise {
+ const notifications = await this.getNotifications(userName);
+ const notification = notifications.find((n) => n.id === notificationId);
+ if (notification) {
+ notification.read = true;
+ await withRetry(() =>
+ this.client.set(this.notificationsKey(userName), notifications)
+ );
+ }
+ }
+
+ async deleteNotification(
+ userName: string,
+ notificationId: string
+ ): Promise {
+ const notifications = await this.getNotifications(userName);
+ const filtered = notifications.filter((n) => n.id !== notificationId);
+ await withRetry(() =>
+ this.client.set(this.notificationsKey(userName), filtered)
+ );
+ }
+
+ async clearAllNotifications(userName: string): Promise {
+ await withRetry(() => this.client.del(this.notificationsKey(userName)));
+ }
+
+ async getUnreadNotificationCount(userName: string): Promise {
+ const notifications = await this.getNotifications(userName);
+ return notifications.filter((n) => !n.read).length;
+ }
+
+ async getLastFavoriteCheckTime(userName: string): Promise {
+ const val = await withRetry(() =>
+ this.client.get(this.lastFavoriteCheckKey(userName))
+ );
+ return val ? (val as number) : 0;
+ }
+
+ async setLastFavoriteCheckTime(
+ userName: string,
+ timestamp: number
+ ): Promise {
+ await withRetry(() =>
+ this.client.set(this.lastFavoriteCheckKey(userName), timestamp)
+ );
+ }
}
// 单例 Upstash Redis 客户端