feat: skip intro & outro

This commit is contained in:
shinya
2025-07-29 18:31:40 +08:00
parent 8fd7451f8a
commit 58ca3ef744
9 changed files with 776 additions and 11 deletions

View File

@@ -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);
```

View File

@@ -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 }
);
}
}

View File

@@ -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<boolean>(() => {
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: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
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: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
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) {

View File

@@ -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<SkipConfig | null> {
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<any>();
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<void> {
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<void> {
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;
}
}
}

View File

@@ -15,6 +15,7 @@
*/
import { getAuthInfoFromBrowserCookie } from './auth';
import { SkipConfig } from './types';
// ---- 类型 ----
export interface PlayRecord {
@@ -52,6 +53,7 @@ interface UserCacheStore {
playRecords?: CacheData<Record<string, PlayRecord>>;
favorites?: CacheData<Record<string, Favorite>>;
searchHistory?: CacheData<string[]>;
skipConfigs?: CacheData<Record<string, SkipConfig>>;
}
// ---- 常量 ----
@@ -248,6 +250,35 @@ class HybridCacheManager {
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);
}
/**
* 清除指定用户的所有缓存
*/
@@ -1122,11 +1153,13 @@ export async function refreshAllCache(): Promise<void> {
try {
// 并行刷新所有数据
const [playRecords, favorites, searchHistory] = await Promise.allSettled([
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
fetchFromApi<string[]>(`/api/searchhistory`),
]);
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);
@@ -1154,6 +1187,15 @@ export async function refreshAllCache(): Promise<void> {
})
);
}
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<void> {
// 检查是否已有有效缓存,避免重复请求
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<void> {
console.warn('预加载用户数据失败:', err);
});
}
// ---------------- 跳过片头片尾配置相关 API ----------------
/**
* 获取跳过片头片尾配置。
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/
export async function getSkipConfig(
source: string,
id: string
): Promise<SkipConfig | null> {
// 服务器端渲染阶段直接返回空
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<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);
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);
return null;
}
}
/**
* 保存跳过片头片尾配置。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function saveSkipConfig(
source: string,
id: string,
config: SkipConfig
): Promise<void> {
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<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);
throw err;
}
}
/**
* 删除跳过片头片尾配置。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function deleteSkipConfig(
source: string,
id: string
): Promise<void> {
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<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);
throw err;
}
}

View File

@@ -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<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);
}
}
}
// 导出默认实例

View File

@@ -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<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))
);
}
}
// 单例 Redis 客户端

View File

@@ -64,6 +64,20 @@ export interface IStorage {
// 管理员配置相关
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>;
}
// 搜索结果数据结构
@@ -95,3 +109,10 @@ export interface DoubanResult {
message: string;
list: DoubanItem[];
}
// 跳过片头片尾配置数据结构
export interface SkipConfig {
enable: boolean; // 是否启用跳过片头片尾
intro_time: number; // 片头时间(秒)
outro_time: number; // 片尾时间(秒)
}

View File

@@ -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<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))
);
}
}
// 单例 Upstash Redis 客户端