From 348494336af2a48b6d2915a747727d0cac6081e5 Mon Sep 17 00:00:00 2001 From: katelya Date: Tue, 2 Sep 2025 13:49:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=B7=B3=E8=BF=87=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=92=8CAPI=E6=94=AF=E6=8C=81=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=92=AD=E6=94=BE=E5=99=A8=E4=BB=A5=E5=A4=84?= =?UTF-8?q?=E7=90=86=E8=B7=B3=E8=BF=87=E7=89=87=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- D1初始化.md | 12 + src/app/api/skip-configs/route.ts | 91 ++++++++ src/app/play/page.tsx | 32 +++ src/components/SkipController.tsx | 373 ++++++++++++++++++++++++++++++ src/lib/d1.db.ts | 102 +++++++- src/lib/db.client.ts | 288 +++++++++++++++++++++++ src/lib/redis.db.ts | 67 +++++- src/lib/types.ts | 23 ++ src/lib/upstash.db.ts | 64 ++++- 9 files changed, 1049 insertions(+), 3 deletions(-) create mode 100644 src/app/api/skip-configs/route.ts create mode 100644 src/components/SkipController.tsx diff --git a/D1初始化.md b/D1初始化.md index 5c80853..abe3571 100644 --- a/D1初始化.md +++ b/D1初始化.md @@ -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); diff --git a/src/app/api/skip-configs/route.ts b/src/app/api/skip-configs/route.ts new file mode 100644 index 0000000..c8d63ad --- /dev/null +++ b/src/app/api/skip-configs/route.ts @@ -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 } + ); + } +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 36b0783..91c1552 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -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(null); const lastSaveTimeRef = useRef(0); + // 播放器时间状态(用于跳过功能) + const [currentPlayTime, setCurrentPlayTime] = useState(0); + const [videoDuration, setVideoDuration] = useState(0); + const artPlayerRef = useRef(null); const artRef = useRef(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' > + {/* 跳过片头片尾控制器 */} + {currentSource && currentId && videoTitle && ( + + )} + {/* 换源加载蒙层 */} {isVideoLoading && (
diff --git a/src/components/SkipController.tsx b/src/components/SkipController.tsx new file mode 100644 index 0000000..4cacaba --- /dev/null +++ b/src/components/SkipController.tsx @@ -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; + currentTime?: number; + _duration?: number; // 使用下划线前缀标识未使用的参数 +} + +export default function SkipController({ + source, + id, + title, + artPlayerRef, + currentTime = 0, + _duration = 0, +}: SkipControllerProps) { + const [skipConfig, setSkipConfig] = useState(null); + const [showSkipButton, setShowSkipButton] = useState(false); + const [currentSkipSegment, setCurrentSkipSegment] = useState(null); + const [isSettingMode, setIsSettingMode] = useState(false); + const [newSegment, setNewSegment] = useState>({}); + + const lastSkipTimeRef = useRef(0); + const skipTimeoutRef = useRef(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 ( +
+ {/* 跳过按钮 */} + {showSkipButton && currentSkipSegment && ( +
+
+ + {currentSkipSegment.type === 'opening' ? '检测到片头' : '检测到片尾'} + + +
+
+ )} + + {/* 设置模式面板 */} + {isSettingMode && ( +
+
+

+ 添加跳过片段 +

+ +
+
+ + +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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="例如: 片头曲" + /> +
+ +
+ 当前播放时间: {formatTime(currentTime)} +
+
+ +
+ + +
+
+
+ )} + + {/* 管理已有片段 */} + {skipConfig && skipConfig.segments && skipConfig.segments.length > 0 && !isSettingMode && ( +
+

+ 已设置的跳过片段: +

+
+ {skipConfig.segments.map((segment, index) => ( +
+ + {segment.type === 'opening' ? '片头' : '片尾'}: {formatTime(segment.start)} - {formatTime(segment.end)} + {segment.title && ` (${segment.title})`} + + +
+ ))} +
+
+ )} + + +
+ ); +} + +// 导出跳过控制器的设置按钮组件 +export function SkipSettingsButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts index b4a9ddf..84e9ee0 100644 --- a/src/lib/d1.db.ts +++ b/src/lib/d1.db.ts @@ -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 { + try { + const db = await this.getDatabase(); + const result = await db + .prepare('SELECT * FROM skip_configs WHERE username = ? AND key = ?') + .bind(userName, key) + .first(); + + 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 { + 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(); + + 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 { + 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; + } + } } diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index a021e52..626a110 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -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 { data: T; @@ -52,6 +68,7 @@ interface UserCacheStore { playRecords?: CacheData>; favorites?: CacheData>; searchHistory?: CacheData; + skipConfigs?: CacheData>; } // ---- 常量 ---- @@ -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 | 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): 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 { console.warn('预加载用户数据失败:', err); }); } + +// ---------------- 片头片尾跳过配置管理 ---------------- + +/** + * 生成跳过配置的存储 key + */ +export function generateSkipConfigKey(source: string, id: string): string { + return `${source}_${id}`; +} + +/** + * 获取单个跳过配置 + */ +export async function getSkipConfig( + source: string, + id: string +): Promise { + 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 { + 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> { + 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 { + 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; + } +} diff --git a/src/lib/redis.db.ts b/src/lib/redis.db.ts index 1dc52d4..7f61fd3 100644 --- a/src/lib/redis.db.ts +++ b/src/lib/redis.db.ts @@ -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 { + 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 { + 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 { + await withRetry(async () => { + // 删除独立的key + await this.client.del(this.skipConfigKey(userName, key)); + // 从用户的跳过配置集合中移除 + await this.client.sRem(this.skipConfigsKey(userName), key); + }); + } } // 单例 Redis 客户端 diff --git a/src/lib/types.ts b/src/lib/types.ts index 83e800a..6a84139 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; deleteSearchHistory(userName: string, keyword?: string): Promise; + // 片头片尾跳过配置相关 + getSkipConfig(userName: string, key: string): Promise; + setSkipConfig(userName: string, key: string, config: EpisodeSkipConfig): Promise; + getAllSkipConfigs(userName: string): Promise<{ [key: string]: EpisodeSkipConfig }>; + deleteSkipConfig(userName: string, key: string): Promise; + // 用户列表 getAllUsers(): Promise; diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index bf2a457..a807c51 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -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 { 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 { + 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 { + 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 { + await withRetry(async () => { + // 删除独立的key + await this.client.del(this.skipConfigKey(userName, key)); + // 从用户的跳过配置集合中移除 + await this.client.srem(this.skipConfigsKey(userName), key); + }); + } } // 单例 Upstash Redis 客户端