first commit
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
export interface AdminConfig {
|
||||
SiteConfig: {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
SearchDownstreamMaxPage: number;
|
||||
SiteInterfaceCacheTime: number;
|
||||
DoubanProxyType: string;
|
||||
DoubanProxy: string;
|
||||
DoubanImageProxyType: string;
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
};
|
||||
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;
|
||||
}[];
|
||||
CustomCategories: {
|
||||
name?: string;
|
||||
type: 'movie' | 'tv';
|
||||
query: 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,29 @@
|
||||
'use client';
|
||||
|
||||
export interface BangumiCalendarData {
|
||||
weekday: {
|
||||
en: string;
|
||||
};
|
||||
items: {
|
||||
id: number;
|
||||
name: string;
|
||||
name_cn: string;
|
||||
rating: {
|
||||
score: number;
|
||||
};
|
||||
air_date: string;
|
||||
images: {
|
||||
large: string;
|
||||
common: string;
|
||||
medium: string;
|
||||
small: string;
|
||||
grid: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function GetBangumiCalendarData(): Promise<BangumiCalendarData[]> {
|
||||
const response = await fetch('https://api.bgm.tv/calendar');
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// 此文件由 scripts/convert-changelog.js 自动生成
|
||||
// 请勿手动编辑
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
added: string[];
|
||||
changed: string[];
|
||||
fixed: string[];
|
||||
}
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: "1.1.1",
|
||||
date: "2025-08-12",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
"修正 zwei 提供的 cors proxy 地址",
|
||||
"移除废弃代码"
|
||||
],
|
||||
fixed: [
|
||||
"[运维] docker workflow release 日期使用东八区日期"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.1.0",
|
||||
date: "2025-08-12",
|
||||
added: [
|
||||
"每日新番放送功能,展示每日新番放送的番剧"
|
||||
],
|
||||
changed: [
|
||||
// 无变更内容
|
||||
],
|
||||
fixed: [
|
||||
"修复远程 CHANGELOG 无法提取变更内容的问题"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.5",
|
||||
date: "2025-08-12",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
"实现基于 Git 标签的自动 Release 工作流"
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.4",
|
||||
date: "2025-08-11",
|
||||
added: [
|
||||
"优化版本管理工作流,实现单点修改"
|
||||
],
|
||||
changed: [
|
||||
"版本号现在从 CHANGELOG 自动提取,无需手动维护 VERSION.txt"
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.3",
|
||||
date: "2025-08-11",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
"升级播放器 Artplayer 至版本 5.2.5"
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.2",
|
||||
date: "2025-08-11",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
"版本号比较机制恢复为数字比较,仅当最新版本大于本地版本时才认为有更新",
|
||||
"[运维] 自动替换 version.ts 中的版本号为 VERSION.txt 中的版本号"
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.1",
|
||||
date: "2025-08-11",
|
||||
added: [
|
||||
// 无新增内容
|
||||
],
|
||||
changed: [
|
||||
// 无变更内容
|
||||
],
|
||||
fixed: [
|
||||
"修复版本检查功能,只要与最新版本号不一致即认为有更新"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "1.0.0",
|
||||
date: "2025-08-10",
|
||||
added: [
|
||||
"基于 Semantic Versioning 的版本号机制",
|
||||
"版本信息面板,展示本地变更日志和远程更新日志"
|
||||
],
|
||||
changed: [
|
||||
// 无变更内容
|
||||
],
|
||||
fixed: [
|
||||
// 无修复内容
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default changelog;
|
||||
@@ -0,0 +1,509 @@
|
||||
/* 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;
|
||||
};
|
||||
custom_category?: {
|
||||
name?: string;
|
||||
type: 'movie' | 'tv';
|
||||
query: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
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);
|
||||
const customCategories = fileConfig.custom_category || [];
|
||||
|
||||
if (adminConfig) {
|
||||
// 补全 SourceConfig
|
||||
const sourceConfigMap = new Map(
|
||||
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
|
||||
);
|
||||
|
||||
apiSiteEntries.forEach(([key, site]) => {
|
||||
sourceConfigMap.set(key, {
|
||||
key,
|
||||
name: site.name,
|
||||
api: site.api,
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.SourceConfig = Array.from(sourceConfigMap.values());
|
||||
|
||||
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
|
||||
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
|
||||
adminConfig.SourceConfig.forEach((source) => {
|
||||
if (!apiSiteKeys.has(source.key)) {
|
||||
source.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
// 确保 CustomCategories 被初始化
|
||||
if (!adminConfig.CustomCategories) {
|
||||
adminConfig.CustomCategories = [];
|
||||
}
|
||||
|
||||
// 补全 CustomCategories
|
||||
const customCategoriesMap = new Map(
|
||||
adminConfig.CustomCategories.map((c) => [c.query + c.type, c])
|
||||
);
|
||||
|
||||
customCategories.forEach((category) => {
|
||||
customCategoriesMap.set(category.query + category.type, {
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
// 检查现有 CustomCategories 是否在 fileConfig.custom_category 中,如果不在则标记为 custom
|
||||
const customCategoriesKeys = new Set(
|
||||
customCategories.map((c) => c.query + c.type)
|
||||
);
|
||||
customCategoriesMap.forEach((category) => {
|
||||
if (!customCategoriesKeys.has(category.query + category.type)) {
|
||||
category.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.CustomCategories = Array.from(customCategoriesMap.values());
|
||||
|
||||
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.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage:
|
||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||
DoubanProxyType:
|
||||
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
DoubanImageProxyType:
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
|
||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||
DisableYellowFilter:
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||
},
|
||||
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,
|
||||
})),
|
||||
CustomCategories: customCategories.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
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.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage:
|
||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
DoubanImageProxyType:
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
|
||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||
DisableYellowFilter:
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||
},
|
||||
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,
|
||||
})),
|
||||
CustomCategories:
|
||||
fileConfig.custom_category?.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
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) {
|
||||
// 确保 CustomCategories 被初始化
|
||||
if (!adminConfig.CustomCategories) {
|
||||
adminConfig.CustomCategories = [];
|
||||
}
|
||||
|
||||
// 合并一些环境变量配置
|
||||
adminConfig.SiteConfig.SiteName =
|
||||
process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
||||
adminConfig.SiteConfig.Announcement =
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||
adminConfig.UserConfig.AllowRegister =
|
||||
process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
|
||||
adminConfig.SiteConfig.DoubanProxyType =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct';
|
||||
adminConfig.SiteConfig.DoubanProxy =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
||||
adminConfig.SiteConfig.DoubanImageProxyType =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct';
|
||||
adminConfig.SiteConfig.DoubanImageProxy =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
|
||||
adminConfig.SiteConfig.DisableYellowFilter =
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||
|
||||
// 合并文件中的源信息
|
||||
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||
const sourceConfigMap = new Map(
|
||||
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
|
||||
);
|
||||
|
||||
apiSiteEntries.forEach(([key, site]) => {
|
||||
const existingSource = sourceConfigMap.get(key);
|
||||
if (existingSource) {
|
||||
// 如果已存在,只覆盖 name、api、detail 和 from
|
||||
existingSource.name = site.name;
|
||||
existingSource.api = site.api;
|
||||
existingSource.detail = site.detail;
|
||||
existingSource.from = 'config';
|
||||
} else {
|
||||
// 如果不存在,创建新条目
|
||||
sourceConfigMap.set(key, {
|
||||
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));
|
||||
sourceConfigMap.forEach((source) => {
|
||||
if (!apiSiteKeys.has(source.key)) {
|
||||
source.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.SourceConfig = Array.from(sourceConfigMap.values());
|
||||
|
||||
// 覆盖 CustomCategories
|
||||
const customCategories = fileConfig.custom_category || [];
|
||||
adminConfig.CustomCategories = customCategories.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
}));
|
||||
|
||||
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 storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
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);
|
||||
const customCategories = fileConfig.custom_category || [];
|
||||
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.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
|
||||
Announcement:
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||
SearchDownstreamMaxPage:
|
||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
|
||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||
DoubanImageProxyType:
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
|
||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||
DisableYellowFilter:
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||
},
|
||||
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,
|
||||
})),
|
||||
CustomCategories:
|
||||
storageType === 'redis'
|
||||
? customCategories?.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
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;
|
||||
cachedConfig.CustomCategories = adminConfig.CustomCategories;
|
||||
}
|
||||
|
||||
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,1657 @@
|
||||
/* 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';
|
||||
import { SkipConfig } from './types';
|
||||
|
||||
// 全局错误触发函数
|
||||
function triggerGlobalError(message: string) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 类型 ----
|
||||
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[]>;
|
||||
skipConfigs?: CacheData<Record<string, SkipConfig>>;
|
||||
}
|
||||
|
||||
// ---- 常量 ----
|
||||
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'
|
||||
| '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 {
|
||||
// 检查缓存大小,超过15MB时清理旧数据
|
||||
const cacheSize = JSON.stringify(cache).length;
|
||||
if (cacheSize > 15 * 1024 * 1024) {
|
||||
console.warn('缓存过大,清理旧数据');
|
||||
this.cleanOldCache(cache);
|
||||
}
|
||||
|
||||
const cacheKey = this.getUserCacheKey(username);
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cache));
|
||||
} catch (error) {
|
||||
console.warn('保存用户缓存失败:', error);
|
||||
// 存储空间不足时清理缓存后重试
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
error.name === 'QuotaExceededError'
|
||||
) {
|
||||
this.clearAllCache();
|
||||
try {
|
||||
const cacheKey = this.getUserCacheKey(username);
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cache));
|
||||
} catch (retryError) {
|
||||
console.error('重试保存缓存仍然失败:', retryError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期缓存数据
|
||||
*/
|
||||
private cleanOldCache(cache: UserCacheStore): void {
|
||||
const now = Date.now();
|
||||
const maxAge = 60 * 24 * 60 * 60 * 1000; // 两个月
|
||||
|
||||
// 清理过期的播放记录缓存
|
||||
if (cache.playRecords && now - cache.playRecords.timestamp > maxAge) {
|
||||
delete cache.playRecords;
|
||||
}
|
||||
|
||||
// 清理过期的收藏缓存
|
||||
if (cache.favorites && now - cache.favorites.timestamp > maxAge) {
|
||||
delete cache.favorites;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有缓存
|
||||
*/
|
||||
private clearAllCache(): void {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('moontv_cache_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否有效
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的跳过片头片尾配置
|
||||
*/
|
||||
getCachedSkipConfigs(): Record<string, SkipConfig> | null {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return null;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
const cached = userCache.skipConfigs;
|
||||
|
||||
if (cached && this.isCacheValid(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存跳过片头片尾配置
|
||||
*/
|
||||
cacheSkipConfigs(data: Record<string, SkipConfig>): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.skipConfigs = 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);
|
||||
triggerGlobalError(`数据库操作失败`);
|
||||
|
||||
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);
|
||||
triggerGlobalError(`刷新${dataType}缓存失败`);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时清理过期缓存
|
||||
if (typeof window !== 'undefined') {
|
||||
setTimeout(() => cacheManager.clearExpiredCaches(), 1000);
|
||||
}
|
||||
|
||||
// ---- 工具函数 ----
|
||||
/**
|
||||
* 通用的 fetch 函数,处理 401 状态码自动跳转登录
|
||||
*/
|
||||
async function fetchWithAuth(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<Response> {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) {
|
||||
// 如果是 401 未授权,跳转到登录页面
|
||||
if (res.status === 401) {
|
||||
// 调用 logout 接口
|
||||
try {
|
||||
await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('注销请求失败:', error);
|
||||
}
|
||||
const currentUrl = window.location.pathname + window.location.search;
|
||||
const loginUrl = new URL('/login', window.location.origin);
|
||||
loginUrl.searchParams.set('redirect', currentUrl);
|
||||
window.location.href = loginUrl.toString();
|
||||
throw new Error('用户未授权,已跳转到登录页面');
|
||||
}
|
||||
throw new Error(`请求 ${url} 失败: ${res.status}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function fetchFromApi<T>(path: string): Promise<T> {
|
||||
const res = await fetchWithAuth(path);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成存储key
|
||||
*/
|
||||
export function generateStorageKey(source: string, id: string): string {
|
||||
return `${source}+${id}`;
|
||||
}
|
||||
|
||||
// ---- API ----
|
||||
/**
|
||||
* 读取全部播放记录。
|
||||
* 非本地存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
|
||||
*/
|
||||
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
||||
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis 和 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);
|
||||
triggerGlobalError('后台同步播放记录失败');
|
||||
});
|
||||
|
||||
return cachedData;
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, PlayRecord>>(
|
||||
`/api/playrecords`
|
||||
);
|
||||
cacheManager.cachePlayRecords(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取播放记录失败:', err);
|
||||
triggerGlobalError('获取播放记录失败');
|
||||
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);
|
||||
triggerGlobalError('读取播放记录失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存播放记录。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
||||
*/
|
||||
export async function savePlayRecord(
|
||||
source: string,
|
||||
id: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||
cachedRecords[key] = record;
|
||||
cacheManager.cachePlayRecords(cachedRecords);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: cachedRecords,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth('/api/playrecords', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, record }),
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('playRecords', err);
|
||||
triggerGlobalError('保存播放记录失败');
|
||||
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);
|
||||
triggerGlobalError('保存播放记录失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除播放记录。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function deletePlayRecord(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||
delete cachedRecords[key];
|
||||
cacheManager.cachePlayRecords(cachedRecords);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: cachedRecords,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth(`/api/playrecords?key=${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('playRecords', err);
|
||||
triggerGlobalError('删除播放记录失败');
|
||||
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);
|
||||
triggerGlobalError('删除播放记录失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 搜索历史相关 API ---------------- */
|
||||
|
||||
/**
|
||||
* 获取搜索历史。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
*/
|
||||
export async function getSearchHistory(): Promise<string[]> {
|
||||
// 服务器端渲染阶段直接返回空
|
||||
if (typeof window === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis 和 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);
|
||||
triggerGlobalError('后台同步搜索历史失败');
|
||||
});
|
||||
|
||||
return cachedData;
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
||||
cacheManager.cacheSearchHistory(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取搜索历史失败:', err);
|
||||
triggerGlobalError('获取搜索历史失败');
|
||||
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);
|
||||
triggerGlobalError('读取搜索历史失败');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将关键字添加到搜索历史。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function addSearchHistory(keyword: string): Promise<void> {
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 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 {
|
||||
await fetchWithAuth('/api/searchhistory', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ keyword: trimmed }),
|
||||
});
|
||||
} 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);
|
||||
triggerGlobalError('保存搜索历史失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空搜索历史。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function clearSearchHistory(): Promise<void> {
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
cacheManager.cacheSearchHistory([]);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('searchHistoryUpdated', {
|
||||
detail: [],
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth(`/api/searchhistory`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} 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 和 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 {
|
||||
await fetchWithAuth(
|
||||
`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
} 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);
|
||||
triggerGlobalError('删除搜索历史失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 收藏相关 API ----------------
|
||||
|
||||
/**
|
||||
* 获取全部收藏。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
*/
|
||||
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
||||
// 服务器端渲染阶段直接返回空
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis 和 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);
|
||||
triggerGlobalError('后台同步收藏失败');
|
||||
});
|
||||
|
||||
return cachedData;
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||
`/api/favorites`
|
||||
);
|
||||
cacheManager.cacheFavorites(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取收藏失败:', err);
|
||||
triggerGlobalError('获取收藏失败');
|
||||
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);
|
||||
triggerGlobalError('读取收藏失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存收藏。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function saveFavorite(
|
||||
source: string,
|
||||
id: string,
|
||||
favorite: Favorite
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||
cachedFavorites[key] = favorite;
|
||||
cacheManager.cacheFavorites(cachedFavorites);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: cachedFavorites,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth('/api/favorites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, favorite }),
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('favorites', err);
|
||||
triggerGlobalError('保存收藏失败');
|
||||
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);
|
||||
triggerGlobalError('保存收藏失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除收藏。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function deleteFavorite(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||
delete cachedFavorites[key];
|
||||
cacheManager.cacheFavorites(cachedFavorites);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: cachedFavorites,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth(`/api/favorites?key=${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('favorites', err);
|
||||
triggerGlobalError('删除收藏失败');
|
||||
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);
|
||||
triggerGlobalError('删除收藏失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否已收藏。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
*/
|
||||
export async function isFavorited(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<boolean> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis 和 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);
|
||||
triggerGlobalError('后台同步收藏失败');
|
||||
});
|
||||
|
||||
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);
|
||||
triggerGlobalError('检查收藏状态失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
const allFavorites = await getAllFavorites();
|
||||
return !!allFavorites[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空全部播放记录
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function clearAllPlayRecords(): Promise<void> {
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
cacheManager.cachePlayRecords({});
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: {},
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth(`/api/playrecords`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('playRecords', err);
|
||||
triggerGlobalError('清空播放记录失败');
|
||||
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 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
cacheManager.cacheFavorites({});
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('favoritesUpdated', {
|
||||
detail: {},
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth(`/api/favorites`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('favorites', err);
|
||||
triggerGlobalError('清空收藏失败');
|
||||
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, skipConfigs] =
|
||||
await Promise.allSettled([
|
||||
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
|
||||
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
|
||||
fetchFromApi<string[]>(`/api/searchhistory`),
|
||||
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`),
|
||||
]);
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (skipConfigs.status === 'fulfilled') {
|
||||
cacheManager.cacheSkipConfigs(skipConfigs.value);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('skipConfigsUpdated', {
|
||||
detail: skipConfigs.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('刷新缓存失败:', err);
|
||||
triggerGlobalError('刷新缓存失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存状态信息
|
||||
* 用于调试和监控缓存健康状态
|
||||
*/
|
||||
export function getCacheStatus(): {
|
||||
hasPlayRecords: boolean;
|
||||
hasFavorites: boolean;
|
||||
hasSearchHistory: boolean;
|
||||
hasSkipConfigs: boolean;
|
||||
username: string | null;
|
||||
} {
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
return {
|
||||
hasPlayRecords: false,
|
||||
hasFavorites: false,
|
||||
hasSearchHistory: false,
|
||||
hasSkipConfigs: false,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
return {
|
||||
hasPlayRecords: !!cacheManager.getCachedPlayRecords(),
|
||||
hasFavorites: !!cacheManager.getCachedFavorites(),
|
||||
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
|
||||
hasSkipConfigs: !!cacheManager.getCachedSkipConfigs(),
|
||||
username: authInfo?.username || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------- React Hook 辅助类型 ----------------
|
||||
|
||||
export type CacheUpdateEvent =
|
||||
| 'playRecordsUpdated'
|
||||
| 'favoritesUpdated'
|
||||
| 'searchHistoryUpdated'
|
||||
| 'skipConfigsUpdated';
|
||||
|
||||
/**
|
||||
* 用于 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 &&
|
||||
status.hasSkipConfigs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 后台静默预加载,不阻塞界面
|
||||
refreshAllCache().catch((err) => {
|
||||
console.warn('预加载用户数据失败:', err);
|
||||
triggerGlobalError('预加载用户数据失败');
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 跳过片头片尾配置相关 API ----------------
|
||||
|
||||
/**
|
||||
* 获取跳过片头片尾配置。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
*/
|
||||
export async function getSkipConfig(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null> {
|
||||
// 服务器端渲染阶段直接返回空
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 优先从缓存获取数据
|
||||
const cachedData = cacheManager.getCachedSkipConfigs();
|
||||
|
||||
if (cachedData) {
|
||||
// 返回缓存数据,同时后台异步更新
|
||||
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`)
|
||||
.then((freshData) => {
|
||||
// 只有数据真正不同时才更新缓存
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheSkipConfigs(freshData);
|
||||
// 触发数据更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('skipConfigsUpdated', {
|
||||
detail: freshData,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步跳过片头片尾配置失败:', err);
|
||||
});
|
||||
|
||||
return cachedData[key] || null;
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, SkipConfig>>(
|
||||
`/api/skipconfigs`
|
||||
);
|
||||
cacheManager.cacheSkipConfigs(freshData);
|
||||
return freshData[key] || null;
|
||||
} catch (err) {
|
||||
console.error('获取跳过片头片尾配置失败:', err);
|
||||
triggerGlobalError('获取跳过片头片尾配置失败');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
try {
|
||||
const raw = localStorage.getItem('moontv_skip_configs');
|
||||
if (!raw) return null;
|
||||
const configs = JSON.parse(raw) as Record<string, SkipConfig>;
|
||||
return configs[key] || null;
|
||||
} catch (err) {
|
||||
console.error('读取跳过片头片尾配置失败:', err);
|
||||
triggerGlobalError('读取跳过片头片尾配置失败');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存跳过片头片尾配置。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function saveSkipConfig(
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedConfigs = cacheManager.getCachedSkipConfigs() || {};
|
||||
cachedConfigs[key] = config;
|
||||
cacheManager.cacheSkipConfigs(cachedConfigs);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('skipConfigsUpdated', {
|
||||
detail: cachedConfigs,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth('/api/skipconfigs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, config }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('保存跳过片头片尾配置失败:', err);
|
||||
triggerGlobalError('保存跳过片头片尾配置失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端保存跳过片头片尾配置到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem('moontv_skip_configs');
|
||||
const configs = raw ? (JSON.parse(raw) as Record<string, SkipConfig>) : {};
|
||||
configs[key] = config;
|
||||
localStorage.setItem('moontv_skip_configs', JSON.stringify(configs));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('skipConfigsUpdated', {
|
||||
detail: configs,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('保存跳过片头片尾配置失败:', err);
|
||||
triggerGlobalError('保存跳过片头片尾配置失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有跳过片头片尾配置。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
*/
|
||||
export async function getAllSkipConfigs(): Promise<Record<string, SkipConfig>> {
|
||||
// 服务器端渲染阶段直接返回空
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 优先从缓存获取数据
|
||||
const cachedData = cacheManager.getCachedSkipConfigs();
|
||||
|
||||
if (cachedData) {
|
||||
// 返回缓存数据,同时后台异步更新
|
||||
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`)
|
||||
.then((freshData) => {
|
||||
// 只有数据真正不同时才更新缓存
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheSkipConfigs(freshData);
|
||||
// 触发数据更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('skipConfigsUpdated', {
|
||||
detail: freshData,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步跳过片头片尾配置失败:', err);
|
||||
triggerGlobalError('后台同步跳过片头片尾配置失败');
|
||||
});
|
||||
|
||||
return cachedData;
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, SkipConfig>>(
|
||||
`/api/skipconfigs`
|
||||
);
|
||||
cacheManager.cacheSkipConfigs(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取跳过片头片尾配置失败:', err);
|
||||
triggerGlobalError('获取跳过片头片尾配置失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
try {
|
||||
const raw = localStorage.getItem('moontv_skip_configs');
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, SkipConfig>;
|
||||
} catch (err) {
|
||||
console.error('读取跳过片头片尾配置失败:', err);
|
||||
triggerGlobalError('读取跳过片头片尾配置失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除跳过片头片尾配置。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function deleteSkipConfig(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedConfigs = cacheManager.getCachedSkipConfigs() || {};
|
||||
delete cachedConfigs[key];
|
||||
cacheManager.cacheSkipConfigs(cachedConfigs);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('skipConfigsUpdated', {
|
||||
detail: cachedConfigs,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth(`/api/skipconfigs?key=${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('删除跳过片头片尾配置失败:', err);
|
||||
triggerGlobalError('删除跳过片头片尾配置失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端删除跳过片头片尾配置到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem('moontv_skip_configs');
|
||||
if (raw) {
|
||||
const configs = JSON.parse(raw) as Record<string, SkipConfig>;
|
||||
delete configs[key];
|
||||
localStorage.setItem('moontv_skip_configs', JSON.stringify(configs));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('skipConfigsUpdated', {
|
||||
detail: configs,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('删除跳过片头片尾配置失败:', err);
|
||||
triggerGlobalError('删除跳过片头片尾配置失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { RedisStorage } from './redis.db';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { UpstashRedisStorage } from './upstash.db';
|
||||
|
||||
// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage'
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'upstash'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// 创建存储实例
|
||||
function createStorage(): IStorage {
|
||||
switch (STORAGE_TYPE) {
|
||||
case 'redis':
|
||||
return new RedisStorage();
|
||||
case 'upstash':
|
||||
return new UpstashRedisStorage();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 跳过片头片尾配置 ----------
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null> {
|
||||
if (typeof (this.storage as any).getSkipConfig === 'function') {
|
||||
return (this.storage as any).getSkipConfig(userName, source, id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).setSkipConfig === 'function') {
|
||||
await (this.storage as any).setSkipConfig(userName, source, id, config);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteSkipConfig === 'function') {
|
||||
await (this.storage as any).deleteSkipConfig(userName, source, id);
|
||||
}
|
||||
}
|
||||
|
||||
async getAllSkipConfigs(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: SkipConfig }> {
|
||||
if (typeof (this.storage as any).getAllSkipConfigs === 'function') {
|
||||
return (this.storage as any).getAllSkipConfigs(userName);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export const db = new DbManager();
|
||||
@@ -0,0 +1,479 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,no-case-declarations */
|
||||
|
||||
import { DoubanItem, DoubanResult } from './types';
|
||||
|
||||
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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DoubanListApiResponse {
|
||||
total: number;
|
||||
subjects: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
card_subtitle: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DoubanRecommendApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
year: string;
|
||||
type: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时的 fetch 请求
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
proxyUrl: string
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
// 检查是否使用代理
|
||||
const finalUrl =
|
||||
proxyUrl === 'https://cors-anywhere.com/'
|
||||
? `${proxyUrl}${url}`
|
||||
: proxyUrl
|
||||
? `${proxyUrl}${encodeURIComponent(url)}`
|
||||
: url;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
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, */*',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(finalUrl, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getDoubanProxyConfig(): {
|
||||
proxyType:
|
||||
| 'direct'
|
||||
| 'cors-proxy-zwei'
|
||||
| 'cmliussss-cdn-tencent'
|
||||
| 'cmliussss-cdn-ali'
|
||||
| 'cors-anywhere'
|
||||
| 'custom';
|
||||
proxyUrl: string;
|
||||
} {
|
||||
const doubanProxyType =
|
||||
localStorage.getItem('doubanDataSource') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE ||
|
||||
'direct';
|
||||
const doubanProxy =
|
||||
localStorage.getItem('doubanProxyUrl') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
|
||||
'';
|
||||
return {
|
||||
proxyType: doubanProxyType,
|
||||
proxyUrl: doubanProxy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器端豆瓣分类数据获取函数
|
||||
*/
|
||||
export async function fetchDoubanCategories(
|
||||
params: DoubanCategoriesParams,
|
||||
proxyUrl: string,
|
||||
useTencentCDN = false,
|
||||
useAliCDN = false
|
||||
): 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 = useTencentCDN
|
||||
? `https://m.douban.cmliussss.net/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`
|
||||
: useAliCDN
|
||||
? `https://m.douban.cmliussss.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`
|
||||
: `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,
|
||||
useTencentCDN || useAliCDN ? '' : proxyUrl
|
||||
);
|
||||
|
||||
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) {
|
||||
// 触发全局错误提示
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message: '获取豆瓣分类数据失败' },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的豆瓣分类数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
|
||||
*/
|
||||
export async function getDoubanCategories(
|
||||
params: DoubanCategoriesParams
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanCategories(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanCategories(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanCategories(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanCategories(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
interface DoubanListParams {
|
||||
tag: string;
|
||||
type: string;
|
||||
pageLimit?: number;
|
||||
pageStart?: number;
|
||||
}
|
||||
|
||||
export async function getDoubanList(
|
||||
params: DoubanListParams
|
||||
): Promise<DoubanResult> {
|
||||
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanList(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanList(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanList(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanList(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanList(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDoubanList(
|
||||
params: DoubanListParams,
|
||||
proxyUrl: string,
|
||||
useTencentCDN = false,
|
||||
useAliCDN = false
|
||||
): Promise<DoubanResult> {
|
||||
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
|
||||
|
||||
// 验证参数
|
||||
if (!tag || !type) {
|
||||
throw new Error('tag 和 type 参数不能为空');
|
||||
}
|
||||
|
||||
if (!['tv', 'movie'].includes(type)) {
|
||||
throw new Error('type 参数必须是 tv 或 movie');
|
||||
}
|
||||
|
||||
if (pageLimit < 1 || pageLimit > 100) {
|
||||
throw new Error('pageLimit 必须在 1-100 之间');
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
throw new Error('pageStart 不能小于 0');
|
||||
}
|
||||
|
||||
const target = useTencentCDN
|
||||
? `https://movie.douban.cmliussss.net/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`
|
||||
: useAliCDN
|
||||
? `https://movie.douban.cmliussss.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`
|
||||
: `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
target,
|
||||
useTencentCDN || useAliCDN ? '' : proxyUrl
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const doubanData: DoubanListApiResponse = await response.json();
|
||||
|
||||
// 转换数据格式
|
||||
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.cover,
|
||||
rate: item.rate,
|
||||
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
// 触发全局错误提示
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message: '获取豆瓣列表数据失败' },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface DoubanRecommendsParams {
|
||||
kind: 'tv' | 'movie';
|
||||
pageLimit?: number;
|
||||
pageStart?: number;
|
||||
category?: string;
|
||||
format?: string;
|
||||
label?: string;
|
||||
region?: string;
|
||||
year?: string;
|
||||
platform?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export async function getDoubanRecommends(
|
||||
params: DoubanRecommendsParams
|
||||
): Promise<DoubanResult> {
|
||||
const {
|
||||
kind,
|
||||
pageLimit = 20,
|
||||
pageStart = 0,
|
||||
category,
|
||||
format,
|
||||
label,
|
||||
region,
|
||||
year,
|
||||
platform,
|
||||
sort,
|
||||
} = params;
|
||||
const { proxyType, proxyUrl } = getDoubanProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'cors-proxy-zwei':
|
||||
return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return fetchDoubanRecommends(params, '', true, false);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return fetchDoubanRecommends(params, '', false, true);
|
||||
case 'cors-anywhere':
|
||||
return fetchDoubanRecommends(params, 'https://cors-anywhere.com/');
|
||||
case 'custom':
|
||||
return fetchDoubanRecommends(params, proxyUrl);
|
||||
case 'direct':
|
||||
default:
|
||||
const response = await fetch(
|
||||
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
|
||||
);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDoubanRecommends(
|
||||
params: DoubanRecommendsParams,
|
||||
proxyUrl: string,
|
||||
useTencentCDN = false,
|
||||
useAliCDN = false
|
||||
): Promise<DoubanResult> {
|
||||
const { kind, pageLimit = 20, pageStart = 0 } = params;
|
||||
let { category, format, region, year, platform, sort, label } = params;
|
||||
if (category === 'all') {
|
||||
category = '';
|
||||
}
|
||||
if (format === 'all') {
|
||||
format = '';
|
||||
}
|
||||
if (label === 'all') {
|
||||
label = '';
|
||||
}
|
||||
if (region === 'all') {
|
||||
region = '';
|
||||
}
|
||||
if (year === 'all') {
|
||||
year = '';
|
||||
}
|
||||
if (platform === 'all') {
|
||||
platform = '';
|
||||
}
|
||||
if (sort === 'T') {
|
||||
sort = '';
|
||||
}
|
||||
|
||||
const selectedCategories = { 类型: category } as any;
|
||||
if (format) {
|
||||
selectedCategories['形式'] = format;
|
||||
}
|
||||
if (region) {
|
||||
selectedCategories['地区'] = region;
|
||||
}
|
||||
|
||||
const tags = [] as Array<string>;
|
||||
if (category) {
|
||||
tags.push(category);
|
||||
}
|
||||
if (!category && format) {
|
||||
tags.push(format);
|
||||
}
|
||||
if (label) {
|
||||
tags.push(label);
|
||||
}
|
||||
if (region) {
|
||||
tags.push(region);
|
||||
}
|
||||
if (year) {
|
||||
tags.push(year);
|
||||
}
|
||||
if (platform) {
|
||||
tags.push(platform);
|
||||
}
|
||||
|
||||
const baseUrl = useTencentCDN
|
||||
? `https://m.douban.cmliussss.net/rexxar/api/v2/${kind}/recommend`
|
||||
: useAliCDN
|
||||
? `https://m.douban.cmliussss.com/rexxar/api/v2/${kind}/recommend`
|
||||
: `https://m.douban.com/rexxar/api/v2/${kind}/recommend`;
|
||||
const reqParams = new URLSearchParams();
|
||||
reqParams.append('refresh', '0');
|
||||
reqParams.append('start', pageStart.toString());
|
||||
reqParams.append('count', pageLimit.toString());
|
||||
reqParams.append('selected_categories', JSON.stringify(selectedCategories));
|
||||
reqParams.append('uncollect', 'false');
|
||||
reqParams.append('score_range', '0,10');
|
||||
reqParams.append('tags', tags.join(','));
|
||||
if (sort) {
|
||||
reqParams.append('sort', sort);
|
||||
}
|
||||
const target = `${baseUrl}?${reqParams.toString()}`;
|
||||
console.log(target);
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
target,
|
||||
useTencentCDN || useAliCDN ? '' : proxyUrl
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const doubanData: DoubanRecommendApiResponse = await response.json();
|
||||
const list: DoubanItem[] = doubanData.items
|
||||
.filter((item) => item.type == 'movie' || item.type == 'tv')
|
||||
.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.year,
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`获取豆瓣推荐数据失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 通用的豆瓣数据获取函数
|
||||
* @param url 请求的URL
|
||||
* @returns Promise<T> 返回指定类型的数据
|
||||
*/
|
||||
export async function fetchDoubanData<T>(url: string): Promise<T> {
|
||||
// 添加超时控制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
// 设置请求选项,包括信号和头部
|
||||
const fetchOptions = {
|
||||
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, */*',
|
||||
Origin: 'https://movie.douban.com',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
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[] = [];
|
||||
let titles: string[] = [];
|
||||
|
||||
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||
if (item.vod_play_url) {
|
||||
// 先用 $$$ 分割
|
||||
const vod_play_url_array = item.vod_play_url.split('$$$');
|
||||
// 分集之间#分割,标题和播放链接 $ 分割
|
||||
vod_play_url_array.forEach((url: string) => {
|
||||
const matchEpisodes: string[] = [];
|
||||
const matchTitles: string[] = [];
|
||||
const title_url_array = url.split('#');
|
||||
title_url_array.forEach((title_url: string) => {
|
||||
const episode_title_url = title_url.split('$');
|
||||
if (
|
||||
episode_title_url.length === 2 &&
|
||||
episode_title_url[1].endsWith('.m3u8')
|
||||
) {
|
||||
matchTitles.push(episode_title_url[0]);
|
||||
matchEpisodes.push(episode_title_url[1]);
|
||||
}
|
||||
});
|
||||
if (matchEpisodes.length > episodes.length) {
|
||||
episodes = matchEpisodes;
|
||||
titles = matchTitles;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.vod_id.toString(),
|
||||
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||
poster: item.vod_pic,
|
||||
episodes,
|
||||
episodes_titles: titles,
|
||||
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[] = [];
|
||||
let titles: string[] = [];
|
||||
|
||||
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||
if (item.vod_play_url) {
|
||||
// 先用 $$$ 分割
|
||||
const vod_play_url_array = item.vod_play_url.split('$$$');
|
||||
// 分集之间#分割,标题和播放链接 $ 分割
|
||||
vod_play_url_array.forEach((url: string) => {
|
||||
const matchEpisodes: string[] = [];
|
||||
const matchTitles: string[] = [];
|
||||
const title_url_array = url.split('#');
|
||||
title_url_array.forEach((title_url: string) => {
|
||||
const episode_title_url = title_url.split('$');
|
||||
if (
|
||||
episode_title_url.length === 2 &&
|
||||
episode_title_url[1].endsWith('.m3u8')
|
||||
) {
|
||||
matchTitles.push(episode_title_url[0]);
|
||||
matchEpisodes.push(episode_title_url[1]);
|
||||
}
|
||||
});
|
||||
if (matchEpisodes.length > episodes.length) {
|
||||
episodes = matchEpisodes;
|
||||
titles = matchTitles;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.vod_id.toString(),
|
||||
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||
poster: item.vod_pic,
|
||||
episodes,
|
||||
episodes_titles: titles,
|
||||
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[] = [];
|
||||
let titles: string[] = [];
|
||||
|
||||
// 处理播放源拆分
|
||||
if (videoDetail.vod_play_url) {
|
||||
// 先用 $$$ 分割
|
||||
const vod_play_url_array = videoDetail.vod_play_url.split('$$$');
|
||||
// 分集之间#分割,标题和播放链接 $ 分割
|
||||
vod_play_url_array.forEach((url: string) => {
|
||||
const matchEpisodes: string[] = [];
|
||||
const matchTitles: string[] = [];
|
||||
const title_url_array = url.split('#');
|
||||
title_url_array.forEach((title_url: string) => {
|
||||
const episode_title_url = title_url.split('$');
|
||||
if (
|
||||
episode_title_url.length === 2 &&
|
||||
episode_title_url[1].endsWith('.m3u8')
|
||||
) {
|
||||
matchTitles.push(episode_title_url[0]);
|
||||
matchEpisodes.push(episode_title_url[1]);
|
||||
}
|
||||
});
|
||||
if (matchEpisodes.length > episodes.length) {
|
||||
episodes = matchEpisodes;
|
||||
titles = matchTitles;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 如果播放源为空,则尝试从内容中解析 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,
|
||||
episodes_titles: titles,
|
||||
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;
|
||||
});
|
||||
|
||||
// 根据 matches 数量生成剧集标题
|
||||
const episodes_titles = Array.from({ length: matches.length }, (_, i) =>
|
||||
(i + 1).toString()
|
||||
);
|
||||
|
||||
// 提取标题
|
||||
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,
|
||||
episodes_titles,
|
||||
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,434 @@
|
||||
/* 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, SkipConfig } 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));
|
||||
}
|
||||
|
||||
// 删除跳过片头片尾配置
|
||||
const skipConfigPattern = `u:${userName}:skip:*`;
|
||||
const skipConfigKeys = await withRetry(() =>
|
||||
this.client.keys(skipConfigPattern)
|
||||
);
|
||||
if (skipConfigKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(skipConfigKeys));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 跳过片头片尾配置 ----------
|
||||
private skipConfigKey(user: string, source: string, id: string) {
|
||||
return `u:${user}:skip:${source}+${id}`;
|
||||
}
|
||||
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.skipConfigKey(userName, source, id))
|
||||
);
|
||||
return val ? (JSON.parse(val) as SkipConfig) : null;
|
||||
}
|
||||
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(
|
||||
this.skipConfigKey(userName, source, id),
|
||||
JSON.stringify(config)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.del(this.skipConfigKey(userName, source, id))
|
||||
);
|
||||
}
|
||||
|
||||
async getAllSkipConfigs(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: SkipConfig }> {
|
||||
const pattern = `u:${userName}:skip:*`;
|
||||
const keys = await withRetry(() => this.client.keys(pattern));
|
||||
|
||||
if (keys.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const configs: { [key: string]: SkipConfig } = {};
|
||||
|
||||
// 批量获取所有配置
|
||||
const values = await withRetry(() => this.client.mGet(keys));
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
const value = values[index];
|
||||
if (value) {
|
||||
// 从key中提取source+id
|
||||
const match = key.match(/^u:.+?:skip:(.+)$/);
|
||||
if (match) {
|
||||
const sourceAndId = match[1];
|
||||
configs[sourceAndId] = JSON.parse(value as string) as SkipConfig;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 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,120 @@
|
||||
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>;
|
||||
|
||||
// 跳过片头片尾配置相关
|
||||
getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null>;
|
||||
setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void>;
|
||||
deleteSkipConfig(userName: string, source: string, id: string): Promise<void>;
|
||||
getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }>;
|
||||
}
|
||||
|
||||
// 搜索结果数据结构
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes: string[];
|
||||
episodes_titles: 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[];
|
||||
}
|
||||
|
||||
// 跳过片头片尾配置数据结构
|
||||
export interface SkipConfig {
|
||||
enable: boolean; // 是否启用跳过片头片尾
|
||||
intro_time: number; // 片头时间(秒)
|
||||
outro_time: number; // 片尾时间(秒)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
/* 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, SkipConfig } 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));
|
||||
}
|
||||
|
||||
// 删除跳过片头片尾配置
|
||||
const skipConfigPattern = `u:${userName}:skip:*`;
|
||||
const skipConfigKeys = await withRetry(() =>
|
||||
this.client.keys(skipConfigPattern)
|
||||
);
|
||||
if (skipConfigKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...skipConfigKeys));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
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));
|
||||
}
|
||||
|
||||
// ---------- 跳过片头片尾配置 ----------
|
||||
private skipConfigKey(user: string, source: string, id: string) {
|
||||
return `u:${user}:skip:${source}+${id}`;
|
||||
}
|
||||
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<SkipConfig | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.skipConfigKey(userName, source, id))
|
||||
);
|
||||
return val ? (val as SkipConfig) : null;
|
||||
}
|
||||
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string,
|
||||
config: SkipConfig
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.skipConfigKey(userName, source, id), config)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSkipConfig(
|
||||
userName: string,
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.del(this.skipConfigKey(userName, source, id))
|
||||
);
|
||||
}
|
||||
|
||||
async getAllSkipConfigs(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: SkipConfig }> {
|
||||
const pattern = `u:${userName}:skip:*`;
|
||||
const keys = await withRetry(() => this.client.keys(pattern));
|
||||
|
||||
if (keys.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const configs: { [key: string]: SkipConfig } = {};
|
||||
|
||||
// 批量获取所有配置
|
||||
const values = await withRetry(() => this.client.mget(keys));
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
const value = values[index];
|
||||
if (value) {
|
||||
// 从key中提取source+id
|
||||
const match = key.match(/^u:.+?:skip:(.+)$/);
|
||||
if (match) {
|
||||
const sourceAndId = match[1];
|
||||
configs[sourceAndId] = value as SkipConfig;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 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,233 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
import he from 'he';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
function getDoubanImageProxyConfig(): {
|
||||
proxyType:
|
||||
| 'direct'
|
||||
| 'server'
|
||||
| 'img3'
|
||||
| 'cmliussss-cdn-tencent'
|
||||
| 'cmliussss-cdn-ali'
|
||||
| 'custom';
|
||||
proxyUrl: string;
|
||||
} {
|
||||
const doubanImageProxyType =
|
||||
localStorage.getItem('doubanImageProxyType') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE ||
|
||||
'direct';
|
||||
const doubanImageProxy =
|
||||
localStorage.getItem('doubanImageProxyUrl') ||
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
|
||||
'';
|
||||
return {
|
||||
proxyType: doubanImageProxyType,
|
||||
proxyUrl: doubanImageProxy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片 URL,如果设置了图片代理则使用代理
|
||||
*/
|
||||
export function processImageUrl(originalUrl: string): string {
|
||||
if (!originalUrl) return originalUrl;
|
||||
|
||||
// 仅处理豆瓣图片代理
|
||||
if (!originalUrl.includes('doubanio.com')) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
||||
switch (proxyType) {
|
||||
case 'server':
|
||||
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
|
||||
case 'img3':
|
||||
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
|
||||
case 'cmliussss-cdn-tencent':
|
||||
return originalUrl.replace(
|
||||
/img\d+\.doubanio\.com/g,
|
||||
'img.doubanio.cmliussss.net'
|
||||
);
|
||||
case 'cmliussss-cdn-ali':
|
||||
return originalUrl.replace(
|
||||
/img\d+\.doubanio\.com/g,
|
||||
'img.doubanio.cmliussss.com'
|
||||
);
|
||||
case 'custom':
|
||||
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
|
||||
case 'direct':
|
||||
default:
|
||||
return originalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从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)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanHtmlTags(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
const cleanedText = text
|
||||
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
|
||||
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
|
||||
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
||||
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
|
||||
.trim(); // 去掉首尾空格
|
||||
|
||||
// 使用 he 库解码 HTML 实体
|
||||
return he.decode(cleanedText);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
const CURRENT_VERSION = '1.1.1';
|
||||
|
||||
// 版本检查结果枚举
|
||||
export enum UpdateStatus {
|
||||
HAS_UPDATE = 'has_update', // 有新版本
|
||||
NO_UPDATE = 'no_update', // 无新版本
|
||||
FETCH_FAILED = 'fetch_failed', // 获取失败
|
||||
}
|
||||
|
||||
// 远程版本检查URL配置
|
||||
const VERSION_CHECK_URLS = [
|
||||
'https://raw.githubusercontent.com/LunaTechLab/MoonTV/main/VERSION.txt',
|
||||
'https://cdn.jsdelivr.net/gh/LunaTechLab/moontv/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 timestamp = Date.now();
|
||||
const urlWithTimestamp = url.includes('?')
|
||||
? `${url}&_t=${timestamp}`
|
||||
: `${url}?_t=${timestamp}`;
|
||||
|
||||
const response = await fetch(urlWithTimestamp, {
|
||||
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 {
|
||||
// 如果版本号相同,无需更新
|
||||
if (remoteVersion === CURRENT_VERSION) {
|
||||
return UpdateStatus.NO_UPDATE;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析版本号为数字数组 [X, Y, Z]
|
||||
const currentParts = CURRENT_VERSION.split('.').map((part) => {
|
||||
const num = parseInt(part, 10);
|
||||
if (isNaN(num) || num < 0) {
|
||||
throw new Error(`无效的版本号格式: ${CURRENT_VERSION}`);
|
||||
}
|
||||
return num;
|
||||
});
|
||||
|
||||
const remoteParts = remoteVersion.split('.').map((part) => {
|
||||
const num = parseInt(part, 10);
|
||||
if (isNaN(num) || num < 0) {
|
||||
throw new Error(`无效的版本号格式: ${remoteVersion}`);
|
||||
}
|
||||
return num;
|
||||
});
|
||||
|
||||
// 标准化版本号到3个部分
|
||||
const normalizeVersion = (parts: number[]) => {
|
||||
if (parts.length >= 3) {
|
||||
return parts.slice(0, 3); // 取前三个元素
|
||||
} else {
|
||||
// 不足3个的部分补0
|
||||
const normalized = [...parts];
|
||||
while (normalized.length < 3) {
|
||||
normalized.push(0);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedCurrent = normalizeVersion(currentParts);
|
||||
const normalizedRemote = normalizeVersion(remoteParts);
|
||||
|
||||
// 逐级比较版本号
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (normalizedRemote[i] > normalizedCurrent[i]) {
|
||||
return UpdateStatus.HAS_UPDATE;
|
||||
} else if (normalizedRemote[i] < normalizedCurrent[i]) {
|
||||
return UpdateStatus.NO_UPDATE;
|
||||
}
|
||||
// 如果当前级别相等,继续比较下一级
|
||||
}
|
||||
|
||||
// 所有级别都相等,无需更新
|
||||
return UpdateStatus.NO_UPDATE;
|
||||
} catch (error) {
|
||||
console.error('版本号比较失败:', error);
|
||||
// 如果版本号格式无效,回退到字符串比较
|
||||
return remoteVersion !== CURRENT_VERSION
|
||||
? UpdateStatus.HAS_UPDATE
|
||||
: UpdateStatus.NO_UPDATE;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出当前版本号供其他地方使用
|
||||
export { compareVersions, CURRENT_VERSION };
|
||||
@@ -0,0 +1,22 @@
|
||||
export const yellowWords = [
|
||||
'伦理片',
|
||||
'福利',
|
||||
'里番动漫',
|
||||
'门事件',
|
||||
'萝莉少女',
|
||||
'制服诱惑',
|
||||
'国产传媒',
|
||||
'cosplay',
|
||||
'黑丝诱惑',
|
||||
'无码',
|
||||
'日本无码',
|
||||
'有码',
|
||||
'日本有码',
|
||||
'SWAG',
|
||||
'网红主播',
|
||||
'色情片',
|
||||
'同性片',
|
||||
'福利视频',
|
||||
'福利片',
|
||||
'写真热舞',
|
||||
];
|
||||
Reference in New Issue
Block a user