Update
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
export interface AdminConfig {
|
||||
SiteConfig: {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
SearchDownstreamMaxPage: number;
|
||||
SiteInterfaceCacheTime: number;
|
||||
ImageProxy: string;
|
||||
DoubanProxy: string;
|
||||
};
|
||||
UserConfig: {
|
||||
AllowRegister: boolean;
|
||||
Users: {
|
||||
username: string;
|
||||
role: 'user' | 'admin' | 'owner';
|
||||
banned?: boolean;
|
||||
}[];
|
||||
};
|
||||
SourceConfig: {
|
||||
key: string;
|
||||
name: string;
|
||||
api: string;
|
||||
detail?: string;
|
||||
from: 'config' | 'custom';
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AdminConfigResult {
|
||||
Role: 'owner' | 'admin';
|
||||
Config: AdminConfig;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// 从cookie获取认证信息 (服务端使用)
|
||||
export function getAuthInfoFromCookie(request: NextRequest): {
|
||||
password?: string;
|
||||
username?: string;
|
||||
signature?: string;
|
||||
timestamp?: number;
|
||||
} | null {
|
||||
const authCookie = request.cookies.get('auth');
|
||||
|
||||
if (!authCookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = decodeURIComponent(authCookie.value);
|
||||
const authData = JSON.parse(decoded);
|
||||
return authData;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 从cookie获取认证信息 (客户端使用)
|
||||
export function getAuthInfoFromBrowserCookie(): {
|
||||
password?: string;
|
||||
username?: string;
|
||||
signature?: string;
|
||||
timestamp?: number;
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
} | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析 document.cookie
|
||||
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
|
||||
const trimmed = cookie.trim();
|
||||
const firstEqualIndex = trimmed.indexOf('=');
|
||||
|
||||
if (firstEqualIndex > 0) {
|
||||
const key = trimmed.substring(0, firstEqualIndex);
|
||||
const value = trimmed.substring(firstEqualIndex + 1);
|
||||
if (key && value) {
|
||||
acc[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const authCookie = cookies['auth'];
|
||||
if (!authCookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理可能的双重编码
|
||||
let decoded = decodeURIComponent(authCookie);
|
||||
|
||||
// 如果解码后仍然包含 %,说明是双重编码,需要再次解码
|
||||
if (decoded.includes('%')) {
|
||||
decoded = decodeURIComponent(decoded);
|
||||
}
|
||||
|
||||
const authData = JSON.parse(decoded);
|
||||
return authData;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { getStorage } from '@/lib/db';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import runtimeConfig from './runtime';
|
||||
|
||||
export interface ApiSite {
|
||||
key: string;
|
||||
api: string;
|
||||
name: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface ConfigFileStruct {
|
||||
cache_time?: number;
|
||||
api_site: {
|
||||
[key: string]: ApiSite;
|
||||
};
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
search: {
|
||||
path: '?ac=videolist&wd=',
|
||||
pagePath: '?ac=videolist&wd={query}&pg={page}',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
path: '?ac=videolist&ids=',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 在模块加载时根据环境决定配置来源
|
||||
let fileConfig: ConfigFileStruct;
|
||||
let cachedConfig: AdminConfig;
|
||||
|
||||
async function initConfig() {
|
||||
if (cachedConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.DOCKER_ENV === 'true') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
const _require = eval('require') as NodeRequire;
|
||||
const fs = _require('fs') as typeof import('fs');
|
||||
const path = _require('path') as typeof import('path');
|
||||
|
||||
const configPath = path.join(process.cwd(), 'config.json');
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
fileConfig = JSON.parse(raw) as ConfigFileStruct;
|
||||
console.log('load dynamic config success');
|
||||
} else {
|
||||
// 默认使用编译时生成的配置
|
||||
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||
}
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType !== 'localstorage') {
|
||||
// 数据库存储,读取并补全管理员配置
|
||||
const storage = getStorage();
|
||||
|
||||
try {
|
||||
// 尝试从数据库获取管理员配置
|
||||
let adminConfig: AdminConfig | null = null;
|
||||
if (storage && typeof (storage as any).getAdminConfig === 'function') {
|
||||
adminConfig = await (storage as any).getAdminConfig();
|
||||
}
|
||||
|
||||
// 获取所有用户名,用于补全 Users
|
||||
let userNames: string[] = [];
|
||||
if (storage && typeof (storage as any).getAllUsers === 'function') {
|
||||
try {
|
||||
userNames = await (storage as any).getAllUsers();
|
||||
} catch (e) {
|
||||
console.error('获取用户列表失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 从文件中获取源信息,用于补全源
|
||||
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||
|
||||
if (adminConfig) {
|
||||
// 补全 SourceConfig
|
||||
const existed = new Set(
|
||||
(adminConfig.SourceConfig || []).map((s) => s.key)
|
||||
);
|
||||
apiSiteEntries.forEach(([key, site]) => {
|
||||
if (!existed.has(key)) {
|
||||
adminConfig!.SourceConfig.push({
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
|
||||
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
|
||||
adminConfig.SourceConfig.forEach((source) => {
|
||||
if (!apiSiteKeys.has(source.key)) {
|
||||
source.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
const existedUsers = new Set(
|
||||
(adminConfig.UserConfig.Users || []).map((u) => u.username)
|
||||
);
|
||||
userNames.forEach((uname) => {
|
||||
if (!existedUsers.has(uname)) {
|
||||
adminConfig!.UserConfig.Users.push({
|
||||
username: uname,
|
||||
role: 'user',
|
||||
});
|
||||
}
|
||||
});
|
||||
// 站长
|
||||
const ownerUser = process.env.USERNAME;
|
||||
if (ownerUser) {
|
||||
adminConfig!.UserConfig.Users = adminConfig!.UserConfig.Users.filter(
|
||||
(u) => u.username !== ownerUser
|
||||
);
|
||||
adminConfig!.UserConfig.Users.unshift({
|
||||
username: ownerUser,
|
||||
role: 'owner',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 数据库中没有配置,创建新的管理员配置
|
||||
let allUsers = userNames.map((uname) => ({
|
||||
username: uname,
|
||||
role: 'user',
|
||||
}));
|
||||
const ownerUser = process.env.USERNAME;
|
||||
if (ownerUser) {
|
||||
allUsers = allUsers.filter((u) => u.username !== ownerUser);
|
||||
allUsers.unshift({
|
||||
username: ownerUser,
|
||||
role: 'owner',
|
||||
});
|
||||
}
|
||||
adminConfig = {
|
||||
SiteConfig: {
|
||||
SiteName: process.env.SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage:
|
||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||
ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
},
|
||||
UserConfig: {
|
||||
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||
Users: allUsers as any,
|
||||
},
|
||||
SourceConfig: apiSiteEntries.map(([key, site]) => ({
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// 写回数据库(更新/创建)
|
||||
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||
await (storage as any).setAdminConfig(adminConfig);
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
cachedConfig = adminConfig;
|
||||
} catch (err) {
|
||||
console.error('加载管理员配置失败:', err);
|
||||
}
|
||||
} else {
|
||||
// 本地存储直接使用文件配置
|
||||
cachedConfig = {
|
||||
SiteConfig: {
|
||||
SiteName: process.env.SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage:
|
||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||
ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
},
|
||||
UserConfig: {
|
||||
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||
Users: [],
|
||||
},
|
||||
SourceConfig: Object.entries(fileConfig.api_site).map(([key, site]) => ({
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})),
|
||||
} as AdminConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<AdminConfig> {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (process.env.DOCKER_ENV === 'true' || storageType === 'localstorage') {
|
||||
await initConfig();
|
||||
return cachedConfig;
|
||||
}
|
||||
// 非 docker 环境且 DB 存储,直接读 db 配置
|
||||
const storage = getStorage();
|
||||
let adminConfig: AdminConfig | null = null;
|
||||
if (storage && typeof (storage as any).getAdminConfig === 'function') {
|
||||
adminConfig = await (storage as any).getAdminConfig();
|
||||
}
|
||||
if (adminConfig) {
|
||||
// 合并一些环境变量配置
|
||||
adminConfig.SiteConfig.SiteName = process.env.SITE_NAME || 'MoonTV';
|
||||
adminConfig.SiteConfig.Announcement =
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||
adminConfig.UserConfig.AllowRegister =
|
||||
process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
|
||||
adminConfig.SiteConfig.ImageProxy =
|
||||
process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
|
||||
adminConfig.SiteConfig.DoubanProxy =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
||||
|
||||
// 合并文件中的源信息
|
||||
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||
const existed = new Set((adminConfig.SourceConfig || []).map((s) => s.key));
|
||||
apiSiteEntries.forEach(([key, site]) => {
|
||||
if (!existed.has(key)) {
|
||||
adminConfig!.SourceConfig.push({
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
|
||||
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
|
||||
adminConfig.SourceConfig.forEach((source) => {
|
||||
if (!apiSiteKeys.has(source.key)) {
|
||||
source.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
const ownerUser = process.env.USERNAME || '';
|
||||
// 检查配置中的站长用户是否和 USERNAME 匹配,如果不匹配则降级为普通用户
|
||||
let containOwner = false;
|
||||
adminConfig.UserConfig.Users.forEach((user) => {
|
||||
if (user.username !== ownerUser && user.role === 'owner') {
|
||||
user.role = 'user';
|
||||
}
|
||||
if (user.username === ownerUser) {
|
||||
containOwner = true;
|
||||
user.role = 'owner';
|
||||
}
|
||||
});
|
||||
|
||||
// 如果不在则添加
|
||||
if (!containOwner) {
|
||||
adminConfig.UserConfig.Users.unshift({
|
||||
username: ownerUser,
|
||||
role: 'owner',
|
||||
});
|
||||
}
|
||||
cachedConfig = adminConfig;
|
||||
} else {
|
||||
// DB 无配置,执行一次初始化
|
||||
await initConfig();
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
export async function resetConfig() {
|
||||
const storage = getStorage();
|
||||
// 获取所有用户名,用于补全 Users
|
||||
let userNames: string[] = [];
|
||||
if (storage && typeof (storage as any).getAllUsers === 'function') {
|
||||
try {
|
||||
userNames = await (storage as any).getAllUsers();
|
||||
} catch (e) {
|
||||
console.error('获取用户列表失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.DOCKER_ENV === 'true') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
const _require = eval('require') as NodeRequire;
|
||||
const fs = _require('fs') as typeof import('fs');
|
||||
const path = _require('path') as typeof import('path');
|
||||
|
||||
const configPath = path.join(process.cwd(), 'config.json');
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
fileConfig = JSON.parse(raw) as ConfigFileStruct;
|
||||
console.log('load dynamic config success');
|
||||
} else {
|
||||
// 默认使用编译时生成的配置
|
||||
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||
}
|
||||
|
||||
// 从文件中获取源信息,用于补全源
|
||||
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||
let allUsers = userNames.map((uname) => ({
|
||||
username: uname,
|
||||
role: 'user',
|
||||
}));
|
||||
const ownerUser = process.env.USERNAME;
|
||||
if (ownerUser) {
|
||||
allUsers = allUsers.filter((u) => u.username !== ownerUser);
|
||||
allUsers.unshift({
|
||||
username: ownerUser,
|
||||
role: 'owner',
|
||||
});
|
||||
}
|
||||
const adminConfig = {
|
||||
SiteConfig: {
|
||||
SiteName: process.env.SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage:
|
||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||
ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
},
|
||||
UserConfig: {
|
||||
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||
Users: allUsers as any,
|
||||
},
|
||||
SourceConfig: apiSiteEntries.map(([key, site]) => ({
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
})),
|
||||
} as AdminConfig;
|
||||
|
||||
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||
await (storage as any).setAdminConfig(adminConfig);
|
||||
}
|
||||
if (cachedConfig == null) {
|
||||
// serverless 环境,直接使用 adminConfig
|
||||
cachedConfig = adminConfig;
|
||||
}
|
||||
cachedConfig.SiteConfig = adminConfig.SiteConfig;
|
||||
cachedConfig.UserConfig = adminConfig.UserConfig;
|
||||
cachedConfig.SourceConfig = adminConfig.SourceConfig;
|
||||
}
|
||||
|
||||
export async function getCacheTime(): Promise<number> {
|
||||
const config = await getConfig();
|
||||
return config.SiteConfig.SiteInterfaceCacheTime || 7200;
|
||||
}
|
||||
|
||||
export async function getAvailableApiSites(): Promise<ApiSite[]> {
|
||||
const config = await getConfig();
|
||||
return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
api: s.api,
|
||||
detail: s.detail,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
|
||||
// D1 数据库接口
|
||||
interface D1Database {
|
||||
prepare(sql: string): D1PreparedStatement;
|
||||
exec(sql: string): Promise<D1ExecResult>;
|
||||
batch(statements: D1PreparedStatement[]): Promise<D1Result[]>;
|
||||
}
|
||||
|
||||
interface D1PreparedStatement {
|
||||
bind(...values: any[]): D1PreparedStatement;
|
||||
first<T = any>(colName?: string): Promise<T | null>;
|
||||
run(): Promise<D1Result>;
|
||||
all<T = any>(): Promise<D1Result<T>>;
|
||||
}
|
||||
|
||||
interface D1Result<T = any> {
|
||||
results: T[];
|
||||
success: boolean;
|
||||
error?: string;
|
||||
meta: {
|
||||
changed_db: boolean;
|
||||
changes: number;
|
||||
last_row_id: number;
|
||||
duration: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface D1ExecResult {
|
||||
count: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// 获取全局D1数据库实例
|
||||
function getD1Database(): D1Database {
|
||||
return (process.env as any).DB as D1Database;
|
||||
}
|
||||
|
||||
export class D1Storage implements IStorage {
|
||||
private db: D1Database | null = null;
|
||||
|
||||
private async getDatabase(): Promise<D1Database> {
|
||||
if (!this.db) {
|
||||
this.db = getD1Database();
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
// 播放记录相关
|
||||
async getPlayRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<PlayRecord | null> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT * FROM play_records WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.first<any>();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
title: result.title,
|
||||
source_name: result.source_name,
|
||||
cover: result.cover,
|
||||
year: result.year,
|
||||
index: result.index_episode,
|
||||
total_episodes: result.total_episodes,
|
||||
play_time: result.play_time,
|
||||
total_time: result.total_time,
|
||||
save_time: result.save_time,
|
||||
search_title: result.search_title || undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to get play record:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare(
|
||||
`
|
||||
INSERT OR REPLACE INTO play_records
|
||||
(username, key, title, source_name, cover, year, index_episode, total_episodes, play_time, total_time, save_time, search_title)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
record.title,
|
||||
record.source_name,
|
||||
record.cover,
|
||||
record.year,
|
||||
record.index,
|
||||
record.total_episodes,
|
||||
record.play_time,
|
||||
record.total_time,
|
||||
record.save_time,
|
||||
record.search_title || null
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to set play record:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllPlayRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, PlayRecord>> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'SELECT * FROM play_records WHERE username = ? ORDER BY save_time DESC'
|
||||
)
|
||||
.bind(userName)
|
||||
.all<any>();
|
||||
|
||||
const records: Record<string, PlayRecord> = {};
|
||||
|
||||
result.results.forEach((row: any) => {
|
||||
records[row.key] = {
|
||||
title: row.title,
|
||||
source_name: row.source_name,
|
||||
cover: row.cover,
|
||||
year: row.year,
|
||||
index: row.index_episode,
|
||||
total_episodes: row.total_episodes,
|
||||
play_time: row.play_time,
|
||||
total_time: row.total_time,
|
||||
save_time: row.save_time,
|
||||
search_title: row.search_title || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error('Failed to get all play records:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare('DELETE FROM play_records WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete play record:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 收藏相关
|
||||
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT * FROM favorites WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.first<any>();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
title: result.title,
|
||||
source_name: result.source_name,
|
||||
cover: result.cover,
|
||||
year: result.year,
|
||||
total_episodes: result.total_episodes,
|
||||
save_time: result.save_time,
|
||||
search_title: result.search_title,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to get favorite:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setFavorite(
|
||||
userName: string,
|
||||
key: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare(
|
||||
`
|
||||
INSERT OR REPLACE INTO favorites
|
||||
(username, key, title, source_name, cover, year, total_episodes, save_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
favorite.title,
|
||||
favorite.source_name,
|
||||
favorite.cover,
|
||||
favorite.year,
|
||||
favorite.total_episodes,
|
||||
favorite.save_time
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to set favorite:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'SELECT * FROM favorites WHERE username = ? ORDER BY save_time DESC'
|
||||
)
|
||||
.bind(userName)
|
||||
.all<any>();
|
||||
|
||||
const favorites: Record<string, Favorite> = {};
|
||||
|
||||
result.results.forEach((row: any) => {
|
||||
favorites[row.key] = {
|
||||
title: row.title,
|
||||
source_name: row.source_name,
|
||||
cover: row.cover,
|
||||
year: row.year,
|
||||
total_episodes: row.total_episodes,
|
||||
save_time: row.save_time,
|
||||
search_title: row.search_title,
|
||||
};
|
||||
});
|
||||
|
||||
return favorites;
|
||||
} catch (err) {
|
||||
console.error('Failed to get all favorites:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare('DELETE FROM favorites WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete favorite:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 用户相关
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare('INSERT INTO users (username, password) VALUES (?, ?)')
|
||||
.bind(userName, password)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to register user:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT password FROM users WHERE username = ?')
|
||||
.bind(userName)
|
||||
.first<{ password: string }>();
|
||||
|
||||
return result?.password === password;
|
||||
} catch (err) {
|
||||
console.error('Failed to verify user:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT 1 FROM users WHERE username = ?')
|
||||
.bind(userName)
|
||||
.first();
|
||||
|
||||
return result !== null;
|
||||
} catch (err) {
|
||||
console.error('Failed to check user existence:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare('UPDATE users SET password = ? WHERE username = ?')
|
||||
.bind(newPassword, userName)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to change password:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUser(userName: string): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const statements = [
|
||||
db.prepare('DELETE FROM users WHERE username = ?').bind(userName),
|
||||
db
|
||||
.prepare('DELETE FROM play_records WHERE username = ?')
|
||||
.bind(userName),
|
||||
db.prepare('DELETE FROM favorites WHERE username = ?').bind(userName),
|
||||
db
|
||||
.prepare('DELETE FROM search_history WHERE username = ?')
|
||||
.bind(userName),
|
||||
];
|
||||
|
||||
await db.batch(statements);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete user:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索历史相关
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'SELECT keyword FROM search_history WHERE username = ? ORDER BY created_at DESC LIMIT ?'
|
||||
)
|
||||
.bind(userName, SEARCH_HISTORY_LIMIT)
|
||||
.all<{ keyword: string }>();
|
||||
|
||||
return result.results.map((row) => row.keyword);
|
||||
} catch (err) {
|
||||
console.error('Failed to get search history:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
// 先删除可能存在的重复记录
|
||||
await db
|
||||
.prepare(
|
||||
'DELETE FROM search_history WHERE username = ? AND keyword = ?'
|
||||
)
|
||||
.bind(userName, keyword)
|
||||
.run();
|
||||
|
||||
// 添加新记录
|
||||
await db
|
||||
.prepare('INSERT INTO search_history (username, keyword) VALUES (?, ?)')
|
||||
.bind(userName, keyword)
|
||||
.run();
|
||||
|
||||
// 保持历史记录条数限制
|
||||
await db
|
||||
.prepare(
|
||||
`
|
||||
DELETE FROM search_history
|
||||
WHERE username = ? AND id NOT IN (
|
||||
SELECT id FROM search_history
|
||||
WHERE username = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
)
|
||||
.bind(userName, userName, SEARCH_HISTORY_LIMIT)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to add search history:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
if (keyword) {
|
||||
await db
|
||||
.prepare(
|
||||
'DELETE FROM search_history WHERE username = ? AND keyword = ?'
|
||||
)
|
||||
.bind(userName, keyword)
|
||||
.run();
|
||||
} else {
|
||||
await db
|
||||
.prepare('DELETE FROM search_history WHERE username = ?')
|
||||
.bind(userName)
|
||||
.run();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete search history:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 用户列表
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT username FROM users ORDER BY created_at ASC')
|
||||
.all<{ username: string }>();
|
||||
|
||||
return result.results.map((row) => row.username);
|
||||
} catch (err) {
|
||||
console.error('Failed to get all users:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员配置相关
|
||||
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT config FROM admin_config WHERE id = 1')
|
||||
.first<{ config: string }>();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return JSON.parse(result.config) as AdminConfig;
|
||||
} catch (err) {
|
||||
console.error('Failed to get admin config:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT OR REPLACE INTO admin_config (id, config) VALUES (1, ?)'
|
||||
)
|
||||
.bind(JSON.stringify(config))
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to set admin config:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1244 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 仅在浏览器端使用的数据库工具,目前基于 localStorage 实现。
|
||||
* 之所以单独拆分文件,是为了避免在客户端 bundle 中引入 `fs`, `path` 等 Node.js 内置模块,
|
||||
* 从而解决诸如 "Module not found: Can't resolve 'fs'" 的问题。
|
||||
*
|
||||
* 功能:
|
||||
* 1. 获取全部播放记录(getAllPlayRecords)。
|
||||
* 2. 保存播放记录(savePlayRecord)。
|
||||
* 3. 数据库存储模式下的混合缓存策略,提升用户体验。
|
||||
*
|
||||
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
||||
*/
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from './auth';
|
||||
|
||||
// ---- 类型 ----
|
||||
export interface PlayRecord {
|
||||
title: string;
|
||||
source_name: string;
|
||||
year: string;
|
||||
cover: string;
|
||||
index: number; // 第几集
|
||||
total_episodes: number; // 总集数
|
||||
play_time: number; // 播放进度(秒)
|
||||
total_time: number; // 总进度(秒)
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
search_title?: string; // 搜索时使用的标题
|
||||
}
|
||||
|
||||
// ---- 收藏类型 ----
|
||||
export interface Favorite {
|
||||
title: string;
|
||||
source_name: string;
|
||||
year: string;
|
||||
cover: string;
|
||||
total_episodes: number;
|
||||
save_time: number;
|
||||
search_title?: string;
|
||||
}
|
||||
|
||||
// ---- 缓存数据结构 ----
|
||||
interface CacheData<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface UserCacheStore {
|
||||
playRecords?: CacheData<Record<string, PlayRecord>>;
|
||||
favorites?: CacheData<Record<string, Favorite>>;
|
||||
searchHistory?: CacheData<string[]>;
|
||||
}
|
||||
|
||||
// ---- 常量 ----
|
||||
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||
const FAVORITES_KEY = 'moontv_favorites';
|
||||
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
||||
|
||||
// 缓存相关常量
|
||||
const CACHE_PREFIX = 'moontv_cache_';
|
||||
const CACHE_VERSION = '1.0.0';
|
||||
const CACHE_EXPIRE_TIME = 60 * 60 * 1000; // 一小时缓存过期
|
||||
|
||||
// ---- 环境变量 ----
|
||||
const STORAGE_TYPE = (() => {
|
||||
const raw =
|
||||
(typeof window !== 'undefined' &&
|
||||
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
|
||||
(process.env.STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'd1'
|
||||
| 'upstash'
|
||||
| undefined) ||
|
||||
'localstorage';
|
||||
return raw;
|
||||
})();
|
||||
|
||||
// ---------------- 搜索历史相关常量 ----------------
|
||||
// 搜索历史最大保存条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
|
||||
// ---- 缓存管理器 ----
|
||||
class HybridCacheManager {
|
||||
private static instance: HybridCacheManager;
|
||||
|
||||
static getInstance(): HybridCacheManager {
|
||||
if (!HybridCacheManager.instance) {
|
||||
HybridCacheManager.instance = new HybridCacheManager();
|
||||
}
|
||||
return HybridCacheManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户名
|
||||
*/
|
||||
private getCurrentUsername(): string | null {
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
return authInfo?.username || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成用户专属的缓存key
|
||||
*/
|
||||
private getUserCacheKey(username: string): string {
|
||||
return `${CACHE_PREFIX}${username}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户缓存数据
|
||||
*/
|
||||
private getUserCache(username: string): UserCacheStore {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
try {
|
||||
const cacheKey = this.getUserCacheKey(username);
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
return cached ? JSON.parse(cached) : {};
|
||||
} catch (error) {
|
||||
console.warn('获取用户缓存失败:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户缓存数据
|
||||
*/
|
||||
private saveUserCache(username: string, cache: UserCacheStore): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const cacheKey = this.getUserCacheKey(username);
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cache));
|
||||
} catch (error) {
|
||||
console.warn('保存用户缓存失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否有效
|
||||
*/
|
||||
private isCacheValid<T>(cache: CacheData<T>): boolean {
|
||||
const now = Date.now();
|
||||
return (
|
||||
cache.version === CACHE_VERSION &&
|
||||
now - cache.timestamp < CACHE_EXPIRE_TIME
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建缓存数据
|
||||
*/
|
||||
private createCacheData<T>(data: T): CacheData<T> {
|
||||
return {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
version: CACHE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的播放记录
|
||||
*/
|
||||
getCachedPlayRecords(): Record<string, PlayRecord> | null {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return null;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
const cached = userCache.playRecords;
|
||||
|
||||
if (cached && this.isCacheValid(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存播放记录
|
||||
*/
|
||||
cachePlayRecords(data: Record<string, PlayRecord>): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.playRecords = this.createCacheData(data);
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的收藏
|
||||
*/
|
||||
getCachedFavorites(): Record<string, Favorite> | null {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return null;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
const cached = userCache.favorites;
|
||||
|
||||
if (cached && this.isCacheValid(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存收藏
|
||||
*/
|
||||
cacheFavorites(data: Record<string, Favorite>): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.favorites = this.createCacheData(data);
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的搜索历史
|
||||
*/
|
||||
getCachedSearchHistory(): string[] | null {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return null;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
const cached = userCache.searchHistory;
|
||||
|
||||
if (cached && this.isCacheValid(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存搜索历史
|
||||
*/
|
||||
cacheSearchHistory(data: string[]): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.searchHistory = this.createCacheData(data);
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定用户的所有缓存
|
||||
*/
|
||||
clearUserCache(username?: string): void {
|
||||
const targetUsername = username || this.getCurrentUsername();
|
||||
if (!targetUsername) return;
|
||||
|
||||
try {
|
||||
const cacheKey = this.getUserCacheKey(targetUsername);
|
||||
localStorage.removeItem(cacheKey);
|
||||
} catch (error) {
|
||||
console.warn('清除用户缓存失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有过期缓存
|
||||
*/
|
||||
clearExpiredCaches(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(CACHE_PREFIX)) {
|
||||
try {
|
||||
const cache = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
// 检查是否有任何缓存数据过期
|
||||
let hasValidData = false;
|
||||
for (const [, cacheData] of Object.entries(cache)) {
|
||||
if (cacheData && this.isCacheValid(cacheData as CacheData<any>)) {
|
||||
hasValidData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasValidData) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
} catch {
|
||||
// 解析失败的缓存也删除
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
} catch (error) {
|
||||
console.warn('清除过期缓存失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取缓存管理器实例
|
||||
const cacheManager = HybridCacheManager.getInstance();
|
||||
|
||||
// ---- 错误处理辅助函数 ----
|
||||
/**
|
||||
* 数据库操作失败时的通用错误处理
|
||||
* 立即从数据库刷新对应类型的缓存以保持数据一致性
|
||||
*/
|
||||
async function handleDatabaseOperationFailure(
|
||||
dataType: 'playRecords' | 'favorites' | 'searchHistory',
|
||||
error: any
|
||||
): Promise<void> {
|
||||
console.error(`数据库操作失败 (${dataType}):`, error);
|
||||
|
||||
try {
|
||||
let freshData: any;
|
||||
let eventName: string;
|
||||
|
||||
switch (dataType) {
|
||||
case 'playRecords':
|
||||
freshData = await fetchFromApi<Record<string, PlayRecord>>(
|
||||
`/api/playrecords`
|
||||
);
|
||||
cacheManager.cachePlayRecords(freshData);
|
||||
eventName = 'playRecordsUpdated';
|
||||
break;
|
||||
case 'favorites':
|
||||
freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||
`/api/favorites`
|
||||
);
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
eventName = 'favoritesUpdated';
|
||||
break;
|
||||
case 'searchHistory':
|
||||
freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
||||
cacheManager.cacheSearchHistory(freshData);
|
||||
eventName = 'searchHistoryUpdated';
|
||||
break;
|
||||
}
|
||||
|
||||
// 触发更新事件通知组件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(eventName, {
|
||||
detail: freshData,
|
||||
})
|
||||
);
|
||||
} catch (refreshErr) {
|
||||
console.error(`刷新${dataType}缓存失败:`, refreshErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时清理过期缓存
|
||||
if (typeof window !== 'undefined') {
|
||||
setTimeout(() => cacheManager.clearExpiredCaches(), 1000);
|
||||
}
|
||||
|
||||
// ---- 工具函数 ----
|
||||
async function fetchFromApi<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new Error(`请求 ${path} 失败: ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成存储key
|
||||
*/
|
||||
export function generateStorageKey(source: string, id: string): string {
|
||||
return `${source}+${id}`;
|
||||
}
|
||||
|
||||
// ---- API ----
|
||||
/**
|
||||
* 读取全部播放记录。
|
||||
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
|
||||
*/
|
||||
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
||||
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 优先从缓存获取数据
|
||||
const cachedData = cacheManager.getCachedPlayRecords();
|
||||
|
||||
if (cachedData) {
|
||||
// 返回缓存数据,同时后台异步更新
|
||||
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`)
|
||||
.then((freshData) => {
|
||||
// 只有数据真正不同时才更新缓存
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cachePlayRecords(freshData);
|
||||
// 触发数据更新事件,供组件监听
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: freshData,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步播放记录失败:', err);
|
||||
});
|
||||
|
||||
return cachedData;
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, PlayRecord>>(
|
||||
`/api/playrecords`
|
||||
);
|
||||
cacheManager.cachePlayRecords(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取播放记录失败:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localstorage 模式
|
||||
try {
|
||||
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, PlayRecord>;
|
||||
} catch (err) {
|
||||
console.error('读取播放记录失败:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存播放记录。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
||||
*/
|
||||
export async function savePlayRecord(
|
||||
source: string,
|
||||
id: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||
cachedRecords[key] = record;
|
||||
cacheManager.cachePlayRecords(cachedRecords);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: cachedRecords,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
const res = await fetch('/api/playrecords', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, record }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`保存播放记录失败: ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('playRecords', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localstorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端保存播放记录到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
allRecords[key] = record;
|
||||
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: allRecords,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('保存播放记录失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除播放记录。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function deletePlayRecord(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||
delete cachedRecords[key];
|
||||
cacheManager.cachePlayRecords(cachedRecords);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: cachedRecords,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/playrecords?key=${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('playRecords', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localstorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端删除播放记录到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
delete allRecords[key];
|
||||
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: allRecords,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('删除播放记录失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 搜索历史相关 API ---------------- */
|
||||
|
||||
/**
|
||||
* 获取搜索历史。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
*/
|
||||
export async function getSearchHistory(): Promise<string[]> {
|
||||
// 服务器端渲染阶段直接返回空
|
||||
if (typeof window === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 优先从缓存获取数据
|
||||
const cachedData = cacheManager.getCachedSearchHistory();
|
||||
|
||||
if (cachedData) {
|
||||
// 返回缓存数据,同时后台异步更新
|
||||
fetchFromApi<string[]>(`/api/searchhistory`)
|
||||
.then((freshData) => {
|
||||
// 只有数据真正不同时才更新缓存
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheSearchHistory(freshData);
|
||||
// 触发数据更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('searchHistoryUpdated', {
|
||||
detail: freshData,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步搜索历史失败:', err);
|
||||
});
|
||||
|
||||
return cachedData;
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
||||
cacheManager.cacheSearchHistory(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取搜索历史失败:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
try {
|
||||
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw) as string[];
|
||||
// 仅返回字符串数组
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch (err) {
|
||||
console.error('读取搜索历史失败:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将关键字添加到搜索历史。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function addSearchHistory(keyword: string): Promise<void> {
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
||||
const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)];
|
||||
// 限制长度
|
||||
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
|
||||
newHistory.length = SEARCH_HISTORY_LIMIT;
|
||||
}
|
||||
cacheManager.cacheSearchHistory(newHistory);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('searchHistoryUpdated', {
|
||||
detail: newHistory,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
const res = await fetch('/api/searchhistory', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ keyword: trimmed }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`保存搜索历史失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('searchHistory', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const history = await getSearchHistory();
|
||||
const newHistory = [trimmed, ...history.filter((k) => k !== trimmed)];
|
||||
// 限制长度
|
||||
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
|
||||
newHistory.length = SEARCH_HISTORY_LIMIT;
|
||||
}
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('searchHistoryUpdated', {
|
||||
detail: newHistory,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('保存搜索历史失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空搜索历史。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function clearSearchHistory(): Promise<void> {
|
||||
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
cacheManager.cacheSearchHistory([]);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('searchHistoryUpdated', {
|
||||
detail: [],
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
const res = await fetch(`/api/searchhistory`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(`清空搜索历史失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('searchHistory', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('searchHistoryUpdated', {
|
||||
detail: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条搜索历史。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function deleteSearchHistory(keyword: string): Promise<void> {
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
||||
const newHistory = cachedHistory.filter((k) => k !== trimmed);
|
||||
cacheManager.cacheSearchHistory(newHistory);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('searchHistoryUpdated', {
|
||||
detail: newHistory,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`删除搜索历史失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('searchHistory', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const history = await getSearchHistory();
|
||||
const newHistory = history.filter((k) => k !== trimmed);
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('searchHistoryUpdated', {
|
||||
detail: newHistory,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('删除搜索历史失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 收藏相关 API ----------------
|
||||
|
||||
/**
|
||||
* 获取全部收藏。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
*/
|
||||
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
||||
// 服务器端渲染阶段直接返回空
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 优先从缓存获取数据
|
||||
const cachedData = cacheManager.getCachedFavorites();
|
||||
|
||||
if (cachedData) {
|
||||
// 返回缓存数据,同时后台异步更新
|
||||
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
|
||||
.then((freshData) => {
|
||||
// 只有数据真正不同时才更新缓存
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
// 触发数据更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: freshData,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步收藏失败:', err);
|
||||
});
|
||||
|
||||
return cachedData;
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||
`/api/favorites`
|
||||
);
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取收藏失败:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
try {
|
||||
const raw = localStorage.getItem(FAVORITES_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, Favorite>;
|
||||
} catch (err) {
|
||||
console.error('读取收藏失败:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存收藏。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function saveFavorite(
|
||||
source: string,
|
||||
id: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||
cachedFavorites[key] = favorite;
|
||||
cacheManager.cacheFavorites(cachedFavorites);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: cachedFavorites,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
const res = await fetch('/api/favorites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, favorite }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`保存收藏失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('favorites', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端保存收藏到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allFavorites = await getAllFavorites();
|
||||
allFavorites[key] = favorite;
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: allFavorites,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('保存收藏失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除收藏。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function deleteFavorite(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||
delete cachedFavorites[key];
|
||||
cacheManager.cacheFavorites(cachedFavorites);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: cachedFavorites,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('favorites', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端删除收藏到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allFavorites = await getAllFavorites();
|
||||
delete allFavorites[key];
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: allFavorites,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('删除收藏失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否已收藏。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
*/
|
||||
export async function isFavorited(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<boolean> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cachedFavorites = cacheManager.getCachedFavorites();
|
||||
|
||||
if (cachedFavorites) {
|
||||
// 返回缓存数据,同时后台异步更新
|
||||
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
|
||||
.then((freshData) => {
|
||||
// 只有数据真正不同时才更新缓存
|
||||
if (JSON.stringify(cachedFavorites) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
// 触发数据更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: freshData,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步收藏失败:', err);
|
||||
});
|
||||
|
||||
return !!cachedFavorites[key];
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||
`/api/favorites`
|
||||
);
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
return !!freshData[key];
|
||||
} catch (err) {
|
||||
console.error('检查收藏状态失败:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
const allFavorites = await getAllFavorites();
|
||||
return !!allFavorites[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空全部播放记录
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function clearAllPlayRecords(): Promise<void> {
|
||||
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
cacheManager.cachePlayRecords({});
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: {},
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
const res = await fetch(`/api/playrecords`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`清空播放记录失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('playRecords', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(PLAY_RECORDS_KEY);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空全部收藏
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function clearAllFavorites(): Promise<void> {
|
||||
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
cacheManager.cacheFavorites({});
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: {},
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
const res = await fetch(`/api/favorites`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`清空收藏失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('favorites', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 混合缓存辅助函数 ----------------
|
||||
|
||||
/**
|
||||
* 清除当前用户的所有缓存数据
|
||||
* 用于用户登出时清理缓存
|
||||
*/
|
||||
export function clearUserCache(): void {
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
cacheManager.clearUserCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动刷新所有缓存数据
|
||||
* 强制从服务器重新获取数据并更新缓存
|
||||
*/
|
||||
export async function refreshAllCache(): Promise<void> {
|
||||
if (STORAGE_TYPE === 'localstorage') return;
|
||||
|
||||
try {
|
||||
// 并行刷新所有数据
|
||||
const [playRecords, favorites, searchHistory] = await Promise.allSettled([
|
||||
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
|
||||
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
|
||||
fetchFromApi<string[]>(`/api/searchhistory`),
|
||||
]);
|
||||
|
||||
if (playRecords.status === 'fulfilled') {
|
||||
cacheManager.cachePlayRecords(playRecords.value);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: playRecords.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (favorites.status === 'fulfilled') {
|
||||
cacheManager.cacheFavorites(favorites.value);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: favorites.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (searchHistory.status === 'fulfilled') {
|
||||
cacheManager.cacheSearchHistory(searchHistory.value);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('searchHistoryUpdated', {
|
||||
detail: searchHistory.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('刷新缓存失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存状态信息
|
||||
* 用于调试和监控缓存健康状态
|
||||
*/
|
||||
export function getCacheStatus(): {
|
||||
hasPlayRecords: boolean;
|
||||
hasFavorites: boolean;
|
||||
hasSearchHistory: boolean;
|
||||
username: string | null;
|
||||
} {
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
return {
|
||||
hasPlayRecords: false,
|
||||
hasFavorites: false,
|
||||
hasSearchHistory: false,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
return {
|
||||
hasPlayRecords: !!cacheManager.getCachedPlayRecords(),
|
||||
hasFavorites: !!cacheManager.getCachedFavorites(),
|
||||
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
|
||||
username: authInfo?.username || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- React Hook 辅助类型 ----------------
|
||||
|
||||
export type CacheUpdateEvent =
|
||||
| 'playRecordsUpdated'
|
||||
| 'favoritesUpdated'
|
||||
| 'searchHistoryUpdated';
|
||||
|
||||
/**
|
||||
* 用于 React 组件监听数据更新的事件监听器
|
||||
* 使用方法:
|
||||
*
|
||||
* useEffect(() => {
|
||||
* const unsubscribe = subscribeToDataUpdates('playRecordsUpdated', (data) => {
|
||||
* setPlayRecords(data);
|
||||
* });
|
||||
* return unsubscribe;
|
||||
* }, []);
|
||||
*/
|
||||
export function subscribeToDataUpdates<T>(
|
||||
eventType: CacheUpdateEvent,
|
||||
callback: (data: T) => void
|
||||
): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handleUpdate = (event: CustomEvent) => {
|
||||
callback(event.detail);
|
||||
};
|
||||
|
||||
window.addEventListener(eventType, handleUpdate as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(eventType, handleUpdate as EventListener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载所有用户数据到缓存
|
||||
* 适合在应用启动时调用,提升后续访问速度
|
||||
*/
|
||||
export async function preloadUserData(): Promise<void> {
|
||||
if (STORAGE_TYPE === 'localstorage') return;
|
||||
|
||||
// 检查是否已有有效缓存,避免重复请求
|
||||
const status = getCacheStatus();
|
||||
if (status.hasPlayRecords && status.hasFavorites && status.hasSearchHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 后台静默预加载,不阻塞界面
|
||||
refreshAllCache().catch((err) => {
|
||||
console.warn('预加载用户数据失败:', err);
|
||||
});
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { D1Storage } from './d1.db';
|
||||
import { RedisStorage } from './redis.db';
|
||||
import { Favorite, IStorage, PlayRecord } from './types';
|
||||
import { UpstashRedisStorage } from './upstash.db';
|
||||
|
||||
// storage type 常量: 'localstorage' | 'redis' | 'd1' | 'upstash',默认 'localstorage'
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'd1'
|
||||
| 'upstash'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// 创建存储实例
|
||||
function createStorage(): IStorage {
|
||||
switch (STORAGE_TYPE) {
|
||||
case 'redis':
|
||||
return new RedisStorage();
|
||||
case 'upstash':
|
||||
return new UpstashRedisStorage();
|
||||
case 'd1':
|
||||
return new D1Storage();
|
||||
case 'localstorage':
|
||||
default:
|
||||
// 默认返回内存实现,保证本地开发可用
|
||||
return null as unknown as IStorage;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例存储实例
|
||||
let storageInstance: IStorage | null = null;
|
||||
|
||||
export function getStorage(): IStorage {
|
||||
if (!storageInstance) {
|
||||
storageInstance = createStorage();
|
||||
}
|
||||
return storageInstance;
|
||||
}
|
||||
|
||||
// 工具函数:生成存储key
|
||||
export function generateStorageKey(source: string, id: string): string {
|
||||
return `${source}+${id}`;
|
||||
}
|
||||
|
||||
// 导出便捷方法
|
||||
export class DbManager {
|
||||
private storage: IStorage;
|
||||
|
||||
constructor() {
|
||||
this.storage = getStorage();
|
||||
}
|
||||
|
||||
// 播放记录相关方法
|
||||
async getPlayRecord(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<PlayRecord | null> {
|
||||
const key = generateStorageKey(source, id);
|
||||
return this.storage.getPlayRecord(userName, key);
|
||||
}
|
||||
|
||||
async savePlayRecord(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.setPlayRecord(userName, key, record);
|
||||
}
|
||||
|
||||
async getAllPlayRecords(userName: string): Promise<{
|
||||
[key: string]: PlayRecord;
|
||||
}> {
|
||||
return this.storage.getAllPlayRecords(userName);
|
||||
}
|
||||
|
||||
async deletePlayRecord(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.deletePlayRecord(userName, key);
|
||||
}
|
||||
|
||||
// 收藏相关方法
|
||||
async getFavorite(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<Favorite | null> {
|
||||
const key = generateStorageKey(source, id);
|
||||
return this.storage.getFavorite(userName, key);
|
||||
}
|
||||
|
||||
async saveFavorite(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.setFavorite(userName, key, favorite);
|
||||
}
|
||||
|
||||
async getAllFavorites(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: Favorite }> {
|
||||
return this.storage.getAllFavorites(userName);
|
||||
}
|
||||
|
||||
async deleteFavorite(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.deleteFavorite(userName, key);
|
||||
}
|
||||
|
||||
async isFavorited(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<boolean> {
|
||||
const favorite = await this.getFavorite(userName, source, id);
|
||||
return favorite !== null;
|
||||
}
|
||||
|
||||
// ---------- 用户相关 ----------
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
await this.storage.registerUser(userName, password);
|
||||
}
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
return this.storage.verifyUser(userName, password);
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
return this.storage.checkUserExist(userName);
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
return this.storage.getSearchHistory(userName);
|
||||
}
|
||||
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
await this.storage.addSearchHistory(userName, keyword);
|
||||
}
|
||||
|
||||
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||
await this.storage.deleteSearchHistory(userName, keyword);
|
||||
}
|
||||
|
||||
// 获取全部用户名
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
if (typeof (this.storage as any).getAllUsers === 'function') {
|
||||
return (this.storage as any).getAllUsers();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||
if (typeof (this.storage as any).getAdminConfig === 'function') {
|
||||
return (this.storage as any).getAdminConfig();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async saveAdminConfig(config: AdminConfig): Promise<void> {
|
||||
if (typeof (this.storage as any).setAdminConfig === 'function') {
|
||||
await (this.storage as any).setAdminConfig(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export const db = new DbManager();
|
||||
@@ -0,0 +1,148 @@
|
||||
import { DoubanItem, DoubanResult } from './types';
|
||||
import { getDoubanProxyUrl } from './utils';
|
||||
|
||||
interface DoubanCategoriesParams {
|
||||
kind: 'tv' | 'movie';
|
||||
category: string;
|
||||
type: string;
|
||||
pageLimit?: number;
|
||||
pageStart?: number;
|
||||
}
|
||||
|
||||
interface DoubanCategoryApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
card_subtitle: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时的 fetch 请求
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
// 检查是否使用代理
|
||||
const proxyUrl = getDoubanProxyUrl();
|
||||
const finalUrl = proxyUrl ? `${proxyUrl}${encodeURIComponent(url)}` : url;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(finalUrl, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该使用客户端获取豆瓣数据
|
||||
*/
|
||||
export function shouldUseDoubanClient(): boolean {
|
||||
return getDoubanProxyUrl() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器端豆瓣分类数据获取函数
|
||||
*/
|
||||
export async function fetchDoubanCategories(
|
||||
params: DoubanCategoriesParams
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
|
||||
// 验证参数
|
||||
if (!['tv', 'movie'].includes(kind)) {
|
||||
throw new Error('kind 参数必须是 tv 或 movie');
|
||||
}
|
||||
|
||||
if (!category || !type) {
|
||||
throw new Error('category 和 type 参数不能为空');
|
||||
}
|
||||
|
||||
if (pageLimit < 1 || pageLimit > 100) {
|
||||
throw new Error('pageLimit 必须在 1-100 之间');
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
throw new Error('pageStart 不能小于 0');
|
||||
}
|
||||
|
||||
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(target);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const doubanData: DoubanCategoryApiResponse = await response.json();
|
||||
|
||||
// 转换数据格式
|
||||
const list: DoubanItem[] = doubanData.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.pic?.normal || item.pic?.large || '',
|
||||
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
|
||||
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的豆瓣分类数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
|
||||
*/
|
||||
export async function getDoubanCategories(
|
||||
params: DoubanCategoriesParams
|
||||
): Promise<DoubanResult> {
|
||||
if (shouldUseDoubanClient()) {
|
||||
// 使用客户端代理获取(当设置了代理 URL 时)
|
||||
return fetchDoubanCategories(params);
|
||||
} else {
|
||||
// 使用服务端 API(当没有设置代理 URL 时)
|
||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
const response = await fetch(
|
||||
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取豆瓣分类数据失败');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { API_CONFIG, ApiSite, getConfig } from '@/lib/config';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { cleanHtmlTags } from '@/lib/utils';
|
||||
|
||||
interface ApiSearchItem {
|
||||
vod_id: string;
|
||||
vod_name: string;
|
||||
vod_pic: string;
|
||||
vod_remarks?: string;
|
||||
vod_play_url?: string;
|
||||
vod_class?: string;
|
||||
vod_year?: string;
|
||||
vod_content?: string;
|
||||
vod_douban_id?: number;
|
||||
type_name?: string;
|
||||
}
|
||||
|
||||
export async function searchFromApi(
|
||||
apiSite: ApiSite,
|
||||
query: string
|
||||
): Promise<SearchResult[]> {
|
||||
try {
|
||||
const apiBaseUrl = apiSite.api;
|
||||
const apiUrl =
|
||||
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
|
||||
const apiName = apiSite.name;
|
||||
|
||||
// 添加超时处理
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
!Array.isArray(data.list) ||
|
||||
data.list.length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
// 处理第一页结果
|
||||
const results = data.list.map((item: ApiSearchItem) => {
|
||||
let episodes: string[] = [];
|
||||
|
||||
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||
if (item.vod_play_url) {
|
||||
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
// 先用 $$$ 分割
|
||||
const vod_play_url_array = item.vod_play_url.split('$$$');
|
||||
// 对每个分片做匹配,取匹配到最多的作为结果
|
||||
vod_play_url_array.forEach((url: string) => {
|
||||
const matches = url.match(m3u8Regex) || [];
|
||||
if (matches.length > episodes.length) {
|
||||
episodes = matches;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
episodes = Array.from(new Set(episodes)).map((link: string) => {
|
||||
link = link.substring(1); // 去掉开头的 $
|
||||
const parenIndex = link.indexOf('(');
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.vod_id.toString(),
|
||||
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||
poster: item.vod_pic,
|
||||
episodes,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
class: item.vod_class,
|
||||
year: item.vod_year
|
||||
? item.vod_year.match(/\d{4}/)?.[0] || ''
|
||||
: 'unknown',
|
||||
desc: cleanHtmlTags(item.vod_content || ''),
|
||||
type_name: item.type_name,
|
||||
douban_id: item.vod_douban_id,
|
||||
};
|
||||
});
|
||||
|
||||
const config = await getConfig();
|
||||
const MAX_SEARCH_PAGES: number = config.SiteConfig.SearchDownstreamMaxPage;
|
||||
|
||||
// 获取总页数
|
||||
const pageCount = data.pagecount || 1;
|
||||
// 确定需要获取的额外页数
|
||||
const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 1);
|
||||
|
||||
// 如果有额外页数,获取更多页的结果
|
||||
if (pagesToFetch > 0) {
|
||||
const additionalPagePromises = [];
|
||||
|
||||
for (let page = 2; page <= pagesToFetch + 1; page++) {
|
||||
const pageUrl =
|
||||
apiBaseUrl +
|
||||
API_CONFIG.search.pagePath
|
||||
.replace('{query}', encodeURIComponent(query))
|
||||
.replace('{page}', page.toString());
|
||||
|
||||
const pagePromise = (async () => {
|
||||
try {
|
||||
const pageController = new AbortController();
|
||||
const pageTimeoutId = setTimeout(
|
||||
() => pageController.abort(),
|
||||
8000
|
||||
);
|
||||
|
||||
const pageResponse = await fetch(pageUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: pageController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(pageTimeoutId);
|
||||
|
||||
if (!pageResponse.ok) return [];
|
||||
|
||||
const pageData = await pageResponse.json();
|
||||
|
||||
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
|
||||
return [];
|
||||
|
||||
return pageData.list.map((item: ApiSearchItem) => {
|
||||
let episodes: string[] = [];
|
||||
|
||||
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||
if (item.vod_play_url) {
|
||||
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
episodes = item.vod_play_url.match(m3u8Regex) || [];
|
||||
}
|
||||
|
||||
episodes = Array.from(new Set(episodes)).map((link: string) => {
|
||||
link = link.substring(1); // 去掉开头的 $
|
||||
const parenIndex = link.indexOf('(');
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.vod_id.toString(),
|
||||
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||
poster: item.vod_pic,
|
||||
episodes,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
class: item.vod_class,
|
||||
year: item.vod_year
|
||||
? item.vod_year.match(/\d{4}/)?.[0] || ''
|
||||
: 'unknown',
|
||||
desc: cleanHtmlTags(item.vod_content || ''),
|
||||
type_name: item.type_name,
|
||||
douban_id: item.vod_douban_id,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
additionalPagePromises.push(pagePromise);
|
||||
}
|
||||
|
||||
// 等待所有额外页的结果
|
||||
const additionalResults = await Promise.all(additionalPagePromises);
|
||||
|
||||
// 合并所有页的结果
|
||||
additionalResults.forEach((pageResults) => {
|
||||
if (pageResults.length > 0) {
|
||||
results.push(...pageResults);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配 m3u8 链接的正则
|
||||
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
|
||||
export async function getDetailFromApi(
|
||||
apiSite: ApiSite,
|
||||
id: string
|
||||
): Promise<SearchResult> {
|
||||
if (apiSite.detail) {
|
||||
return handleSpecialSourceDetail(id, apiSite);
|
||||
}
|
||||
|
||||
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(detailUrl, {
|
||||
headers: API_CONFIG.detail.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`详情请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
!Array.isArray(data.list) ||
|
||||
data.list.length === 0
|
||||
) {
|
||||
throw new Error('获取到的详情内容无效');
|
||||
}
|
||||
|
||||
const videoDetail = data.list[0];
|
||||
let episodes: string[] = [];
|
||||
|
||||
// 处理播放源拆分
|
||||
if (videoDetail.vod_play_url) {
|
||||
const playSources = videoDetail.vod_play_url.split('$$$');
|
||||
if (playSources.length > 0) {
|
||||
const mainSource = playSources[0];
|
||||
const episodeList = mainSource.split('#');
|
||||
episodes = episodeList
|
||||
.map((ep: string) => {
|
||||
const parts = ep.split('$');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
})
|
||||
.filter(
|
||||
(url: string) =>
|
||||
url && (url.startsWith('http://') || url.startsWith('https://'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果播放源为空,则尝试从内容中解析 m3u8
|
||||
if (episodes.length === 0 && videoDetail.vod_content) {
|
||||
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
|
||||
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
|
||||
}
|
||||
|
||||
return {
|
||||
id: id.toString(),
|
||||
title: videoDetail.vod_name,
|
||||
poster: videoDetail.vod_pic,
|
||||
episodes,
|
||||
source: apiSite.key,
|
||||
source_name: apiSite.name,
|
||||
class: videoDetail.vod_class,
|
||||
year: videoDetail.vod_year
|
||||
? videoDetail.vod_year.match(/\d{4}/)?.[0] || ''
|
||||
: 'unknown',
|
||||
desc: cleanHtmlTags(videoDetail.vod_content),
|
||||
type_name: videoDetail.type_name,
|
||||
douban_id: videoDetail.vod_douban_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSpecialSourceDetail(
|
||||
id: string,
|
||||
apiSite: ApiSite
|
||||
): Promise<SearchResult> {
|
||||
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(detailUrl, {
|
||||
headers: API_CONFIG.detail.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`详情页请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
let matches: string[] = [];
|
||||
|
||||
if (apiSite.key === 'ffzy') {
|
||||
const ffzyPattern =
|
||||
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
|
||||
matches = html.match(ffzyPattern) || [];
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
matches = html.match(generalPattern) || [];
|
||||
}
|
||||
|
||||
// 去重并清理链接前缀
|
||||
matches = Array.from(new Set(matches)).map((link: string) => {
|
||||
link = link.substring(1); // 去掉开头的 $
|
||||
const parenIndex = link.indexOf('(');
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
// 提取标题
|
||||
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
|
||||
const titleText = titleMatch ? titleMatch[1].trim() : '';
|
||||
|
||||
// 提取描述
|
||||
const descMatch = html.match(
|
||||
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
|
||||
);
|
||||
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
|
||||
|
||||
// 提取封面
|
||||
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
|
||||
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
|
||||
|
||||
// 提取年份
|
||||
const yearMatch = html.match(/>(\d{4})</);
|
||||
const yearText = yearMatch ? yearMatch[1] : 'unknown';
|
||||
|
||||
return {
|
||||
id,
|
||||
title: titleText,
|
||||
poster: coverUrl,
|
||||
episodes: matches,
|
||||
source: apiSite.key,
|
||||
source_name: apiSite.name,
|
||||
class: '',
|
||||
year: yearText,
|
||||
desc: descText,
|
||||
type_name: '',
|
||||
douban_id: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getAvailableApiSites } from '@/lib/config';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
import { getDetailFromApi, searchFromApi } from './downstream';
|
||||
|
||||
interface FetchVideoDetailOptions {
|
||||
source: string;
|
||||
id: string;
|
||||
fallbackTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 source 与 id 获取视频详情。
|
||||
* 1. 若传入 fallbackTitle,则先调用 /api/search 搜索精确匹配。
|
||||
* 2. 若搜索未命中或未提供 fallbackTitle,则直接调用 /api/detail。
|
||||
*/
|
||||
export async function fetchVideoDetail({
|
||||
source,
|
||||
id,
|
||||
fallbackTitle = '',
|
||||
}: FetchVideoDetailOptions): Promise<SearchResult> {
|
||||
// 优先通过搜索接口查找精确匹配
|
||||
const apiSites = await getAvailableApiSites();
|
||||
const apiSite = apiSites.find((site) => site.key === source);
|
||||
if (!apiSite) {
|
||||
throw new Error('无效的API来源');
|
||||
}
|
||||
if (fallbackTitle) {
|
||||
try {
|
||||
const searchData = await searchFromApi(apiSite, fallbackTitle.trim());
|
||||
const exactMatch = searchData.find(
|
||||
(item: SearchResult) =>
|
||||
item.source.toString() === source.toString() &&
|
||||
item.id.toString() === id.toString()
|
||||
);
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 /api/detail 接口
|
||||
const detail = await getDetailFromApi(apiSite, id);
|
||||
if (!detail) {
|
||||
throw new Error('获取视频详情失败');
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
|
||||
// 数据类型转换辅助函数
|
||||
function ensureString(value: any): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function ensureStringArray(value: any[]): string[] {
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
|
||||
// 添加Redis操作重试包装器
|
||||
async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries = 3
|
||||
): Promise<T> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err: any) {
|
||||
const isLastAttempt = i === maxRetries - 1;
|
||||
const isConnectionError =
|
||||
err.message?.includes('Connection') ||
|
||||
err.message?.includes('ECONNREFUSED') ||
|
||||
err.message?.includes('ENOTFOUND') ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'EPIPE';
|
||||
|
||||
if (isConnectionError && !isLastAttempt) {
|
||||
console.log(
|
||||
`Redis operation failed, retrying... (${i + 1}/${maxRetries})`
|
||||
);
|
||||
console.error('Error:', err.message);
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
|
||||
|
||||
// 尝试重新连接
|
||||
try {
|
||||
const client = getRedisClient();
|
||||
if (!client.isOpen) {
|
||||
await client.connect();
|
||||
}
|
||||
} catch (reconnectErr) {
|
||||
console.error('Failed to reconnect:', reconnectErr);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
export class RedisStorage implements IStorage {
|
||||
private client: RedisClientType;
|
||||
|
||||
constructor() {
|
||||
this.client = getRedisClient();
|
||||
}
|
||||
|
||||
// ---------- 播放记录 ----------
|
||||
private prKey(user: string, key: string) {
|
||||
return `u:${user}:pr:${key}`; // u:username:pr:source+id
|
||||
}
|
||||
|
||||
async getPlayRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<PlayRecord | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.prKey(userName, key))
|
||||
);
|
||||
return val ? (JSON.parse(val) as PlayRecord) : null;
|
||||
}
|
||||
|
||||
async setPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.prKey(userName, key), JSON.stringify(record))
|
||||
);
|
||||
}
|
||||
|
||||
async getAllPlayRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, PlayRecord>> {
|
||||
const pattern = `u:${userName}:pr:*`;
|
||||
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
if (keys.length === 0) return {};
|
||||
const values = await withRetry(() => this.client.mGet(keys));
|
||||
const result: Record<string, PlayRecord> = {};
|
||||
keys.forEach((fullKey: string, idx: number) => {
|
||||
const raw = values[idx];
|
||||
if (raw) {
|
||||
const rec = JSON.parse(raw) as PlayRecord;
|
||||
// 截取 source+id 部分
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
||||
result[keyPart] = rec;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.prKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 收藏 ----------
|
||||
private favKey(user: string, key: string) {
|
||||
return `u:${user}:fav:${key}`;
|
||||
}
|
||||
|
||||
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.favKey(userName, key))
|
||||
);
|
||||
return val ? (JSON.parse(val) as Favorite) : null;
|
||||
}
|
||||
|
||||
async setFavorite(
|
||||
userName: string,
|
||||
key: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.favKey(userName, key), JSON.stringify(favorite))
|
||||
);
|
||||
}
|
||||
|
||||
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||
const pattern = `u:${userName}:fav:*`;
|
||||
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
if (keys.length === 0) return {};
|
||||
const values = await withRetry(() => this.client.mGet(keys));
|
||||
const result: Record<string, Favorite> = {};
|
||||
keys.forEach((fullKey: string, idx: number) => {
|
||||
const raw = values[idx];
|
||||
if (raw) {
|
||||
const fav = JSON.parse(raw) as Favorite;
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
||||
result[keyPart] = fav;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 用户注册 / 登录 ----------
|
||||
private userPwdKey(user: string) {
|
||||
return `u:${user}:pwd`;
|
||||
}
|
||||
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() => this.client.set(this.userPwdKey(userName), password));
|
||||
}
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
const stored = await withRetry(() =>
|
||||
this.client.get(this.userPwdKey(userName))
|
||||
);
|
||||
if (stored === null) return false;
|
||||
// 确保比较时都是字符串类型
|
||||
return ensureString(stored) === password;
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
// 使用 EXISTS 判断 key 是否存在
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userPwdKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
// 修改用户密码
|
||||
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() =>
|
||||
this.client.set(this.userPwdKey(userName), newPassword)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除用户及其所有数据
|
||||
async deleteUser(userName: string): Promise<void> {
|
||||
// 删除用户密码
|
||||
await withRetry(() => this.client.del(this.userPwdKey(userName)));
|
||||
|
||||
// 删除搜索历史
|
||||
await withRetry(() => this.client.del(this.shKey(userName)));
|
||||
|
||||
// 删除播放记录
|
||||
const playRecordPattern = `u:${userName}:pr:*`;
|
||||
const playRecordKeys = await withRetry(() =>
|
||||
this.client.keys(playRecordPattern)
|
||||
);
|
||||
if (playRecordKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(playRecordKeys));
|
||||
}
|
||||
|
||||
// 删除收藏夹
|
||||
const favoritePattern = `u:${userName}:fav:*`;
|
||||
const favoriteKeys = await withRetry(() =>
|
||||
this.client.keys(favoritePattern)
|
||||
);
|
||||
if (favoriteKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(favoriteKeys));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
private shKey(user: string) {
|
||||
return `u:${user}:sh`; // u:username:sh
|
||||
}
|
||||
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
const result = await withRetry(() =>
|
||||
this.client.lRange(this.shKey(userName), 0, -1)
|
||||
);
|
||||
// 确保返回的都是字符串类型
|
||||
return ensureStringArray(result as any[]);
|
||||
}
|
||||
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
// 先去重
|
||||
await withRetry(() => this.client.lRem(key, 0, ensureString(keyword)));
|
||||
// 插入到最前
|
||||
await withRetry(() => this.client.lPush(key, ensureString(keyword)));
|
||||
// 限制最大长度
|
||||
await withRetry(() => this.client.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||
}
|
||||
|
||||
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
if (keyword) {
|
||||
await withRetry(() => this.client.lRem(key, 0, ensureString(keyword)));
|
||||
} else {
|
||||
await withRetry(() => this.client.del(key));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||
return keys
|
||||
.map((k) => {
|
||||
const match = k.match(/^u:(.+?):pwd$/);
|
||||
return match ? ensureString(match[1]) : undefined;
|
||||
})
|
||||
.filter((u): u is string => typeof u === 'string');
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
private adminConfigKey() {
|
||||
return 'admin:config';
|
||||
}
|
||||
|
||||
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
|
||||
return val ? (JSON.parse(val) as AdminConfig) : null;
|
||||
}
|
||||
|
||||
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.adminConfigKey(), JSON.stringify(config))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Redis 客户端
|
||||
function getRedisClient(): RedisClientType {
|
||||
const globalKey = Symbol.for('__MOONTV_REDIS_CLIENT__');
|
||||
let client: RedisClientType | undefined = (global as any)[globalKey];
|
||||
|
||||
if (!client) {
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) {
|
||||
throw new Error('REDIS_URL env variable not set');
|
||||
}
|
||||
|
||||
// 创建客户端,配置重连策略
|
||||
client = createClient({
|
||||
url,
|
||||
socket: {
|
||||
// 重连策略:指数退避,最大30秒
|
||||
reconnectStrategy: (retries: number) => {
|
||||
console.log(`Redis reconnection attempt ${retries + 1}`);
|
||||
if (retries > 10) {
|
||||
console.error('Redis max reconnection attempts exceeded');
|
||||
return false; // 停止重连
|
||||
}
|
||||
return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒
|
||||
},
|
||||
connectTimeout: 10000, // 10秒连接超时
|
||||
// 设置no delay,减少延迟
|
||||
noDelay: true,
|
||||
},
|
||||
// 添加其他配置
|
||||
pingInterval: 30000, // 30秒ping一次,保持连接活跃
|
||||
});
|
||||
|
||||
// 添加错误事件监听
|
||||
client.on('error', (err) => {
|
||||
console.error('Redis client error:', err);
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log('Redis connected');
|
||||
});
|
||||
|
||||
client.on('reconnecting', () => {
|
||||
console.log('Redis reconnecting...');
|
||||
});
|
||||
|
||||
client.on('ready', () => {
|
||||
console.log('Redis ready');
|
||||
});
|
||||
|
||||
// 初始连接,带重试机制
|
||||
const connectWithRetry = async () => {
|
||||
try {
|
||||
await client!.connect();
|
||||
console.log('Redis connected successfully');
|
||||
} catch (err) {
|
||||
console.error('Redis initial connection failed:', err);
|
||||
console.log('Will retry in 5 seconds...');
|
||||
setTimeout(connectWithRetry, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
connectWithRetry();
|
||||
|
||||
(global as any)[globalKey] = client;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { AdminConfig } from './admin.types';
|
||||
|
||||
// 播放记录数据结构
|
||||
export interface PlayRecord {
|
||||
title: string;
|
||||
source_name: string;
|
||||
cover: string;
|
||||
year: string;
|
||||
index: number; // 第几集
|
||||
total_episodes: number; // 总集数
|
||||
play_time: number; // 播放进度(秒)
|
||||
total_time: number; // 总进度(秒)
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
search_title: string; // 搜索时使用的标题
|
||||
}
|
||||
|
||||
// 收藏数据结构
|
||||
export interface Favorite {
|
||||
source_name: string;
|
||||
total_episodes: number; // 总集数
|
||||
title: string;
|
||||
year: string;
|
||||
cover: string;
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
search_title: string; // 搜索时使用的标题
|
||||
}
|
||||
|
||||
// 存储接口
|
||||
export interface IStorage {
|
||||
// 播放记录相关
|
||||
getPlayRecord(userName: string, key: string): Promise<PlayRecord | null>;
|
||||
setPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void>;
|
||||
getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>;
|
||||
deletePlayRecord(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 收藏相关
|
||||
getFavorite(userName: string, key: string): Promise<Favorite | null>;
|
||||
setFavorite(userName: string, key: string, favorite: Favorite): Promise<void>;
|
||||
getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }>;
|
||||
deleteFavorite(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 用户相关
|
||||
registerUser(userName: string, password: string): Promise<void>;
|
||||
verifyUser(userName: string, password: string): Promise<boolean>;
|
||||
// 检查用户是否存在(无需密码)
|
||||
checkUserExist(userName: string): Promise<boolean>;
|
||||
// 修改用户密码
|
||||
changePassword(userName: string, newPassword: string): Promise<void>;
|
||||
// 删除用户(包括密码、搜索历史、播放记录、收藏夹)
|
||||
deleteUser(userName: string): Promise<void>;
|
||||
|
||||
// 搜索历史相关
|
||||
getSearchHistory(userName: string): Promise<string[]>;
|
||||
addSearchHistory(userName: string, keyword: string): Promise<void>;
|
||||
deleteSearchHistory(userName: string, keyword?: string): Promise<void>;
|
||||
|
||||
// 用户列表
|
||||
getAllUsers(): Promise<string[]>;
|
||||
|
||||
// 管理员配置相关
|
||||
getAdminConfig(): Promise<AdminConfig | null>;
|
||||
setAdminConfig(config: AdminConfig): Promise<void>;
|
||||
}
|
||||
|
||||
// 搜索结果数据结构
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes: string[];
|
||||
source: string;
|
||||
source_name: string;
|
||||
class?: string;
|
||||
year: string;
|
||||
desc?: string;
|
||||
type_name?: string;
|
||||
douban_id?: number;
|
||||
}
|
||||
|
||||
// 豆瓣数据结构
|
||||
export interface DoubanItem {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
rate: string;
|
||||
year: string;
|
||||
}
|
||||
|
||||
export interface DoubanResult {
|
||||
code: number;
|
||||
message: string;
|
||||
list: DoubanItem[];
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { Redis } from '@upstash/redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
|
||||
// 数据类型转换辅助函数
|
||||
function ensureString(value: any): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function ensureStringArray(value: any[]): string[] {
|
||||
return value.map((item) => String(item));
|
||||
}
|
||||
|
||||
// 添加Upstash Redis操作重试包装器
|
||||
async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries = 3
|
||||
): Promise<T> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err: any) {
|
||||
const isLastAttempt = i === maxRetries - 1;
|
||||
const isConnectionError =
|
||||
err.message?.includes('Connection') ||
|
||||
err.message?.includes('ECONNREFUSED') ||
|
||||
err.message?.includes('ENOTFOUND') ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'EPIPE' ||
|
||||
err.name === 'UpstashError';
|
||||
|
||||
if (isConnectionError && !isLastAttempt) {
|
||||
console.log(
|
||||
`Upstash Redis operation failed, retrying... (${i + 1}/${maxRetries})`
|
||||
);
|
||||
console.error('Error:', err.message);
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
export class UpstashRedisStorage implements IStorage {
|
||||
private client: Redis;
|
||||
|
||||
constructor() {
|
||||
this.client = getUpstashRedisClient();
|
||||
}
|
||||
|
||||
// ---------- 播放记录 ----------
|
||||
private prKey(user: string, key: string) {
|
||||
return `u:${user}:pr:${key}`; // u:username:pr:source+id
|
||||
}
|
||||
|
||||
async getPlayRecord(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<PlayRecord | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.prKey(userName, key))
|
||||
);
|
||||
return val ? (val as PlayRecord) : null;
|
||||
}
|
||||
|
||||
async setPlayRecord(
|
||||
userName: string,
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
await withRetry(() => this.client.set(this.prKey(userName, key), record));
|
||||
}
|
||||
|
||||
async getAllPlayRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, PlayRecord>> {
|
||||
const pattern = `u:${userName}:pr:*`;
|
||||
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
if (keys.length === 0) return {};
|
||||
|
||||
const result: Record<string, PlayRecord> = {};
|
||||
for (const fullKey of keys) {
|
||||
const value = await withRetry(() => this.client.get(fullKey));
|
||||
if (value) {
|
||||
// 截取 source+id 部分
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
||||
result[keyPart] = value as PlayRecord;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.prKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 收藏 ----------
|
||||
private favKey(user: string, key: string) {
|
||||
return `u:${user}:fav:${key}`;
|
||||
}
|
||||
|
||||
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.favKey(userName, key))
|
||||
);
|
||||
return val ? (val as Favorite) : null;
|
||||
}
|
||||
|
||||
async setFavorite(
|
||||
userName: string,
|
||||
key: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.favKey(userName, key), favorite)
|
||||
);
|
||||
}
|
||||
|
||||
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||
const pattern = `u:${userName}:fav:*`;
|
||||
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||
if (keys.length === 0) return {};
|
||||
|
||||
const result: Record<string, Favorite> = {};
|
||||
for (const fullKey of keys) {
|
||||
const value = await withRetry(() => this.client.get(fullKey));
|
||||
if (value) {
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
||||
result[keyPart] = value as Favorite;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||
await withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||
}
|
||||
|
||||
// ---------- 用户注册 / 登录 ----------
|
||||
private userPwdKey(user: string) {
|
||||
return `u:${user}:pwd`;
|
||||
}
|
||||
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() => this.client.set(this.userPwdKey(userName), password));
|
||||
}
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
const stored = await withRetry(() =>
|
||||
this.client.get(this.userPwdKey(userName))
|
||||
);
|
||||
if (stored === null) return false;
|
||||
// 确保比较时都是字符串类型
|
||||
return ensureString(stored) === password;
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
// 使用 EXISTS 判断 key 是否存在
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userPwdKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
// 修改用户密码
|
||||
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() =>
|
||||
this.client.set(this.userPwdKey(userName), newPassword)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除用户及其所有数据
|
||||
async deleteUser(userName: string): Promise<void> {
|
||||
// 删除用户密码
|
||||
await withRetry(() => this.client.del(this.userPwdKey(userName)));
|
||||
|
||||
// 删除搜索历史
|
||||
await withRetry(() => this.client.del(this.shKey(userName)));
|
||||
|
||||
// 删除播放记录
|
||||
const playRecordPattern = `u:${userName}:pr:*`;
|
||||
const playRecordKeys = await withRetry(() =>
|
||||
this.client.keys(playRecordPattern)
|
||||
);
|
||||
if (playRecordKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...playRecordKeys));
|
||||
}
|
||||
|
||||
// 删除收藏夹
|
||||
const favoritePattern = `u:${userName}:fav:*`;
|
||||
const favoriteKeys = await withRetry(() =>
|
||||
this.client.keys(favoritePattern)
|
||||
);
|
||||
if (favoriteKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...favoriteKeys));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
private shKey(user: string) {
|
||||
return `u:${user}:sh`; // u:username:sh
|
||||
}
|
||||
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
const result = await withRetry(() =>
|
||||
this.client.lrange(this.shKey(userName), 0, -1)
|
||||
);
|
||||
// 确保返回的都是字符串类型
|
||||
return ensureStringArray(result as any[]);
|
||||
}
|
||||
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
// 先去重
|
||||
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
||||
// 插入到最前
|
||||
await withRetry(() => this.client.lpush(key, ensureString(keyword)));
|
||||
// 限制最大长度
|
||||
await withRetry(() => this.client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||
}
|
||||
|
||||
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||
const key = this.shKey(userName);
|
||||
if (keyword) {
|
||||
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
||||
} else {
|
||||
await withRetry(() => this.client.del(key));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||
return keys
|
||||
.map((k) => {
|
||||
const match = k.match(/^u:(.+?):pwd$/);
|
||||
return match ? ensureString(match[1]) : undefined;
|
||||
})
|
||||
.filter((u): u is string => typeof u === 'string');
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
private adminConfigKey() {
|
||||
return 'admin:config';
|
||||
}
|
||||
|
||||
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
|
||||
return val ? (val as AdminConfig) : null;
|
||||
}
|
||||
|
||||
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||
await withRetry(() => this.client.set(this.adminConfigKey(), config));
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Upstash Redis 客户端
|
||||
function getUpstashRedisClient(): Redis {
|
||||
const globalKey = Symbol.for('__MOONTV_UPSTASH_REDIS_CLIENT__');
|
||||
let client: Redis | undefined = (global as any)[globalKey];
|
||||
|
||||
if (!client) {
|
||||
const upstashUrl = process.env.UPSTASH_URL;
|
||||
const upstashToken = process.env.UPSTASH_TOKEN;
|
||||
|
||||
if (!upstashUrl || !upstashToken) {
|
||||
throw new Error(
|
||||
'UPSTASH_URL and UPSTASH_TOKEN env variables must be set'
|
||||
);
|
||||
}
|
||||
|
||||
// 创建 Upstash Redis 客户端
|
||||
client = new Redis({
|
||||
url: upstashUrl,
|
||||
token: upstashToken,
|
||||
// 可选配置
|
||||
retry: {
|
||||
retries: 3,
|
||||
backoff: (retryCount: number) =>
|
||||
Math.min(1000 * Math.pow(2, retryCount), 30000),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Upstash Redis client created successfully');
|
||||
|
||||
(global as any)[globalKey] = client;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import Hls from 'hls.js';
|
||||
|
||||
/**
|
||||
* 获取图片代理 URL 设置
|
||||
*/
|
||||
export function getImageProxyUrl(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
// 本地未开启图片代理,则不使用代理
|
||||
const enableImageProxy = localStorage.getItem('enableImageProxy');
|
||||
if (enableImageProxy !== null) {
|
||||
if (!JSON.parse(enableImageProxy) as boolean) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const localImageProxy = localStorage.getItem('imageProxyUrl');
|
||||
if (localImageProxy != null) {
|
||||
return localImageProxy.trim() ? localImageProxy.trim() : null;
|
||||
}
|
||||
|
||||
// 如果未设置,则使用全局对象
|
||||
const serverImageProxy = (window as any).RUNTIME_CONFIG?.IMAGE_PROXY;
|
||||
return serverImageProxy && serverImageProxy.trim()
|
||||
? serverImageProxy.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片 URL,如果设置了图片代理则使用代理
|
||||
*/
|
||||
export function processImageUrl(originalUrl: string): string {
|
||||
if (!originalUrl) return originalUrl;
|
||||
|
||||
const proxyUrl = getImageProxyUrl();
|
||||
if (!proxyUrl) return originalUrl;
|
||||
|
||||
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取豆瓣代理 URL 设置
|
||||
*/
|
||||
export function getDoubanProxyUrl(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
// 本地未开启豆瓣代理,则不使用代理
|
||||
const enableDoubanProxy = localStorage.getItem('enableDoubanProxy');
|
||||
if (enableDoubanProxy !== null) {
|
||||
if (!JSON.parse(enableDoubanProxy) as boolean) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const localDoubanProxy = localStorage.getItem('doubanProxyUrl');
|
||||
if (localDoubanProxy != null) {
|
||||
return localDoubanProxy.trim() ? localDoubanProxy.trim() : null;
|
||||
}
|
||||
|
||||
// 如果未设置,则使用全局对象
|
||||
const serverDoubanProxy = (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY;
|
||||
return serverDoubanProxy && serverDoubanProxy.trim()
|
||||
? serverDoubanProxy.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理豆瓣 URL,如果设置了豆瓣代理则使用代理
|
||||
*/
|
||||
export function processDoubanUrl(originalUrl: string): string {
|
||||
if (!originalUrl) return originalUrl;
|
||||
|
||||
const proxyUrl = getDoubanProxyUrl();
|
||||
if (!proxyUrl) return originalUrl;
|
||||
|
||||
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
|
||||
}
|
||||
|
||||
export function cleanHtmlTags(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
|
||||
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
|
||||
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
||||
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
|
||||
.replace(/ /g, ' ') // 将 替换为空格
|
||||
.trim(); // 去掉首尾空格
|
||||
}
|
||||
|
||||
/**
|
||||
* 从m3u8地址获取视频质量等级和网络信息
|
||||
* @param m3u8Url m3u8播放列表的URL
|
||||
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number}> 视频质量等级和网络信息
|
||||
*/
|
||||
export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||
quality: string; // 如720p、1080p等
|
||||
loadSpeed: string; // 自动转换为KB/s或MB/s
|
||||
pingTime: number; // 网络延迟(毫秒)
|
||||
}> {
|
||||
try {
|
||||
// 直接使用m3u8 URL作为视频源,避免CORS问题
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.preload = 'metadata';
|
||||
|
||||
// 测量网络延迟(ping时间) - 使用m3u8 URL而不是ts文件
|
||||
const pingStart = performance.now();
|
||||
let pingTime = 0;
|
||||
|
||||
// 测量ping时间(使用m3u8 URL)
|
||||
fetch(m3u8Url, { method: 'HEAD', mode: 'no-cors' })
|
||||
.then(() => {
|
||||
pingTime = performance.now() - pingStart;
|
||||
})
|
||||
.catch(() => {
|
||||
pingTime = performance.now() - pingStart; // 记录到失败为止的时间
|
||||
});
|
||||
|
||||
// 固定使用hls.js加载
|
||||
const hls = new Hls();
|
||||
|
||||
// 设置超时处理
|
||||
const timeout = setTimeout(() => {
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
reject(new Error('Timeout loading video metadata'));
|
||||
}, 4000);
|
||||
|
||||
video.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
reject(new Error('Failed to load video metadata'));
|
||||
};
|
||||
|
||||
let actualLoadSpeed = '未知';
|
||||
let hasSpeedCalculated = false;
|
||||
let hasMetadataLoaded = false;
|
||||
|
||||
let fragmentStartTime = 0;
|
||||
|
||||
// 检查是否可以返回结果
|
||||
const checkAndResolve = () => {
|
||||
if (
|
||||
hasMetadataLoaded &&
|
||||
(hasSpeedCalculated || actualLoadSpeed !== '未知')
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
const width = video.videoWidth;
|
||||
if (width && width > 0) {
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
|
||||
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
|
||||
const quality =
|
||||
width >= 3840
|
||||
? '4K' // 4K: 3840x2160
|
||||
: width >= 2560
|
||||
? '2K' // 2K: 2560x1440
|
||||
: width >= 1920
|
||||
? '1080p' // 1080p: 1920x1080
|
||||
: width >= 1280
|
||||
? '720p' // 720p: 1280x720
|
||||
: width >= 854
|
||||
? '480p'
|
||||
: 'SD'; // 480p: 854x480
|
||||
|
||||
resolve({
|
||||
quality,
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
});
|
||||
} else {
|
||||
// webkit 无法获取尺寸,直接返回
|
||||
resolve({
|
||||
quality: '未知',
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听片段加载开始
|
||||
hls.on(Hls.Events.FRAG_LOADING, () => {
|
||||
fragmentStartTime = performance.now();
|
||||
});
|
||||
|
||||
// 监听片段加载完成,只需首个分片即可计算速度
|
||||
hls.on(Hls.Events.FRAG_LOADED, (event: any, data: any) => {
|
||||
if (
|
||||
fragmentStartTime > 0 &&
|
||||
data &&
|
||||
data.payload &&
|
||||
!hasSpeedCalculated
|
||||
) {
|
||||
const loadTime = performance.now() - fragmentStartTime;
|
||||
const size = data.payload.byteLength || 0;
|
||||
|
||||
if (loadTime > 0 && size > 0) {
|
||||
const speedKBps = size / 1024 / (loadTime / 1000);
|
||||
|
||||
// 立即计算速度,无需等待更多分片
|
||||
const avgSpeedKBps = speedKBps;
|
||||
|
||||
if (avgSpeedKBps >= 1024) {
|
||||
actualLoadSpeed = `${(avgSpeedKBps / 1024).toFixed(1)} MB/s`;
|
||||
} else {
|
||||
actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`;
|
||||
}
|
||||
hasSpeedCalculated = true;
|
||||
checkAndResolve(); // 尝试返回结果
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hls.loadSource(m3u8Url);
|
||||
hls.attachMedia(video);
|
||||
|
||||
// 监听hls.js错误
|
||||
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||
console.error('HLS错误:', data);
|
||||
if (data.fatal) {
|
||||
clearTimeout(timeout);
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
reject(new Error(`HLS播放失败: ${data.type}`));
|
||||
}
|
||||
});
|
||||
|
||||
// 监听视频元数据加载完成
|
||||
video.onloadedmetadata = () => {
|
||||
hasMetadataLoaded = true;
|
||||
checkAndResolve(); // 尝试返回结果
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Error getting video resolution: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
const CURRENT_VERSION = '20250928125318';
|
||||
|
||||
// 版本检查结果枚举
|
||||
export enum UpdateStatus {
|
||||
HAS_UPDATE = 'has_update', // 有新版本
|
||||
NO_UPDATE = 'no_update', // 无新版本
|
||||
FETCH_FAILED = 'fetch_failed', // 获取失败
|
||||
}
|
||||
|
||||
// 远程版本检查URL配置
|
||||
const VERSION_CHECK_URLS = [
|
||||
'https://ghfast.top/raw.githubusercontent.com/senshinya/MoonTV/main/VERSION.txt',
|
||||
'https://raw.githubusercontent.com/senshinya/MoonTV/main/VERSION.txt',
|
||||
];
|
||||
|
||||
/**
|
||||
* 检查是否有新版本可用
|
||||
* @returns Promise<UpdateStatus> - 返回版本检查状态
|
||||
*/
|
||||
export async function checkForUpdates(): Promise<UpdateStatus> {
|
||||
try {
|
||||
// 尝试从主要URL获取版本信息
|
||||
const primaryVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[0]);
|
||||
if (primaryVersion) {
|
||||
return compareVersions(primaryVersion);
|
||||
}
|
||||
|
||||
// 如果主要URL失败,尝试备用URL
|
||||
const backupVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[1]);
|
||||
if (backupVersion) {
|
||||
return compareVersions(backupVersion);
|
||||
}
|
||||
|
||||
// 如果两个URL都失败,返回获取失败状态
|
||||
return UpdateStatus.FETCH_FAILED;
|
||||
} catch (error) {
|
||||
console.error('版本检查失败:', error);
|
||||
return UpdateStatus.FETCH_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定URL获取版本信息
|
||||
* @param url - 版本信息URL
|
||||
* @returns Promise<string | null> - 版本字符串或null
|
||||
*/
|
||||
async function fetchVersionFromUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const version = await response.text();
|
||||
return version.trim();
|
||||
} catch (error) {
|
||||
console.warn(`从 ${url} 获取版本信息失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较版本号
|
||||
* @param remoteVersion - 远程版本号
|
||||
* @returns UpdateStatus - 返回版本比较结果
|
||||
*/
|
||||
function compareVersions(remoteVersion: string): UpdateStatus {
|
||||
try {
|
||||
// 将版本号转换为数字进行比较
|
||||
const current = parseInt(CURRENT_VERSION, 10);
|
||||
const remote = parseInt(remoteVersion, 10);
|
||||
|
||||
return remote > current ? UpdateStatus.HAS_UPDATE : UpdateStatus.NO_UPDATE;
|
||||
} catch (error) {
|
||||
console.error('版本比较失败:', error);
|
||||
return UpdateStatus.FETCH_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出当前版本号供其他地方使用
|
||||
export { CURRENT_VERSION };
|
||||
Reference in New Issue
Block a user