添加跳过配置功能,包括数据库和API支持,更新播放器以处理跳过片段
This commit is contained in:
@@ -49,6 +49,18 @@ CREATE TABLE IF NOT EXISTS admin_config (
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skip_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
segments TEXT NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
UNIQUE(username, key)
|
||||
);
|
||||
|
||||
-- 基本索引
|
||||
CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { EpisodeSkipConfig } from '@/lib/types';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, key, config, username } = body;
|
||||
|
||||
// 验证请求参数
|
||||
if (!action) {
|
||||
return NextResponse.json({ error: '缺少操作类型' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取认证信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
|
||||
// 如果是直接传入的认证信息(客户端模式),使用传入的信息
|
||||
const finalUsername = username || authInfo?.username;
|
||||
|
||||
if (!finalUsername) {
|
||||
return NextResponse.json({ error: '用户未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 创建存储实例
|
||||
const storage = getStorage();
|
||||
|
||||
switch (action) {
|
||||
case 'get': {
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: '缺少配置键' }, { status: 400 });
|
||||
}
|
||||
|
||||
const skipConfig = await storage.getSkipConfig(finalUsername, key);
|
||||
return NextResponse.json({ config: skipConfig });
|
||||
}
|
||||
|
||||
case 'set': {
|
||||
if (!key || !config) {
|
||||
return NextResponse.json({ error: '缺少配置键或配置数据' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证配置数据结构
|
||||
if (!config.source || !config.id || !config.title || !Array.isArray(config.segments)) {
|
||||
return NextResponse.json({ error: '配置数据格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证片段数据
|
||||
for (const segment of config.segments) {
|
||||
if (
|
||||
typeof segment.start !== 'number' ||
|
||||
typeof segment.end !== 'number' ||
|
||||
segment.start >= segment.end ||
|
||||
!['opening', 'ending'].includes(segment.type)
|
||||
) {
|
||||
return NextResponse.json({ error: '片段数据格式错误' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
await storage.setSkipConfig(finalUsername, key, config as EpisodeSkipConfig);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
case 'getAll': {
|
||||
const allConfigs = await storage.getAllSkipConfigs(finalUsername);
|
||||
return NextResponse.json({ configs: allConfigs });
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: '缺少配置键' }, { status: 400 });
|
||||
}
|
||||
|
||||
await storage.deleteSkipConfig(finalUsername, key);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: '不支持的操作类型' }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('跳过配置 API 错误:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
|
||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import SkipController from '@/components/SkipController';
|
||||
|
||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||
declare global {
|
||||
@@ -163,6 +164,10 @@ function PlayPageClient() {
|
||||
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSaveTimeRef = useRef<number>(0);
|
||||
|
||||
// 播放器时间状态(用于跳过功能)
|
||||
const [currentPlayTime, setCurrentPlayTime] = useState<number>(0);
|
||||
const [videoDuration, setVideoDuration] = useState<number>(0);
|
||||
|
||||
const artPlayerRef = useRef<any>(null);
|
||||
const artRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -1200,12 +1205,27 @@ function PlayPageClient() {
|
||||
// 监听播放器事件
|
||||
artPlayerRef.current.on('ready', () => {
|
||||
setError(null);
|
||||
// 更新视频时长
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
setVideoDuration(duration);
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('video:volumechange', () => {
|
||||
lastVolumeRef.current = artPlayerRef.current.volume;
|
||||
});
|
||||
|
||||
// 监听播放时间更新(用于跳过功能)
|
||||
artPlayerRef.current.on('video:timeupdate', () => {
|
||||
const currentTime = artPlayerRef.current.currentTime || 0;
|
||||
setCurrentPlayTime(currentTime);
|
||||
|
||||
// 同时更新时长(防止ready事件中获取不到)
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
if (duration > 0 && videoDuration !== duration) {
|
||||
setVideoDuration(duration);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听视频可播放事件,这时恢复播放进度更可靠
|
||||
artPlayerRef.current.on('video:canplay', () => {
|
||||
// 若存在需要恢复的播放进度,则跳转
|
||||
@@ -1531,6 +1551,18 @@ function PlayPageClient() {
|
||||
className='bg-black w-full h-full rounded-xl overflow-hidden shadow-lg'
|
||||
></div>
|
||||
|
||||
{/* 跳过片头片尾控制器 */}
|
||||
{currentSource && currentId && videoTitle && (
|
||||
<SkipController
|
||||
source={currentSource}
|
||||
id={currentId}
|
||||
title={videoTitle}
|
||||
artPlayerRef={artPlayerRef}
|
||||
currentTime={currentPlayTime}
|
||||
_duration={videoDuration}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 换源加载蒙层 */}
|
||||
{isVideoLoading && (
|
||||
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[500] transition-all duration-300'>
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
deleteSkipConfig,
|
||||
EpisodeSkipConfig,
|
||||
getSkipConfig,
|
||||
saveSkipConfig,
|
||||
SkipSegment,
|
||||
} from '@/lib/db.client';
|
||||
|
||||
interface SkipControllerProps {
|
||||
source: string;
|
||||
id: string;
|
||||
title: string;
|
||||
artPlayerRef: React.MutableRefObject<any>;
|
||||
currentTime?: number;
|
||||
_duration?: number; // 使用下划线前缀标识未使用的参数
|
||||
}
|
||||
|
||||
export default function SkipController({
|
||||
source,
|
||||
id,
|
||||
title,
|
||||
artPlayerRef,
|
||||
currentTime = 0,
|
||||
_duration = 0,
|
||||
}: SkipControllerProps) {
|
||||
const [skipConfig, setSkipConfig] = useState<EpisodeSkipConfig | null>(null);
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
const [currentSkipSegment, setCurrentSkipSegment] = useState<SkipSegment | null>(null);
|
||||
const [isSettingMode, setIsSettingMode] = useState(false);
|
||||
const [newSegment, setNewSegment] = useState<Partial<SkipSegment>>({});
|
||||
|
||||
const lastSkipTimeRef = useRef<number>(0);
|
||||
const skipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 加载跳过配置
|
||||
const loadSkipConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await getSkipConfig(source, id);
|
||||
setSkipConfig(config);
|
||||
} catch (err) {
|
||||
console.error('加载跳过配置失败:', err);
|
||||
}
|
||||
}, [source, id]);
|
||||
|
||||
// 检查当前播放时间是否在跳过区间内
|
||||
const checkSkipSegment = useCallback(
|
||||
(time: number) => {
|
||||
if (!skipConfig?.segments?.length) return;
|
||||
|
||||
const currentSegment = skipConfig.segments.find(
|
||||
(segment) => time >= segment.start && time <= segment.end
|
||||
);
|
||||
|
||||
if (currentSegment && currentSegment !== currentSkipSegment) {
|
||||
setCurrentSkipSegment(currentSegment);
|
||||
setShowSkipButton(true);
|
||||
|
||||
// 自动隐藏跳过按钮
|
||||
if (skipTimeoutRef.current) {
|
||||
clearTimeout(skipTimeoutRef.current);
|
||||
}
|
||||
skipTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipButton(false);
|
||||
setCurrentSkipSegment(null);
|
||||
}, 8000); // 8秒后自动隐藏
|
||||
} else if (!currentSegment && currentSkipSegment) {
|
||||
setCurrentSkipSegment(null);
|
||||
setShowSkipButton(false);
|
||||
if (skipTimeoutRef.current) {
|
||||
clearTimeout(skipTimeoutRef.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
[skipConfig, currentSkipSegment]
|
||||
);
|
||||
|
||||
// 执行跳过
|
||||
const handleSkip = useCallback(() => {
|
||||
if (!currentSkipSegment || !artPlayerRef.current) return;
|
||||
|
||||
const targetTime = currentSkipSegment.end + 1; // 跳到片段结束后1秒
|
||||
artPlayerRef.current.currentTime = targetTime;
|
||||
lastSkipTimeRef.current = Date.now();
|
||||
|
||||
setShowSkipButton(false);
|
||||
setCurrentSkipSegment(null);
|
||||
|
||||
if (skipTimeoutRef.current) {
|
||||
clearTimeout(skipTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 显示跳过提示
|
||||
if (artPlayerRef.current.notice) {
|
||||
const segmentName = currentSkipSegment.type === 'opening' ? '片头' : '片尾';
|
||||
artPlayerRef.current.notice.show = `已跳过${segmentName}`;
|
||||
}
|
||||
}, [currentSkipSegment, artPlayerRef]);
|
||||
|
||||
// 保存新的跳过片段
|
||||
const handleSaveSegment = useCallback(async () => {
|
||||
if (!newSegment.start || !newSegment.end || !newSegment.type) {
|
||||
alert('请填写完整的跳过片段信息');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newSegment.start >= newSegment.end) {
|
||||
alert('开始时间必须小于结束时间');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const segment: SkipSegment = {
|
||||
start: newSegment.start,
|
||||
end: newSegment.end,
|
||||
type: newSegment.type as 'opening' | 'ending',
|
||||
title: newSegment.title || (newSegment.type === 'opening' ? '片头' : '片尾'),
|
||||
};
|
||||
|
||||
const updatedConfig: EpisodeSkipConfig = {
|
||||
source,
|
||||
id,
|
||||
title,
|
||||
segments: skipConfig?.segments ? [...skipConfig.segments, segment] : [segment],
|
||||
updated_time: Date.now(),
|
||||
};
|
||||
|
||||
await saveSkipConfig(source, id, updatedConfig);
|
||||
setSkipConfig(updatedConfig);
|
||||
setIsSettingMode(false);
|
||||
setNewSegment({});
|
||||
|
||||
alert('跳过片段已保存');
|
||||
} catch (err) {
|
||||
console.error('保存跳过片段失败:', err);
|
||||
alert('保存失败,请重试');
|
||||
}
|
||||
}, [newSegment, skipConfig, source, id, title]);
|
||||
|
||||
// 删除跳过片段
|
||||
const handleDeleteSegment = useCallback(
|
||||
async (index: number) => {
|
||||
if (!skipConfig?.segments) return;
|
||||
|
||||
try {
|
||||
const updatedSegments = skipConfig.segments.filter((_, i) => i !== index);
|
||||
|
||||
if (updatedSegments.length === 0) {
|
||||
// 如果没有片段了,删除整个配置
|
||||
await deleteSkipConfig(source, id);
|
||||
setSkipConfig(null);
|
||||
} else {
|
||||
// 更新配置
|
||||
const updatedConfig: EpisodeSkipConfig = {
|
||||
...skipConfig,
|
||||
segments: updatedSegments,
|
||||
updated_time: Date.now(),
|
||||
};
|
||||
await saveSkipConfig(source, id, updatedConfig);
|
||||
setSkipConfig(updatedConfig);
|
||||
}
|
||||
|
||||
alert('跳过片段已删除');
|
||||
} catch (err) {
|
||||
console.error('删除跳过片段失败:', err);
|
||||
alert('删除失败,请重试');
|
||||
}
|
||||
},
|
||||
[skipConfig, source, id]
|
||||
);
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 初始化加载配置
|
||||
useEffect(() => {
|
||||
loadSkipConfig();
|
||||
}, [loadSkipConfig]);
|
||||
|
||||
// 监听播放时间变化
|
||||
useEffect(() => {
|
||||
if (currentTime > 0) {
|
||||
checkSkipSegment(currentTime);
|
||||
}
|
||||
}, [currentTime, checkSkipSegment]);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (skipTimeoutRef.current) {
|
||||
clearTimeout(skipTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="skip-controller">
|
||||
{/* 跳过按钮 */}
|
||||
{showSkipButton && currentSkipSegment && (
|
||||
<div className="fixed top-20 right-4 z-50 bg-black/80 text-white px-4 py-2 rounded-lg backdrop-blur-sm border border-white/20 shadow-lg animate-fade-in">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm">
|
||||
{currentSkipSegment.type === 'opening' ? '检测到片头' : '检测到片尾'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="px-3 py-1 bg-green-600 hover:bg-green-700 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 设置模式面板 */}
|
||||
{isSettingMode && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
||||
添加跳过片段
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
类型
|
||||
</label>
|
||||
<select
|
||||
value={newSegment.type || ''}
|
||||
onChange={(e) => setNewSegment({ ...newSegment, type: e.target.value as 'opening' | 'ending' })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">选择类型</option>
|
||||
<option value="opening">片头</option>
|
||||
<option value="ending">片尾</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
开始时间 (秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newSegment.start || ''}
|
||||
onChange={(e) => setNewSegment({ ...newSegment, start: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="例如: 0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
结束时间 (秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newSegment.end || ''}
|
||||
onChange={(e) => setNewSegment({ ...newSegment, end: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="例如: 90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
描述 (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSegment.title || ''}
|
||||
onChange={(e) => setNewSegment({ ...newSegment, title: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
placeholder="例如: 片头曲"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
当前播放时间: {formatTime(currentTime)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<button
|
||||
onClick={handleSaveSegment}
|
||||
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded font-medium transition-colors"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSettingMode(false);
|
||||
setNewSegment({});
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded font-medium transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 管理已有片段 */}
|
||||
{skipConfig && skipConfig.segments && skipConfig.segments.length > 0 && !isSettingMode && (
|
||||
<div className="mt-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<h4 className="font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||
已设置的跳过片段:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{skipConfig.segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-white dark:bg-gray-700 rounded text-sm"
|
||||
>
|
||||
<span className="text-gray-900 dark:text-gray-100">
|
||||
{segment.type === 'opening' ? '片头' : '片尾'}: {formatTime(segment.start)} - {formatTime(segment.end)}
|
||||
{segment.title && ` (${segment.title})`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteSegment(index)}
|
||||
className="px-2 py-1 bg-red-500 hover:bg-red-600 text-white rounded text-xs transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 导出跳过控制器的设置按钮组件
|
||||
export function SkipSettingsButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center space-x-1 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded text-sm text-gray-700 dark:text-gray-300 transition-colors"
|
||||
title="设置跳过片头片尾"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>跳过设置</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+101
-1
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord } from './types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
@@ -473,4 +473,104 @@ export class D1Storage implements IStorage {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过配置相关
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<EpisodeSkipConfig | null> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT * FROM skip_configs WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.first<any>();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
source: result.source,
|
||||
id: result.video_id,
|
||||
title: result.title,
|
||||
segments: JSON.parse(result.segments),
|
||||
updated_time: result.updated_time,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to get skip config:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
key: string,
|
||||
config: EpisodeSkipConfig
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare(
|
||||
`
|
||||
INSERT OR REPLACE INTO skip_configs
|
||||
(username, key, source, video_id, title, segments, updated_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
config.source,
|
||||
config.id,
|
||||
config.title,
|
||||
JSON.stringify(config.segments),
|
||||
config.updated_time
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to set skip config:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllSkipConfigs(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: EpisodeSkipConfig }> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT * FROM skip_configs WHERE username = ?')
|
||||
.bind(userName)
|
||||
.all<any>();
|
||||
|
||||
const configs: { [key: string]: EpisodeSkipConfig } = {};
|
||||
|
||||
for (const row of result.results) {
|
||||
configs[row.key] = {
|
||||
source: row.source,
|
||||
id: row.video_id,
|
||||
title: row.title,
|
||||
segments: JSON.parse(row.segments),
|
||||
updated_time: row.updated_time,
|
||||
};
|
||||
}
|
||||
|
||||
return configs;
|
||||
} catch (err) {
|
||||
console.error('Failed to get all skip configs:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSkipConfig(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare('DELETE FROM skip_configs WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete skip config:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,22 @@ export interface Favorite {
|
||||
search_title?: string;
|
||||
}
|
||||
|
||||
// ---- 片头片尾跳过配置类型 ----
|
||||
export interface SkipSegment {
|
||||
start: number; // 开始时间(秒)
|
||||
end: number; // 结束时间(秒)
|
||||
type: 'opening' | 'ending'; // 片头或片尾
|
||||
title?: string; // 可选的描述
|
||||
}
|
||||
|
||||
export interface EpisodeSkipConfig {
|
||||
source: string; // 资源站标识
|
||||
id: string; // 剧集ID
|
||||
title: string; // 剧集标题
|
||||
segments: SkipSegment[]; // 跳过片段列表
|
||||
updated_time: number; // 最后更新时间
|
||||
}
|
||||
|
||||
// ---- 缓存数据结构 ----
|
||||
interface CacheData<T> {
|
||||
data: T;
|
||||
@@ -52,6 +68,7 @@ interface UserCacheStore {
|
||||
playRecords?: CacheData<Record<string, PlayRecord>>;
|
||||
favorites?: CacheData<Record<string, Favorite>>;
|
||||
searchHistory?: CacheData<string[]>;
|
||||
skipConfigs?: CacheData<Record<string, EpisodeSkipConfig>>;
|
||||
}
|
||||
|
||||
// ---- 常量 ----
|
||||
@@ -59,6 +76,7 @@ interface UserCacheStore {
|
||||
const PLAY_RECORDS_KEY = 'katelyatv_play_records';
|
||||
const FAVORITES_KEY = 'katelyatv_favorites';
|
||||
const SEARCH_HISTORY_KEY = 'katelyatv_search_history';
|
||||
const SKIP_CONFIGS_KEY = 'katelyatv_skip_configs';
|
||||
const LEGACY_PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||
const LEGACY_FAVORITES_KEY = 'moontv_favorites';
|
||||
const LEGACY_SEARCH_HISTORY_KEY = 'moontv_search_history';
|
||||
@@ -253,6 +271,35 @@ class HybridCacheManager {
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的跳过配置
|
||||
*/
|
||||
getCachedSkipConfigs(): Record<string, EpisodeSkipConfig> | 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, EpisodeSkipConfig>): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.skipConfigs = this.createCacheData(data);
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定用户的所有缓存
|
||||
*/
|
||||
@@ -1255,3 +1302,244 @@ export async function preloadUserData(): Promise<void> {
|
||||
console.warn('预加载用户数据失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 片头片尾跳过配置管理 ----------------
|
||||
|
||||
/**
|
||||
* 生成跳过配置的存储 key
|
||||
*/
|
||||
export function generateSkipConfigKey(source: string, id: string): string {
|
||||
return `${source}_${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个跳过配置
|
||||
*/
|
||||
export async function getSkipConfig(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<EpisodeSkipConfig | null> {
|
||||
try {
|
||||
const key = generateSkipConfigKey(source, id);
|
||||
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
// localStorage 模式
|
||||
const allConfigs = JSON.parse(
|
||||
localStorage.getItem(SKIP_CONFIGS_KEY) || '{}'
|
||||
);
|
||||
return allConfigs[key] || null;
|
||||
} else {
|
||||
// 数据库模式:先查缓存
|
||||
const cacheManager = HybridCacheManager.getInstance();
|
||||
const cachedConfigs = cacheManager.getCachedSkipConfigs();
|
||||
|
||||
if (cachedConfigs && cachedConfigs[key]) {
|
||||
return cachedConfigs[key];
|
||||
}
|
||||
|
||||
// 缓存未命中,从服务器获取
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
if (!authInfo?.username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/skip-configs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'get',
|
||||
key,
|
||||
username: authInfo.username,
|
||||
signature: authInfo.signature,
|
||||
timestamp: authInfo.timestamp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const config = data.config;
|
||||
|
||||
// 更新缓存
|
||||
if (config) {
|
||||
const allConfigs = cachedConfigs || {};
|
||||
allConfigs[key] = config;
|
||||
cacheManager.cacheSkipConfigs(allConfigs);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取跳过配置失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存跳过配置
|
||||
*/
|
||||
export async function saveSkipConfig(
|
||||
source: string,
|
||||
id: string,
|
||||
config: EpisodeSkipConfig
|
||||
): Promise<void> {
|
||||
try {
|
||||
const key = generateSkipConfigKey(source, id);
|
||||
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
// localStorage 模式
|
||||
const allConfigs = JSON.parse(
|
||||
localStorage.getItem(SKIP_CONFIGS_KEY) || '{}'
|
||||
);
|
||||
allConfigs[key] = config;
|
||||
localStorage.setItem(SKIP_CONFIGS_KEY, JSON.stringify(allConfigs));
|
||||
} else {
|
||||
// 数据库模式
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
if (!authInfo?.username) {
|
||||
throw new Error('用户未登录');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/skip-configs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'set',
|
||||
key,
|
||||
config,
|
||||
username: authInfo.username,
|
||||
signature: authInfo.signature,
|
||||
timestamp: authInfo.timestamp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('保存跳过配置失败');
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
const cacheManager = HybridCacheManager.getInstance();
|
||||
const cachedConfigs = cacheManager.getCachedSkipConfigs() || {};
|
||||
cachedConfigs[key] = config;
|
||||
cacheManager.cacheSkipConfigs(cachedConfigs);
|
||||
}
|
||||
|
||||
console.log('跳过配置已保存:', key);
|
||||
} catch (err) {
|
||||
console.error('保存跳过配置失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有跳过配置
|
||||
*/
|
||||
export async function getAllSkipConfigs(): Promise<Record<string, EpisodeSkipConfig>> {
|
||||
try {
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
// localStorage 模式
|
||||
return JSON.parse(localStorage.getItem(SKIP_CONFIGS_KEY) || '{}');
|
||||
} else {
|
||||
// 数据库模式:先查缓存
|
||||
const cacheManager = HybridCacheManager.getInstance();
|
||||
const cachedConfigs = cacheManager.getCachedSkipConfigs();
|
||||
|
||||
if (cachedConfigs) {
|
||||
return cachedConfigs;
|
||||
}
|
||||
|
||||
// 缓存未命中,从服务器获取
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
if (!authInfo?.username) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const response = await fetch('/api/skip-configs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'getAll',
|
||||
username: authInfo.username,
|
||||
signature: authInfo.signature,
|
||||
timestamp: authInfo.timestamp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const configs = data.configs || {};
|
||||
|
||||
// 更新缓存
|
||||
cacheManager.cacheSkipConfigs(configs);
|
||||
|
||||
return configs;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取所有跳过配置失败:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除跳过配置
|
||||
*/
|
||||
export async function deleteSkipConfig(source: string, id: string): Promise<void> {
|
||||
try {
|
||||
const key = generateSkipConfigKey(source, id);
|
||||
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
// localStorage 模式
|
||||
const allConfigs = JSON.parse(
|
||||
localStorage.getItem(SKIP_CONFIGS_KEY) || '{}'
|
||||
);
|
||||
delete allConfigs[key];
|
||||
localStorage.setItem(SKIP_CONFIGS_KEY, JSON.stringify(allConfigs));
|
||||
} else {
|
||||
// 数据库模式
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
if (!authInfo?.username) {
|
||||
throw new Error('用户未登录');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/skip-configs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'delete',
|
||||
key,
|
||||
username: authInfo.username,
|
||||
signature: authInfo.signature,
|
||||
timestamp: authInfo.timestamp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除跳过配置失败');
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
const cacheManager = HybridCacheManager.getInstance();
|
||||
const cachedConfigs = cacheManager.getCachedSkipConfigs() || {};
|
||||
delete cachedConfigs[key];
|
||||
cacheManager.cacheSkipConfigs(cachedConfigs);
|
||||
}
|
||||
|
||||
console.log('跳过配置已删除:', key);
|
||||
} catch (err) {
|
||||
console.error('删除跳过配置失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
+66
-1
@@ -3,7 +3,7 @@
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord } from './types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
@@ -283,6 +283,71 @@ export class RedisStorage implements IStorage {
|
||||
this.client.set(this.adminConfigKey(), JSON.stringify(config))
|
||||
);
|
||||
}
|
||||
|
||||
// 跳过配置相关
|
||||
private skipConfigKey(userName: string, key: string): string {
|
||||
return `katelyatv:skip_config:${userName}:${key}`;
|
||||
}
|
||||
|
||||
private skipConfigsKey(userName: string): string {
|
||||
return `katelyatv:skip_configs:${userName}`;
|
||||
}
|
||||
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<EpisodeSkipConfig | null> {
|
||||
const data = await withRetry(() =>
|
||||
this.client.get(this.skipConfigKey(userName, key))
|
||||
);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
key: string,
|
||||
config: EpisodeSkipConfig
|
||||
): Promise<void> {
|
||||
await withRetry(async () => {
|
||||
// 保存到独立的key
|
||||
await this.client.set(
|
||||
this.skipConfigKey(userName, key),
|
||||
JSON.stringify(config)
|
||||
);
|
||||
// 同时加入到用户的跳过配置集合中
|
||||
await this.client.sAdd(this.skipConfigsKey(userName), key);
|
||||
});
|
||||
}
|
||||
|
||||
async getAllSkipConfigs(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: EpisodeSkipConfig }> {
|
||||
const keys = await withRetry(() =>
|
||||
this.client.sMembers(this.skipConfigsKey(userName))
|
||||
);
|
||||
|
||||
const configs: { [key: string]: EpisodeSkipConfig } = {};
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await withRetry(() =>
|
||||
this.client.get(this.skipConfigKey(userName, key))
|
||||
);
|
||||
if (data) {
|
||||
configs[key] = JSON.parse(data);
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
async deleteSkipConfig(userName: string, key: string): Promise<void> {
|
||||
await withRetry(async () => {
|
||||
// 删除独立的key
|
||||
await this.client.del(this.skipConfigKey(userName, key));
|
||||
// 从用户的跳过配置集合中移除
|
||||
await this.client.sRem(this.skipConfigsKey(userName), key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Redis 客户端
|
||||
|
||||
@@ -14,6 +14,23 @@ export interface PlayRecord {
|
||||
search_title: string; // 搜索时使用的标题
|
||||
}
|
||||
|
||||
// 片头片尾数据结构
|
||||
export interface SkipSegment {
|
||||
start: number; // 开始时间(秒)
|
||||
end: number; // 结束时间(秒)
|
||||
type: 'opening' | 'ending'; // 片头或片尾
|
||||
title?: string; // 可选的描述
|
||||
}
|
||||
|
||||
// 剧集跳过配置
|
||||
export interface EpisodeSkipConfig {
|
||||
source: string; // 资源站标识
|
||||
id: string; // 剧集ID
|
||||
title: string; // 剧集标题
|
||||
segments: SkipSegment[]; // 跳过片段列表
|
||||
updated_time: number; // 最后更新时间
|
||||
}
|
||||
|
||||
// 收藏数据结构
|
||||
export interface Favorite {
|
||||
source_name: string;
|
||||
@@ -58,6 +75,12 @@ export interface IStorage {
|
||||
addSearchHistory(userName: string, keyword: string): Promise<void>;
|
||||
deleteSearchHistory(userName: string, keyword?: string): Promise<void>;
|
||||
|
||||
// 片头片尾跳过配置相关
|
||||
getSkipConfig(userName: string, key: string): Promise<EpisodeSkipConfig | null>;
|
||||
setSkipConfig(userName: string, key: string, config: EpisodeSkipConfig): Promise<void>;
|
||||
getAllSkipConfigs(userName: string): Promise<{ [key: string]: EpisodeSkipConfig }>;
|
||||
deleteSkipConfig(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 用户列表
|
||||
getAllUsers(): Promise<string[]>;
|
||||
|
||||
|
||||
+63
-1
@@ -3,7 +3,7 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord } from './types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
@@ -267,6 +267,68 @@ export class UpstashRedisStorage implements IStorage {
|
||||
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||
await withRetry(() => this.client.set(this.adminConfigKey(), config));
|
||||
}
|
||||
|
||||
// 跳过配置相关
|
||||
private skipConfigKey(userName: string, key: string): string {
|
||||
return `katelyatv:skip_config:${userName}:${key}`;
|
||||
}
|
||||
|
||||
private skipConfigsKey(userName: string): string {
|
||||
return `katelyatv:skip_configs:${userName}`;
|
||||
}
|
||||
|
||||
async getSkipConfig(
|
||||
userName: string,
|
||||
key: string
|
||||
): Promise<EpisodeSkipConfig | null> {
|
||||
const data = await withRetry(() =>
|
||||
this.client.get(this.skipConfigKey(userName, key))
|
||||
);
|
||||
return data ? (data as EpisodeSkipConfig) : null;
|
||||
}
|
||||
|
||||
async setSkipConfig(
|
||||
userName: string,
|
||||
key: string,
|
||||
config: EpisodeSkipConfig
|
||||
): Promise<void> {
|
||||
await withRetry(async () => {
|
||||
// 保存到独立的key
|
||||
await this.client.set(this.skipConfigKey(userName, key), config);
|
||||
// 同时加入到用户的跳过配置集合中
|
||||
await this.client.sadd(this.skipConfigsKey(userName), key);
|
||||
});
|
||||
}
|
||||
|
||||
async getAllSkipConfigs(
|
||||
userName: string
|
||||
): Promise<{ [key: string]: EpisodeSkipConfig }> {
|
||||
const keys = await withRetry(() =>
|
||||
this.client.smembers(this.skipConfigsKey(userName))
|
||||
);
|
||||
|
||||
const configs: { [key: string]: EpisodeSkipConfig } = {};
|
||||
|
||||
for (const key of ensureStringArray(keys || [])) {
|
||||
const data = await withRetry(() =>
|
||||
this.client.get(this.skipConfigKey(userName, key))
|
||||
);
|
||||
if (data) {
|
||||
configs[key] = data as EpisodeSkipConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
async deleteSkipConfig(userName: string, key: string): Promise<void> {
|
||||
await withRetry(async () => {
|
||||
// 删除独立的key
|
||||
await this.client.del(this.skipConfigKey(userName, key));
|
||||
// 从用户的跳过配置集合中移除
|
||||
await this.client.srem(this.skipConfigsKey(userName), key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Upstash Redis 客户端
|
||||
|
||||
Reference in New Issue
Block a user