This commit is contained in:
katelya
2025-08-27 14:07:03 +08:00
parent 5b7e209355
commit f239211864
111 changed files with 31739 additions and 197 deletions
+31
View File
@@ -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;
}
+72
View File
@@ -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;
}
}
+389
View File
@@ -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,
}));
}
+476
View File
@@ -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;
}
}
}
+1244
View File
@@ -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
View File
@@ -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();
+148
View File
@@ -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();
}
}
+344
View File
@@ -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,
};
}
+51
View File
@@ -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;
}
+355
View File
@@ -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;
}
+97
View File
@@ -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[];
}
+305
View File
@@ -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;
}
+247
View File
@@ -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(/&nbsp;/g, ' ') // 将 &nbsp; 替换为空格
.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)
}`
);
}
}
+97
View File
@@ -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 };