Files
KVideo/lib/store/settings-store.ts
T
kuekhaoyang cdab3b1c8a fix: harden M3U8 ad filtering
Reject unsafe dynamic filter execution, reduce heuristic false positives, add regression coverage, bump the app to 4.9.17, and update Next.js to the latest stable security patch.
2026-07-29 22:13:54 +08:00

351 lines
14 KiB
TypeScript

/**
* Settings Store - Manages application settings and preferences
*/
import type { VideoSource, SourceSubscription } from '@/lib/types';
import { DEFAULT_SOURCES } from '@/lib/api/default-sources';
import { PREMIUM_SOURCES } from '@/lib/api/premium-sources';
import { createSubscription } from '@/lib/utils/source-import-utils';
export type LocaleOption = 'zh-CN' | 'zh-TW';
export type SortOption =
| 'default'
| 'relevance'
| 'latency-asc'
| 'date-desc'
| 'date-asc'
| 'rating-desc'
| 'name-asc'
| 'name-desc';
export type SearchDisplayMode = 'normal' | 'grouped';
export type AdFilterMode = 'off' | 'keyword' | 'heuristic' | 'aggressive';
export type ProxyMode = 'retry' | 'none' | 'always';
export const DEFAULT_SEEK_STEP_SECONDS = 10;
export const MIN_SEEK_STEP_SECONDS = 1;
export const MAX_SEEK_STEP_SECONDS = 120;
export function normalizeSeekStepSeconds(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_SEEK_STEP_SECONDS;
}
return Math.min(MAX_SEEK_STEP_SECONDS, Math.max(MIN_SEEK_STEP_SECONDS, Math.round(value)));
}
export interface AppSettings {
sources: VideoSource[];
premiumSources: VideoSource[];
subscriptions: SourceSubscription[]; // Source subscriptions for auto-update
sortBy: SortOption;
searchHistory: boolean;
watchHistory: boolean;
// Player settings
autoNextEpisode: boolean;
autoSkipIntro: boolean;
skipIntroSeconds: number;
autoSkipOutro: boolean;
skipOutroSeconds: number;
seekStepSeconds: number;
showModeIndicator: boolean; // Show '直连模式'/'代理模式' badge on player
adFilter: boolean; // Filter ad tags from m3u8 (legacy, kept for compatibility)
adFilterMode: AdFilterMode; // 'off' | 'keyword' | 'heuristic' | 'aggressive'
adKeywords: string[]; // Dynamically loaded ad keywords
// Search & Display settings
realtimeLatency: boolean; // Enable real-time latency ping updates
searchDisplayMode: SearchDisplayMode; // 'normal' = individual cards, 'grouped' = group same-name videos
episodeReverseOrder: boolean; // Persist episode list reverse state
fullscreenType: 'auto' | 'native' | 'window'; // Fullscreen mode preference: 'auto' (native on desktop, window on mobile) | 'native' | 'window'
proxyMode: ProxyMode; // Proxy behavior: 'retry' | 'none' | 'always'
rememberScrollPosition: boolean; // Remember scroll position when navigating back or refreshing
personalizedRecommendations: boolean; // Show personalized recommendations based on watch history
videoTogetherEnabled: boolean; // Show VideoTogether entry on supported player pages
// Danmaku settings
danmakuEnabled: boolean; // Show danmaku overlay on video
danmakuApiUrl: string; // Self-hosted danmaku API endpoint
danmakuOpacity: number; // 0.1 - 1.0
danmakuFontSize: number; // px
danmakuDisplayArea: number; // 0.25 | 0.5 | 0.75 | 1.0
locale: LocaleOption; // 'zh-CN' (Simplified) or 'zh-TW' (Traditional)
blockedCategories: string[]; // Category keywords to hide from search results (e.g. '伦理')
}
import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers';
const SETTINGS_KEY = 'kvideo-settings';
export const getDefaultSources = (): VideoSource[] => DEFAULT_SOURCES;
export const getDefaultPremiumSources = (): VideoSource[] => PREMIUM_SOURCES;
function getEnvSubscriptions(customValue?: string): SourceSubscription[] {
const envValue = (customValue || process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '').trim();
if (!envValue) return [];
// 1. Try JSON
try {
const raw = JSON.parse(envValue);
if (Array.isArray(raw)) {
return raw
.filter((item: any) => item && typeof item.name === 'string' && typeof item.url === 'string')
.map((item: any) => createSubscription(item.name, item.url));
}
} catch (e) {
// Ignore JSON parse error, try direct URL
}
// 2. Try Simple URL (or comma separated)
// Check if it looks like a URL (basic check)
if (envValue.includes('http')) {
const urls = envValue.split(',').map(u => u.trim()).filter(u => u.length > 0);
return urls.map((url, index) => {
// Basic URL validation
if (!url.startsWith('http')) return null;
const name = urls.length > 1
? `系统预设源 ${index + 1}`
: `系统预设源`;
return createSubscription(name, url);
}).filter((s): s is SourceSubscription => s !== null);
}
return [];
}
// Debugging helper
// console.log("Environment Subscriptions:", getEnvSubscriptions());
// Shared default settings factory to avoid code duplication
function getDefaultAppSettings(): AppSettings {
return {
sources: getDefaultSources(),
premiumSources: getDefaultPremiumSources(),
subscriptions: getEnvSubscriptions(),
sortBy: 'default',
searchHistory: true,
watchHistory: true,
autoNextEpisode: true,
autoSkipIntro: false,
skipIntroSeconds: 0,
autoSkipOutro: false,
skipOutroSeconds: 0,
seekStepSeconds: DEFAULT_SEEK_STEP_SECONDS,
showModeIndicator: false,
adFilter: false,
adFilterMode: 'heuristic',
adKeywords: [],
realtimeLatency: false,
searchDisplayMode: 'normal',
episodeReverseOrder: false,
fullscreenType: 'auto',
proxyMode: 'retry',
rememberScrollPosition: true,
personalizedRecommendations: true,
videoTogetherEnabled: false,
danmakuEnabled: false,
danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '',
danmakuOpacity: 0.7,
danmakuFontSize: 20,
danmakuDisplayArea: 0.5,
locale: 'zh-CN',
blockedCategories: [],
};
}
export function hasStoredAppSetting(key: keyof AppSettings): boolean {
if (typeof window === 'undefined') {
return false;
}
const stored = localStorage.getItem(SETTINGS_KEY);
if (!stored) {
return false;
}
try {
const parsed = JSON.parse(stored);
return Object.prototype.hasOwnProperty.call(parsed, key);
} catch {
return false;
}
}
export const settingsStore = {
getSettings(): AppSettings {
// SSR: Return defaults
if (typeof window === 'undefined') {
return getDefaultAppSettings();
}
// Client: No stored settings, return defaults
const stored = localStorage.getItem(SETTINGS_KEY);
if (!stored) {
return getDefaultAppSettings();
}
try {
const parsed = JSON.parse(stored);
// Get ENV subscriptions
const envSubscriptions = getEnvSubscriptions();
// Parse stored subscriptions
const storedSubscriptions = Array.isArray(parsed.subscriptions) ? parsed.subscriptions : [];
// Merge: ENV subscriptions take precedence for existence, but we want to keep local state (like lastUpdated) if possible
// However, for simplicity and ensuring "auto update" as user requested, if it's in ENV, we act as if it's a fresh/enforced source
// actually, let's just merge them by URL.
const mergedSubscriptions = [...storedSubscriptions];
envSubscriptions.forEach(envSub => {
const existingIndex = mergedSubscriptions.findIndex(s => s.url === envSub.url);
if (existingIndex > -1) {
// Update name if changed in ENV, but keep ID and lastUpdated from local to avoid unnecessary re-fetches or ID shifts if dependent
// BUT, if the user explicitly wants "ENV input... automatically update", maybe they mean the content updates.
// Let's just ensure it exists.
mergedSubscriptions[existingIndex] = {
...mergedSubscriptions[existingIndex],
name: envSub.name, // Allow renaming via ENV
autoRefresh: true // Enforce autoRefresh for ENV sources
};
} else {
mergedSubscriptions.push(envSub);
}
});
// Filter out invalid sources (missing baseUrl etc)
const validSources = (Array.isArray(parsed.sources) ? parsed.sources : getDefaultSources())
.filter((s: any) => s && s.id && s.name && s.baseUrl);
const validPremiumSources = (Array.isArray(parsed.premiumSources) ? parsed.premiumSources : getDefaultPremiumSources())
.filter((s: any) => s && s.id && s.name && s.baseUrl);
// Validate that parsed data has all required properties
return {
sources: validSources,
premiumSources: validPremiumSources,
subscriptions: mergedSubscriptions.filter((s: any) => s && s.id && s.name && s.url),
sortBy: parsed.sortBy || 'default',
searchHistory: parsed.searchHistory !== undefined ? parsed.searchHistory : true,
watchHistory: parsed.watchHistory !== undefined ? parsed.watchHistory : true,
autoNextEpisode: parsed.autoNextEpisode !== undefined ? parsed.autoNextEpisode : true,
autoSkipIntro: parsed.autoSkipIntro !== undefined ? parsed.autoSkipIntro : false,
skipIntroSeconds: typeof parsed.skipIntroSeconds === 'number' ? parsed.skipIntroSeconds : 0,
autoSkipOutro: parsed.autoSkipOutro !== undefined ? parsed.autoSkipOutro : false,
skipOutroSeconds: typeof parsed.skipOutroSeconds === 'number' ? parsed.skipOutroSeconds : 0,
seekStepSeconds: normalizeSeekStepSeconds(parsed.seekStepSeconds),
showModeIndicator: parsed.showModeIndicator !== undefined ? parsed.showModeIndicator : false,
adFilter: parsed.adFilter !== undefined ? parsed.adFilter : false,
adFilterMode: parsed.adFilterMode || 'heuristic',
adKeywords: Array.isArray(parsed.adKeywords) ? parsed.adKeywords : [],
realtimeLatency: parsed.realtimeLatency !== undefined ? parsed.realtimeLatency : false,
searchDisplayMode: parsed.searchDisplayMode === 'grouped' ? 'grouped' : 'normal',
episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false,
fullscreenType: (parsed.fullscreenType === 'window' || parsed.fullscreenType === 'native' || parsed.fullscreenType === 'auto') ? parsed.fullscreenType : 'auto',
proxyMode: (parsed.proxyMode === 'retry' || parsed.proxyMode === 'none' || parsed.proxyMode === 'always') ? parsed.proxyMode : 'retry',
rememberScrollPosition: parsed.rememberScrollPosition !== undefined ? parsed.rememberScrollPosition : true,
personalizedRecommendations: parsed.personalizedRecommendations !== undefined ? parsed.personalizedRecommendations : true,
videoTogetherEnabled: parsed.videoTogetherEnabled !== undefined ? parsed.videoTogetherEnabled : false,
danmakuEnabled: parsed.danmakuEnabled !== undefined ? parsed.danmakuEnabled : false,
danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''),
danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7,
danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20,
danmakuDisplayArea: typeof parsed.danmakuDisplayArea === 'number' ? parsed.danmakuDisplayArea : 0.5,
locale: parsed.locale === 'zh-TW' ? 'zh-TW' : 'zh-CN',
blockedCategories: Array.isArray(parsed.blockedCategories) ? parsed.blockedCategories : [],
};
} catch {
// Even if localStorage fails, we should return defaults + ENV subscriptions
return getDefaultAppSettings();
}
},
listeners: new Set<() => void>(),
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
},
notifyListeners(): void {
this.listeners.forEach((listener) => listener());
},
saveSettings(settings: AppSettings): void {
if (typeof window !== 'undefined') {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
this.notifyListeners();
}
},
exportSettings(includeHistory: boolean = true): string {
return exportSettings(this.getSettings(), includeHistory);
},
importSettings(jsonString: string): boolean {
return importSettings(jsonString, (s) => this.saveSettings(s), this.getSettings());
},
syncEnvSubscriptions(rawEnvValue: string): void {
if (typeof window === 'undefined') return;
const currentSettings = this.getSettings();
const envSubs = getEnvSubscriptions(rawEnvValue);
if (envSubs.length === 0) return;
const mergedSubscriptions = [...currentSettings.subscriptions];
let changed = false;
envSubs.forEach(envSub => {
const existingIndex = mergedSubscriptions.findIndex(s => s.url === envSub.url);
if (existingIndex > -1) {
// Only update if something meaningful changed to avoid unnecessary re-renders
if (mergedSubscriptions[existingIndex].name !== envSub.name) {
mergedSubscriptions[existingIndex] = {
...mergedSubscriptions[existingIndex],
name: envSub.name,
autoRefresh: true
};
changed = true;
}
} else {
mergedSubscriptions.push(envSub);
changed = true;
}
});
if (changed) {
this.saveSettings({
...currentSettings,
subscriptions: mergedSubscriptions
});
}
},
resetToDefaults(): void {
if (typeof window !== 'undefined') {
localStorage.removeItem(SETTINGS_KEY);
localStorage.removeItem(SEARCH_HISTORY_KEY);
localStorage.removeItem(WATCH_HISTORY_KEY);
// Clear all cookies
document.cookie.split(";").forEach((c) => {
document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
});
// Clear cache if available
if ('caches' in window) {
caches.keys().then((names) => {
names.forEach(name => caches.delete(name));
});
}
}
},
};