diff --git a/D1初始化.md b/D1初始化.md index 5c80853..48b846f 100644 --- a/D1初始化.md +++ b/D1初始化.md @@ -49,6 +49,17 @@ 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, + source TEXT NOT NULL, + id_video TEXT NOT NULL, + enable INTEGER NOT NULL DEFAULT 0, + intro_time INTEGER NOT NULL DEFAULT 0, + outro_time INTEGER NOT NULL DEFAULT 0, + UNIQUE(username, source, id_video) +); + -- 基本索引 CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username); CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username); @@ -70,6 +81,9 @@ CREATE INDEX IF NOT EXISTS idx_search_history_username_keyword ON search_history -- 搜索历史:用户名+创建时间的复合索引,用于按时间排序的查询 CREATE INDEX IF NOT EXISTS idx_search_history_username_created_at ON search_history(username, created_at DESC); +-- 跳过片头片尾配置:用户名+源+视频ID的复合索引,用于快速查找特定配置 +CREATE INDEX IF NOT EXISTS idx_skip_configs_username_source_id ON skip_configs(username, source, id_video); + -- 搜索历史清理查询的优化索引 CREATE INDEX IF NOT EXISTS idx_search_history_username_id_created_at ON search_history(username, id, created_at DESC); ``` diff --git a/src/app/api/skipconfigs/route.ts b/src/app/api/skipconfigs/route.ts new file mode 100644 index 0000000..e2b920d --- /dev/null +++ b/src/app/api/skipconfigs/route.ts @@ -0,0 +1,108 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { SkipConfig } from '@/lib/types'; + +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const source = searchParams.get('source'); + const id = searchParams.get('id'); + + if (source && id) { + // 获取单个配置 + const config = await db.getSkipConfig(authInfo.username, source, id); + return NextResponse.json(config); + } else { + // 获取所有配置 + // 注意:这里需要实现获取所有跳过片头片尾配置的方法 + // 由于当前接口设计是按source+id获取单个配置,这里返回空对象 + return NextResponse.json({}); + } + } catch (error) { + console.error('获取跳过片头片尾配置失败:', error); + return NextResponse.json( + { error: '获取跳过片头片尾配置失败' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const body = await request.json(); + const { key, config } = body; + + if (!key || !config) { + return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); + } + + // 解析key为source和id + const [source, id] = key.split('+'); + if (!source || !id) { + return NextResponse.json({ error: '无效的key格式' }, { status: 400 }); + } + + // 验证配置格式 + const skipConfig: SkipConfig = { + enable: Boolean(config.enable), + intro_time: Number(config.intro_time) || 0, + outro_time: Number(config.outro_time) || 0, + }; + + await db.setSkipConfig(authInfo.username, source, id, skipConfig); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('保存跳过片头片尾配置失败:', error); + return NextResponse.json( + { error: '保存跳过片头片尾配置失败' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const key = searchParams.get('key'); + + if (!key) { + return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); + } + + // 解析key为source和id + const [source, id] = key.split('+'); + if (!source || !id) { + return NextResponse.json({ error: '无效的key格式' }, { status: 400 }); + } + + await db.deleteSkipConfig(authInfo.username, source, id); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('删除跳过片头片尾配置失败:', error); + return NextResponse.json( + { error: '删除跳过片头片尾配置失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 36b0783..8597a36 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -11,11 +11,14 @@ import { Suspense, useEffect, useRef, useState } from 'react'; import { deleteFavorite, deletePlayRecord, + deleteSkipConfig, generateStorageKey, getAllPlayRecords, + getSkipConfig, isFavorited, saveFavorite, savePlayRecord, + saveSkipConfig, subscribeToDataUpdates, } from '@/lib/db.client'; import { SearchResult } from '@/lib/types'; @@ -49,6 +52,26 @@ function PlayPageClient() { // 收藏状态 const [favorited, setFavorited] = useState(false); + // 跳过片头片尾配置 + const [skipConfig, setSkipConfig] = useState<{ + enable: boolean; + intro_time: number; + outro_time: number; + }>({ + enable: false, + intro_time: 0, + outro_time: 0, + }); + const skipConfigRef = useRef(skipConfig); + useEffect(() => { + skipConfigRef.current = skipConfig; + }, [ + skipConfig, + skipConfig.enable, + skipConfig.intro_time, + skipConfig.outro_time, + ]); + // 去广告开关(从 localStorage 继承,默认 true) const [blockAdEnabled, setBlockAdEnabled] = useState(() => { if (typeof window !== 'undefined') { @@ -428,6 +451,37 @@ function PlayPageClient() { return filteredLines.join('\n'); } + // 跳过片头片尾配置相关函数 + const handleSkipConfigChange = async (newConfig: { + enable: boolean; + intro_time: number; + outro_time: number; + }) => { + if (!currentSourceRef.current || !currentIdRef.current) return; + + try { + await saveSkipConfig( + currentSourceRef.current, + currentIdRef.current, + newConfig + ); + setSkipConfig(newConfig); + console.log('跳过片头片尾配置已保存:', newConfig); + } catch (err) { + console.error('保存跳过片头片尾配置失败:', err); + } + }; + + const formatTime = (seconds: number): string => { + if (seconds === 0) return '0秒'; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + if (minutes === 0) { + return `${remainingSeconds}秒`; + } + return `${minutes}分${remainingSeconds.toString().padStart(2, '0')}秒`; + }; + class CustomHlsJsLoader extends Hls.DefaultConfig.loader { constructor(config: any) { super(config); @@ -642,6 +696,25 @@ function PlayPageClient() { initFromHistory(); }, []); + // 跳过片头片尾配置处理 + useEffect(() => { + // 仅在初次挂载时检查跳过片头片尾配置 + const initSkipConfig = async () => { + if (!currentSource || !currentId) return; + + try { + const config = await getSkipConfig(currentSource, currentId); + if (config) { + setSkipConfig(config); + } + } catch (err) { + console.error('读取跳过片头片尾配置失败:', err); + } + }; + + initSkipConfig(); + }, []); + // 处理换源 const handleSourceChange = async ( newSource: string, @@ -670,6 +743,19 @@ function PlayPageClient() { } } + // 清除并设置下一个跳过片头片尾配置 + if (currentSourceRef.current && currentIdRef.current) { + try { + await deleteSkipConfig( + currentSourceRef.current, + currentIdRef.current + ); + await saveSkipConfig(newSource, newId, skipConfigRef.current); + } catch (err) { + console.error('清除跳过片头片尾配置失败:', err); + } + } + const newDetail = availableSources.find( (source) => source.source === newSource && source.id === newId ); @@ -1182,6 +1268,56 @@ function PlayPageClient() { return newVal ? '当前开启' : '当前关闭'; }, }, + { + html: '跳过片头片尾', + switch: skipConfig.enable, + onSwitch: function (item) { + const newConfig = { + ...skipConfigRef.current, + enable: !item.switch, + }; + handleSkipConfigChange(newConfig); + return !item.switch; + }, + }, + { + html: '设置片头', + icon: '', + tooltip: + skipConfig.intro_time === 0 + ? '设置片头时间' + : `${formatTime(skipConfig.intro_time)}`, + onClick: function () { + const currentTime = artPlayerRef.current?.currentTime || 0; + if (currentTime > 0) { + const newConfig = { + ...skipConfigRef.current, + intro_time: currentTime, + }; + handleSkipConfigChange(newConfig); + return `${formatTime(currentTime)}`; + } + }, + }, + { + html: '设置片尾', + icon: '', + tooltip: + skipConfig.outro_time === 0 + ? '设置片尾时间' + : `${formatTime(skipConfig.outro_time)}`, + onClick: function () { + const currentTime = artPlayerRef.current?.currentTime || 0; + if (currentTime > 0) { + const newConfig = { + ...skipConfig, + outro_time: currentTime, + }; + handleSkipConfigChange(newConfig); + return `${formatTime(currentTime)}`; + } + }, + }, ], // 控制栏配置 controls: [ @@ -1237,6 +1373,37 @@ function PlayPageClient() { setIsVideoLoading(false); }); + // 监听视频时间更新事件,实现跳过片头片尾 + artPlayerRef.current.on('video:timeupdate', () => { + if (!skipConfigRef.current.enable) return; + + const currentTime = artPlayerRef.current.currentTime || 0; + const duration = artPlayerRef.current.duration || 0; + + // 跳过片头 + if ( + skipConfigRef.current.intro_time > 0 && + currentTime < skipConfigRef.current.intro_time + ) { + artPlayerRef.current.currentTime = skipConfigRef.current.intro_time; + artPlayerRef.current.notice.show = `已跳过片头 (${formatTime( + skipConfigRef.current.intro_time + )})`; + } + + // 跳过片尾 + if ( + skipConfigRef.current.outro_time > 0 && + duration > 0 && + currentTime > skipConfigRef.current.outro_time + ) { + handleNextEpisode(); + artPlayerRef.current.notice.show = `已跳过片尾 (${formatTime( + skipConfigRef.current.outro_time + )})`; + } + }); + artPlayerRef.current.on('error', (err: any) => { console.error('播放器错误:', err); if (artPlayerRef.current.currentTime > 0) { diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts index b4a9ddf..87ce2a2 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 { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; // 搜索历史最大条数 const SEARCH_HISTORY_LIMIT = 20; @@ -340,6 +340,9 @@ export class D1Storage implements IStorage { db .prepare('DELETE FROM search_history WHERE username = ?') .bind(userName), + db + .prepare('DELETE FROM skip_configs WHERE username = ?') + .bind(userName), ]; await db.batch(statements); @@ -473,4 +476,82 @@ export class D1Storage implements IStorage { throw err; } } + + // ---------- 跳过片头片尾配置 ---------- + async getSkipConfig( + userName: string, + source: string, + id: string + ): Promise { + try { + const db = await this.getDatabase(); + const result = await db + .prepare( + 'SELECT * FROM skip_configs WHERE username = ? AND source = ? AND id_video = ?' + ) + .bind(userName, source, id) + .first(); + + if (!result) return null; + + return { + enable: Boolean(result.enable), + intro_time: result.intro_time, + outro_time: result.outro_time, + }; + } catch (err) { + console.error('Failed to get skip config:', err); + throw err; + } + } + + async setSkipConfig( + userName: string, + source: string, + id: string, + config: SkipConfig + ): Promise { + try { + const db = await this.getDatabase(); + await db + .prepare( + ` + INSERT OR REPLACE INTO skip_configs + (username, source, id_video, enable, intro_time, outro_time) + VALUES (?, ?, ?, ?, ?, ?) + ` + ) + .bind( + userName, + source, + id, + config.enable ? 1 : 0, + config.intro_time, + config.outro_time + ) + .run(); + } catch (err) { + console.error('Failed to set skip config:', err); + throw err; + } + } + + async deleteSkipConfig( + userName: string, + source: string, + id: string + ): Promise { + try { + const db = await this.getDatabase(); + await db + .prepare( + 'DELETE FROM skip_configs WHERE username = ? AND source = ? AND id_video = ?' + ) + .bind(userName, source, id) + .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 2c42f9e..1860fdb 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -15,6 +15,7 @@ */ import { getAuthInfoFromBrowserCookie } from './auth'; +import { SkipConfig } from './types'; // ---- 类型 ---- export interface PlayRecord { @@ -52,6 +53,7 @@ interface UserCacheStore { playRecords?: CacheData>; favorites?: CacheData>; searchHistory?: CacheData; + skipConfigs?: CacheData>; } // ---- 常量 ---- @@ -248,6 +250,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); + } + /** * 清除指定用户的所有缓存 */ @@ -1122,11 +1153,13 @@ export async function refreshAllCache(): Promise { try { // 并行刷新所有数据 - const [playRecords, favorites, searchHistory] = await Promise.allSettled([ - fetchFromApi>(`/api/playrecords`), - fetchFromApi>(`/api/favorites`), - fetchFromApi(`/api/searchhistory`), - ]); + const [playRecords, favorites, searchHistory, skipConfigs] = + await Promise.allSettled([ + fetchFromApi>(`/api/playrecords`), + fetchFromApi>(`/api/favorites`), + fetchFromApi(`/api/searchhistory`), + fetchFromApi>(`/api/skipconfigs`), + ]); if (playRecords.status === 'fulfilled') { cacheManager.cachePlayRecords(playRecords.value); @@ -1154,6 +1187,15 @@ export async function refreshAllCache(): Promise { }) ); } + + if (skipConfigs.status === 'fulfilled') { + cacheManager.cacheSkipConfigs(skipConfigs.value); + window.dispatchEvent( + new CustomEvent('skipConfigsUpdated', { + detail: skipConfigs.value, + }) + ); + } } catch (err) { console.error('刷新缓存失败:', err); } @@ -1167,6 +1209,7 @@ export function getCacheStatus(): { hasPlayRecords: boolean; hasFavorites: boolean; hasSearchHistory: boolean; + hasSkipConfigs: boolean; username: string | null; } { if (STORAGE_TYPE === 'localstorage') { @@ -1174,6 +1217,7 @@ export function getCacheStatus(): { hasPlayRecords: false, hasFavorites: false, hasSearchHistory: false, + hasSkipConfigs: false, username: null, }; } @@ -1183,6 +1227,7 @@ export function getCacheStatus(): { hasPlayRecords: !!cacheManager.getCachedPlayRecords(), hasFavorites: !!cacheManager.getCachedFavorites(), hasSearchHistory: !!cacheManager.getCachedSearchHistory(), + hasSkipConfigs: !!cacheManager.getCachedSkipConfigs(), username: authInfo?.username || null, }; } @@ -1192,7 +1237,8 @@ export function getCacheStatus(): { export type CacheUpdateEvent = | 'playRecordsUpdated' | 'favoritesUpdated' - | 'searchHistoryUpdated'; + | 'searchHistoryUpdated' + | 'skipConfigsUpdated'; /** * 用于 React 组件监听数据更新的事件监听器 @@ -1233,7 +1279,12 @@ export async function preloadUserData(): Promise { // 检查是否已有有效缓存,避免重复请求 const status = getCacheStatus(); - if (status.hasPlayRecords && status.hasFavorites && status.hasSearchHistory) { + if ( + status.hasPlayRecords && + status.hasFavorites && + status.hasSearchHistory && + status.hasSkipConfigs + ) { return; } @@ -1242,3 +1293,198 @@ export async function preloadUserData(): Promise { console.warn('预加载用户数据失败:', err); }); } + +// ---------------- 跳过片头片尾配置相关 API ---------------- + +/** + * 获取跳过片头片尾配置。 + * 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。 + */ +export async function getSkipConfig( + source: string, + id: string +): Promise { + // 服务器端渲染阶段直接返回空 + if (typeof window === 'undefined') { + return null; + } + + const key = generateStorageKey(source, id); + + // 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash) + if (STORAGE_TYPE !== 'localstorage') { + // 优先从缓存获取数据 + const cachedData = cacheManager.getCachedSkipConfigs(); + + if (cachedData) { + // 返回缓存数据,同时后台异步更新 + fetchFromApi>(`/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>( + `/api/skipconfigs` + ); + cacheManager.cacheSkipConfigs(freshData); + return freshData[key] || null; + } catch (err) { + console.error('获取跳过片头片尾配置失败:', err); + return null; + } + } + } + + // localStorage 模式 + try { + const raw = localStorage.getItem('moontv_skip_configs'); + if (!raw) return null; + const configs = JSON.parse(raw) as Record; + return configs[key] || null; + } catch (err) { + console.error('读取跳过片头片尾配置失败:', err); + return null; + } +} + +/** + * 保存跳过片头片尾配置。 + * 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。 + */ +export async function saveSkipConfig( + source: string, + id: string, + config: SkipConfig +): Promise { + const key = generateStorageKey(source, id); + + // 数据库存储模式:乐观更新策略(包括 redis、d1、upstash) + if (STORAGE_TYPE !== 'localstorage') { + // 立即更新缓存 + const cachedConfigs = cacheManager.getCachedSkipConfigs() || {}; + cachedConfigs[key] = config; + cacheManager.cacheSkipConfigs(cachedConfigs); + + // 触发立即更新事件 + window.dispatchEvent( + new CustomEvent('skipConfigsUpdated', { + detail: cachedConfigs, + }) + ); + + // 异步同步到数据库 + try { + const res = await fetch('/api/skipconfigs', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key, config }), + }); + if (!res.ok) throw new Error(`保存跳过片头片尾配置失败: ${res.status}`); + } catch (err) { + console.error('保存跳过片头片尾配置失败:', err); + } + 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) : {}; + configs[key] = config; + localStorage.setItem('moontv_skip_configs', JSON.stringify(configs)); + window.dispatchEvent( + new CustomEvent('skipConfigsUpdated', { + detail: configs, + }) + ); + } catch (err) { + console.error('保存跳过片头片尾配置失败:', err); + throw err; + } +} + +/** + * 删除跳过片头片尾配置。 + * 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。 + */ +export async function deleteSkipConfig( + source: string, + id: string +): Promise { + const key = generateStorageKey(source, id); + + // 数据库存储模式:乐观更新策略(包括 redis、d1、upstash) + if (STORAGE_TYPE !== 'localstorage') { + // 立即更新缓存 + const cachedConfigs = cacheManager.getCachedSkipConfigs() || {}; + delete cachedConfigs[key]; + cacheManager.cacheSkipConfigs(cachedConfigs); + + // 触发立即更新事件 + window.dispatchEvent( + new CustomEvent('skipConfigsUpdated', { + detail: cachedConfigs, + }) + ); + + // 异步同步到数据库 + try { + const res = await fetch( + `/api/skipconfigs?key=${encodeURIComponent(key)}`, + { + method: 'DELETE', + } + ); + if (!res.ok) throw new Error(`删除跳过片头片尾配置失败: ${res.status}`); + } catch (err) { + console.error('删除跳过片头片尾配置失败:', err); + } + 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; + delete configs[key]; + localStorage.setItem('moontv_skip_configs', JSON.stringify(configs)); + window.dispatchEvent( + new CustomEvent('skipConfigsUpdated', { + detail: configs, + }) + ); + } + } catch (err) { + console.error('删除跳过片头片尾配置失败:', err); + throw err; + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index b075b70..751d2ae 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -3,7 +3,7 @@ import { AdminConfig } from './admin.types'; import { D1Storage } from './d1.db'; import { RedisStorage } from './redis.db'; -import { Favorite, IStorage, PlayRecord } from './types'; +import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { UpstashRedisStorage } from './upstash.db'; // storage type 常量: 'localstorage' | 'redis' | 'd1' | 'upstash',默认 'localstorage' @@ -181,6 +181,39 @@ export class DbManager { await (this.storage as any).setAdminConfig(config); } } + + // ---------- 跳过片头片尾配置 ---------- + async getSkipConfig( + userName: string, + source: string, + id: string + ): Promise { + 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 { + 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 { + if (typeof (this.storage as any).deleteSkipConfig === 'function') { + await (this.storage as any).deleteSkipConfig(userName, source, id); + } + } } // 导出默认实例 diff --git a/src/lib/redis.db.ts b/src/lib/redis.db.ts index 1b9d024..7bc9368 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 { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; // 搜索历史最大条数 const SEARCH_HISTORY_LIMIT = 20; @@ -223,6 +223,15 @@ export class RedisStorage implements IStorage { 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)); + } } // ---------- 搜索历史 ---------- @@ -283,6 +292,46 @@ export class RedisStorage implements IStorage { 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 { + 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 { + await withRetry(() => + this.client.set( + this.skipConfigKey(userName, source, id), + JSON.stringify(config) + ) + ); + } + + async deleteSkipConfig( + userName: string, + source: string, + id: string + ): Promise { + await withRetry(() => + this.client.del(this.skipConfigKey(userName, source, id)) + ); + } } // 单例 Redis 客户端 diff --git a/src/lib/types.ts b/src/lib/types.ts index a7e1f48..24b8e3a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -64,6 +64,20 @@ export interface IStorage { // 管理员配置相关 getAdminConfig(): Promise; setAdminConfig(config: AdminConfig): Promise; + + // 跳过片头片尾配置相关 + getSkipConfig( + userName: string, + source: string, + id: string + ): Promise; + setSkipConfig( + userName: string, + source: string, + id: string, + config: SkipConfig + ): Promise; + deleteSkipConfig(userName: string, source: string, id: string): Promise; } // 搜索结果数据结构 @@ -95,3 +109,10 @@ export interface DoubanResult { message: string; list: DoubanItem[]; } + +// 跳过片头片尾配置数据结构 +export interface SkipConfig { + enable: boolean; // 是否启用跳过片头片尾 + intro_time: number; // 片头时间(秒) + outro_time: number; // 片尾时间(秒) +} diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 3832bd3..945c0e3 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 { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; // 搜索历史最大条数 const SEARCH_HISTORY_LIMIT = 20; @@ -209,6 +209,15 @@ export class UpstashRedisStorage implements IStorage { 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)); + } } // ---------- 搜索历史 ---------- @@ -267,6 +276,43 @@ export class UpstashRedisStorage implements IStorage { async setAdminConfig(config: AdminConfig): Promise { 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 { + 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 { + await withRetry(() => + this.client.set(this.skipConfigKey(userName, source, id), config) + ); + } + + async deleteSkipConfig( + userName: string, + source: string, + id: string + ): Promise { + await withRetry(() => + this.client.del(this.skipConfigKey(userName, source, id)) + ); + } } // 单例 Upstash Redis 客户端