feat: add cron to refresh records and favorites

This commit is contained in:
shinya
2025-07-03 12:55:16 +08:00
parent 038f94fa9b
commit 0585d36cad
6 changed files with 154 additions and 7 deletions

View File

@@ -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",

9
pnpm-lock.yaml generated
View File

@@ -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

View File

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

30
src/lib/cron.ts Normal file
View File

@@ -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 {}; // 仅用于确保这是一个模块

View File

@@ -61,6 +61,9 @@ export interface IStorage {
getSearchHistory(): Promise<string[]>;
addSearchHistory(keyword: string): Promise<void>;
deleteSearchHistory(keyword?: string): Promise<void>;
// 用户列表
getAllUsers(): Promise<string[]>;
}
// ---------------- Redis 实现 ----------------
@@ -203,6 +206,17 @@ class RedisStorage implements IStorage {
await this.client.del(this.shKey);
}
}
// ---------- 获取全部用户 ----------
async getAllUsers(): Promise<string[]> {
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<void> {
await this.storage.deleteSearchHistory(keyword);
}
// 获取全部用户名
async getAllUsers(): Promise<string[]> {
if (typeof (this.storage as any).getAllUsers === 'function') {
return (this.storage as any).getAllUsers();
}
return [];
}
}
// 导出默认实例

View File

@@ -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<VideoDetail>
const detailCache = new Map<string, Promise<VideoDetail>>();
// 获取详情 Promise带缓存
const getDetail = (
source: string,
id: string,
fallbackTitle: string
): Promise<VideoDetail> => {
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;