添加跳过配置功能,包括数据库和API支持,更新播放器以处理跳过片段

This commit is contained in:
katelya
2025-09-02 13:49:46 +08:00
parent d9d50891f2
commit 348494336a
9 changed files with 1049 additions and 3 deletions
+101 -1
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 { 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;
}
}
}
+288
View File
@@ -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
View File
@@ -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 客户端
+23
View File
@@ -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
View File
@@ -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 客户端