diff --git a/package.json b/package.json index 5e6941d..f634ebc 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@cloudflare/next-on-pages": "^1.13.12", "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", + "@upstash/redis": "^1.25.0", "@vidstack/react": "^1.12.13", "clsx": "^2.0.0", "framer-motion": "^12.18.1", @@ -32,15 +33,15 @@ "next": "^14.2.23", "next-pwa": "^5.6.0", "next-themes": "^0.4.6", + "node-cron": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.4.0", + "redis": "^4.6.7", "swiper": "^11.2.8", "tailwind-merge": "^2.6.0", "vidstack": "^0.6.15", - "@upstash/redis": "^1.25.0", - "zod": "^3.24.1", - "redis": "^4.6.7" + "zod": "^3.24.1" }, "devDependencies": { "@commitlint/cli": "^16.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae43736..78c31b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + node-cron: + specifier: ^4.2.0 + version: 4.2.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -4735,6 +4738,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-cron@4.2.0: + resolution: {integrity: sha512-nOdP7uH7u55w7ybQq9fusXtsResok+ErzvOBydJUPBBaQ9W+EfBaBWFPgJ8sOB7FWQednDvVBJtgP5xA0bME7Q==} + engines: {node: '>=6.0.0'} + node-fetch@2.6.7: resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} engines: {node: 4.x || >=6.0.0} @@ -11612,6 +11619,8 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-cron@4.2.0: {} + node-fetch@2.6.7: dependencies: whatwg-url: 5.0.0 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8ae938e..dbaba9a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from 'next'; import { Inter } from 'next/font/google'; +import '../lib/cron'; import './globals.css'; @@ -35,10 +36,7 @@ export default function RootLayout({ // 将运行时配置注入到全局 window 对象,供客户端在运行时读取 const runtimeConfig = { - STORAGE_TYPE: - process.env.STORAGE_TYPE || - process.env.NEXT_PUBLIC_STORAGE_TYPE || - 'localstorage', + STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage', ENABLE_REGISTER: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true', }; diff --git a/src/lib/cron.ts b/src/lib/cron.ts new file mode 100644 index 0000000..c413d0b --- /dev/null +++ b/src/lib/cron.ts @@ -0,0 +1,30 @@ +import cron from 'node-cron'; + +import refreshRecordAndFavorites from '@/lib/refreshRecordAndFavorites'; + +/* + * 初始化定时任务:每个小时的 02 分执行一次。 + * 若需要添加更多任务,可在此文件中继续编写。 + */ + +declare global { + // 避免在开发热重载或多次导入时重复初始化 + // eslint-disable-next-line no-var + var __moonTVCronInitialized: boolean | undefined; +} + +if (!global.__moonTVCronInitialized) { + cron.schedule( + '2 * * * *', + async () => { + refreshRecordAndFavorites(); + }, + { + timezone: 'Asia/Shanghai', + } + ); + + global.__moonTVCronInitialized = true; +} + +export {}; // 仅用于确保这是一个模块 diff --git a/src/lib/db.ts b/src/lib/db.ts index 87136c3..6c05464 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -61,6 +61,9 @@ export interface IStorage { getSearchHistory(): Promise; addSearchHistory(keyword: string): Promise; deleteSearchHistory(keyword?: string): Promise; + + // 用户列表 + getAllUsers(): Promise; } // ---------------- Redis 实现 ---------------- @@ -203,6 +206,17 @@ class RedisStorage implements IStorage { await this.client.del(this.shKey); } } + + // ---------- 获取全部用户 ---------- + async getAllUsers(): Promise { + const keys = await this.client.keys('u:*:pwd'); + return keys + .map((k) => { + const match = k.match(/^u:(.+?):pwd$/); + return match ? match[1] : undefined; + }) + .filter((u): u is string => typeof u === 'string'); + } } // 创建存储实例 @@ -368,6 +382,14 @@ export class DbManager { async deleteSearchHistory(keyword?: string): Promise { await this.storage.deleteSearchHistory(keyword); } + + // 获取全部用户名 + async getAllUsers(): Promise { + if (typeof (this.storage as any).getAllUsers === 'function') { + return (this.storage as any).getAllUsers(); + } + return []; + } } // 导出默认实例 diff --git a/src/lib/refreshRecordAndFavorites.ts b/src/lib/refreshRecordAndFavorites.ts new file mode 100644 index 0000000..86b6644 --- /dev/null +++ b/src/lib/refreshRecordAndFavorites.ts @@ -0,0 +1,87 @@ +/* eslint-disable no-console */ + +import { db } from '@/lib/db'; +import { type VideoDetail, fetchVideoDetail } from '@/lib/fetchVideoDetail'; + +const STORAGE_TYPE = process.env.NEXT_PUBLIC_STORAGE_TYPE ?? 'localstorage'; + +async function refreshRecordAndFavorites() { + if (STORAGE_TYPE === 'localstorage') { + return; + } + + try { + const users = await db.getAllUsers(); + // 函数级缓存:key 为 `${source}+${id}`,值为 Promise + const detailCache = new Map>(); + + // 获取详情 Promise(带缓存) + const getDetail = ( + source: string, + id: string, + fallbackTitle: string + ): Promise => { + const key = `${source}+${id}`; + let promise = detailCache.get(key); + if (!promise) { + promise = fetchVideoDetail({ + source, + id, + fallbackTitle, + fallbackYear: '', + }); + detailCache.set(key, promise); + } + return promise; + }; + + for (const user of users) { + // 播放记录 + const playRecords = await db.getAllPlayRecords(user); + for (const [key, record] of Object.entries(playRecords)) { + const [source, id] = key.split('+'); + if (!source || !id) continue; + + const detail: VideoDetail = await getDetail(source, id, record.title); + + const episodeCount = detail.episodes?.length || 0; + if (episodeCount > 0 && episodeCount !== record.total_episodes) { + await db.savePlayRecord(user, source, id, { + title: record.title, + source_name: record.source_name, + cover: record.cover, + index: record.index, + total_episodes: episodeCount, + play_time: record.play_time, + total_time: record.total_time, + save_time: record.save_time, + }); + } + } + + // 收藏 + const favorites = await db.getAllFavorites(user); + for (const [key, fav] of Object.entries(favorites)) { + const [source, id] = key.split('+'); + if (!source || !id) continue; + + const favDetail: VideoDetail = await getDetail(source, id, fav.title); + + const favEpisodeCount = favDetail.episodes?.length || 0; + if (favEpisodeCount > 0 && favEpisodeCount !== fav.total_episodes) { + await db.saveFavorite(user, source, id, { + title: fav.title, + source_name: fav.source_name, + cover: fav.cover, + total_episodes: favEpisodeCount, + save_time: fav.save_time, + }); + } + } + } + } catch (err) { + console.error('刷新播放记录/收藏失败', err); + } +} + +export default refreshRecordAndFavorites;