选集屏蔽增加相反模式
This commit is contained in:
+7
-24
@@ -46,13 +46,14 @@ import {
|
|||||||
saveSkipConfig,
|
saveSkipConfig,
|
||||||
subscribeToDataUpdates,
|
subscribeToDataUpdates,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
|
import { getDoubanDetail } from '@/lib/douban.client';
|
||||||
|
import { isEpisodeHiddenByFilter, normalizeEpisodeFilterConfig } from '@/lib/episode-filter';
|
||||||
import {
|
import {
|
||||||
buildEpisodeProgressContentKey,
|
buildEpisodeProgressContentKey,
|
||||||
loadLocalEpisodeProgress,
|
loadLocalEpisodeProgress,
|
||||||
pruneLocalEpisodeProgressStorage,
|
pruneLocalEpisodeProgressStorage,
|
||||||
saveLocalEpisodeProgress,
|
saveLocalEpisodeProgress,
|
||||||
} from '@/lib/episode-progress';
|
} from '@/lib/episode-progress';
|
||||||
import { getDoubanDetail } from '@/lib/douban.client';
|
|
||||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||||
import {
|
import {
|
||||||
getRecommendationCache,
|
getRecommendationCache,
|
||||||
@@ -543,10 +544,11 @@ function PlayPageClient() {
|
|||||||
// 加载集数过滤配置
|
// 加载集数过滤配置
|
||||||
const episodeConfig = await getEpisodeFilterConfig();
|
const episodeConfig = await getEpisodeFilterConfig();
|
||||||
if (episodeConfig) {
|
if (episodeConfig) {
|
||||||
setEpisodeFilterConfig(episodeConfig);
|
const normalizedEpisodeConfig = normalizeEpisodeFilterConfig(episodeConfig);
|
||||||
episodeFilterConfigRef.current = episodeConfig;
|
setEpisodeFilterConfig(normalizedEpisodeConfig);
|
||||||
|
episodeFilterConfigRef.current = normalizedEpisodeConfig;
|
||||||
} else {
|
} else {
|
||||||
const defaultEpisodeConfig: EpisodeFilterConfig = { rules: [] };
|
const defaultEpisodeConfig: EpisodeFilterConfig = normalizeEpisodeFilterConfig();
|
||||||
setEpisodeFilterConfig(defaultEpisodeConfig);
|
setEpisodeFilterConfig(defaultEpisodeConfig);
|
||||||
episodeFilterConfigRef.current = defaultEpisodeConfig;
|
episodeFilterConfigRef.current = defaultEpisodeConfig;
|
||||||
}
|
}
|
||||||
@@ -4592,26 +4594,7 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 检查集数是否被过滤
|
// 检查集数是否被过滤
|
||||||
const isEpisodeFilteredByTitle = (title: string): boolean => {
|
const isEpisodeFilteredByTitle = (title: string): boolean => {
|
||||||
const filterConfig = episodeFilterConfigRef.current;
|
return isEpisodeHiddenByFilter(title, episodeFilterConfigRef.current);
|
||||||
if (!filterConfig || filterConfig.rules.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const rule of filterConfig.rules) {
|
|
||||||
if (!rule.enabled) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (rule.type === 'normal' && title.includes(rule.keyword)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (rule.type === 'regex' && new RegExp(rule.keyword).test(title)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('集数过滤规则错误:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextEpisode = async () => {
|
const handleNextEpisode = async () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect, useRef,useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { getEpisodeFilterConfig, saveEpisodeFilterConfig } from '@/lib/db.client';
|
import { getEpisodeFilterConfig, saveEpisodeFilterConfig } from '@/lib/db.client';
|
||||||
|
import { normalizeEpisodeFilterConfig } from '@/lib/episode-filter';
|
||||||
import { EpisodeFilterConfig, EpisodeFilterRule } from '@/lib/types';
|
import { EpisodeFilterConfig, EpisodeFilterRule } from '@/lib/types';
|
||||||
|
|
||||||
interface EpisodeFilterSettingsProps {
|
interface EpisodeFilterSettingsProps {
|
||||||
@@ -21,7 +22,7 @@ export default function EpisodeFilterSettings({
|
|||||||
onConfigUpdate,
|
onConfigUpdate,
|
||||||
onShowToast,
|
onShowToast,
|
||||||
}: EpisodeFilterSettingsProps) {
|
}: EpisodeFilterSettingsProps) {
|
||||||
const [config, setConfig] = useState<EpisodeFilterConfig>({ rules: [] });
|
const [config, setConfig] = useState<EpisodeFilterConfig>(normalizeEpisodeFilterConfig());
|
||||||
const [newKeyword, setNewKeyword] = useState('');
|
const [newKeyword, setNewKeyword] = useState('');
|
||||||
const [newType, setNewType] = useState<'normal' | 'regex'>('normal');
|
const [newType, setNewType] = useState<'normal' | 'regex'>('normal');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -130,9 +131,9 @@ export default function EpisodeFilterSettings({
|
|||||||
try {
|
try {
|
||||||
const loadedConfig = await getEpisodeFilterConfig();
|
const loadedConfig = await getEpisodeFilterConfig();
|
||||||
if (loadedConfig) {
|
if (loadedConfig) {
|
||||||
setConfig(loadedConfig);
|
setConfig(normalizeEpisodeFilterConfig(loadedConfig));
|
||||||
} else {
|
} else {
|
||||||
setConfig({ rules: [] });
|
setConfig(normalizeEpisodeFilterConfig());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载集数过滤配置失败:', error);
|
console.error('加载集数过滤配置失败:', error);
|
||||||
@@ -141,13 +142,31 @@ export default function EpisodeFilterSettings({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleReverseMode = () => {
|
||||||
|
setConfig((prev) => {
|
||||||
|
const normalizedConfig = normalizeEpisodeFilterConfig(prev);
|
||||||
|
return {
|
||||||
|
...normalizedConfig,
|
||||||
|
reverseMode: !normalizedConfig.reverseMode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
const normalizedConfig = normalizeEpisodeFilterConfig(config);
|
||||||
|
if (normalizedConfig.reverseMode && normalizedConfig.rules.length === 0) {
|
||||||
|
if (onShowToast) {
|
||||||
|
onShowToast('启用相反模式时,至少需要添加一条规则', 'info');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveEpisodeFilterConfig(config);
|
await saveEpisodeFilterConfig(normalizedConfig);
|
||||||
if (onConfigUpdate) {
|
if (onConfigUpdate) {
|
||||||
onConfigUpdate(config);
|
onConfigUpdate(normalizedConfig);
|
||||||
}
|
}
|
||||||
if (onShowToast) {
|
if (onShowToast) {
|
||||||
onShowToast('保存成功!', 'success');
|
onShowToast('保存成功!', 'success');
|
||||||
@@ -182,9 +201,13 @@ export default function EpisodeFilterSettings({
|
|||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setConfig((prev) => ({
|
setConfig((prev) => {
|
||||||
rules: [...prev.rules, newRule],
|
const normalizedConfig = normalizeEpisodeFilterConfig(prev);
|
||||||
}));
|
return {
|
||||||
|
...normalizedConfig,
|
||||||
|
rules: [...normalizedConfig.rules, newRule],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 清空输入框并强制重新渲染
|
// 清空输入框并强制重新渲染
|
||||||
setNewKeyword('');
|
setNewKeyword('');
|
||||||
@@ -202,19 +225,27 @@ export default function EpisodeFilterSettings({
|
|||||||
// 删除规则
|
// 删除规则
|
||||||
const handleDeleteRule = (id: string | undefined) => {
|
const handleDeleteRule = (id: string | undefined) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setConfig((prev) => ({
|
setConfig((prev) => {
|
||||||
rules: prev.rules.filter((rule) => rule.id !== id),
|
const normalizedConfig = normalizeEpisodeFilterConfig(prev);
|
||||||
}));
|
return {
|
||||||
|
...normalizedConfig,
|
||||||
|
rules: normalizedConfig.rules.filter((rule) => rule.id !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换规则启用状态
|
// 切换规则启用状态
|
||||||
const handleToggleRule = (id: string | undefined) => {
|
const handleToggleRule = (id: string | undefined) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setConfig((prev) => ({
|
setConfig((prev) => {
|
||||||
rules: prev.rules.map((rule) =>
|
const normalizedConfig = normalizeEpisodeFilterConfig(prev);
|
||||||
rule.id === id ? { ...rule, enabled: !rule.enabled } : rule
|
return {
|
||||||
),
|
...normalizedConfig,
|
||||||
}));
|
rules: normalizedConfig.rules.map((rule) =>
|
||||||
|
rule.id === id ? { ...rule, enabled: !rule.enabled } : rule
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isVisible || !mounted) return null;
|
if (!isVisible || !mounted) return null;
|
||||||
@@ -294,6 +325,37 @@ export default function EpisodeFilterSettings({
|
|||||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4 min-h-0">
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4 min-h-0">
|
||||||
{/* 添加规则 */}
|
{/* 添加规则 */}
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-3">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3 rounded-xl bg-white dark:bg-gray-700/60 border border-gray-200 dark:border-gray-600 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
相反模式
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
|
||||||
|
开启后,将屏蔽改为仅显示符合规则的集数。
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-amber-600 dark:text-amber-400">
|
||||||
|
启用时必须至少保留一条规则才能保存。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleReverseMode}
|
||||||
|
className="flex-shrink-0 active:scale-95 transition-transform duration-150"
|
||||||
|
title={config.reverseMode ? '关闭相反模式' : '开启相反模式'}
|
||||||
|
>
|
||||||
|
{config.reverseMode ? (
|
||||||
|
<ToggleRight
|
||||||
|
size={28}
|
||||||
|
className="text-green-500 hover:text-green-400 transition-colors duration-150"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ToggleLeft
|
||||||
|
size={28}
|
||||||
|
className="text-gray-400 hover:text-gray-300 transition-colors duration-150"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
添加屏蔽规则
|
添加屏蔽规则
|
||||||
</h3>
|
</h3>
|
||||||
@@ -333,7 +395,8 @@ export default function EpisodeFilterSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
|
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||||
💡 普通模式:集数标题包含关键字即屏蔽<br/>
|
💡 普通模式:集数标题包含关键字即命中规则<br/>
|
||||||
|
🔄 相反模式:仅显示命中规则的集数<br/>
|
||||||
🔧 正则模式:支持正则表达式匹配(如:^预告.*匹配以"预告"开头的集数)
|
🔧 正则模式:支持正则表达式匹配(如:^预告.*匹配以"预告"开头的集数)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import React, {
|
|||||||
|
|
||||||
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
||||||
import { generateStorageKey, getCachedPlayRecordsSnapshot } from '@/lib/db.client';
|
import { generateStorageKey, getCachedPlayRecordsSnapshot } from '@/lib/db.client';
|
||||||
|
import { isEpisodeHiddenByFilter } from '@/lib/episode-filter';
|
||||||
import { loadAllLocalEpisodeProgressRecords } from '@/lib/episode-progress';
|
import { loadAllLocalEpisodeProgressRecords } from '@/lib/episode-progress';
|
||||||
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
||||||
@@ -249,29 +250,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
// 获取集数标题
|
// 获取集数标题
|
||||||
const title = episodes_titles?.[episodeNumber - 1];
|
const title = episodes_titles?.[episodeNumber - 1];
|
||||||
if (!title) return false;
|
if (!title) return false;
|
||||||
|
return isEpisodeHiddenByFilter(title, episodeFilterConfig);
|
||||||
// 检查每个启用的规则
|
|
||||||
for (const rule of episodeFilterConfig.rules) {
|
|
||||||
if (!rule.enabled) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (rule.type === 'normal') {
|
|
||||||
// 普通模式:字符串包含匹配
|
|
||||||
if (title.includes(rule.keyword)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (rule.type === 'regex') {
|
|
||||||
// 正则模式:正则表达式匹配
|
|
||||||
if (new RegExp(rule.keyword).test(title)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('集数过滤规则错误:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
[episodeFilterConfig, episodes_titles]
|
[episodeFilterConfig, episodes_titles]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth';
|
import { getAuthInfoFromBrowserCookie, clearAuthCookie } from './auth';
|
||||||
|
import { normalizeEpisodeFilterConfig } from './episode-filter';
|
||||||
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
import { MangaReadRecord, MangaShelfItem } from './manga.types';
|
||||||
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types';
|
import { DanmakuFilterConfig, EpisodeFilterConfig,SkipConfig } from './types';
|
||||||
|
|
||||||
@@ -2643,7 +2644,7 @@ export async function getEpisodeFilterConfig(): Promise<EpisodeFilterConfig | nu
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('moontv_episode_filter_config');
|
const raw = localStorage.getItem('moontv_episode_filter_config');
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
return JSON.parse(raw) as EpisodeFilterConfig;
|
return normalizeEpisodeFilterConfig(JSON.parse(raw) as EpisodeFilterConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('读取集数过滤配置失败:', err);
|
console.error('读取集数过滤配置失败:', err);
|
||||||
return null;
|
return null;
|
||||||
@@ -2662,10 +2663,11 @@ export async function saveEpisodeFilterConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('moontv_episode_filter_config', JSON.stringify(config));
|
const normalizedConfig = normalizeEpisodeFilterConfig(config);
|
||||||
|
localStorage.setItem('moontv_episode_filter_config', JSON.stringify(normalizedConfig));
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('episodeFilterConfigUpdated', {
|
new CustomEvent('episodeFilterConfigUpdated', {
|
||||||
detail: config,
|
detail: normalizedConfig,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { EpisodeFilterConfig } from './types';
|
||||||
|
|
||||||
|
export function normalizeEpisodeFilterConfig(
|
||||||
|
config?: EpisodeFilterConfig | null
|
||||||
|
): EpisodeFilterConfig {
|
||||||
|
return {
|
||||||
|
rules: config?.rules ?? [],
|
||||||
|
reverseMode: config?.reverseMode ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doesEpisodeTitleMatchFilterRules(
|
||||||
|
title: string,
|
||||||
|
config?: EpisodeFilterConfig | null
|
||||||
|
): boolean {
|
||||||
|
const normalizedConfig = normalizeEpisodeFilterConfig(config);
|
||||||
|
|
||||||
|
for (const rule of normalizedConfig.rules) {
|
||||||
|
if (!rule.enabled) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (rule.type === 'normal' && title.includes(rule.keyword)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (rule.type === 'regex' && new RegExp(rule.keyword).test(title)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('集数过滤规则错误:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEpisodeHiddenByFilter(
|
||||||
|
title: string,
|
||||||
|
config?: EpisodeFilterConfig | null
|
||||||
|
): boolean {
|
||||||
|
const normalizedConfig = normalizeEpisodeFilterConfig(config);
|
||||||
|
if (normalizedConfig.rules.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatched = doesEpisodeTitleMatchFilterRules(title, normalizedConfig);
|
||||||
|
return normalizedConfig.reverseMode ? !isMatched : isMatched;
|
||||||
|
}
|
||||||
@@ -253,6 +253,7 @@ export interface EpisodeFilterRule {
|
|||||||
// 集数过滤配置数据结构
|
// 集数过滤配置数据结构
|
||||||
export interface EpisodeFilterConfig {
|
export interface EpisodeFilterConfig {
|
||||||
rules: EpisodeFilterRule[]; // 过滤规则列表
|
rules: EpisodeFilterRule[]; // 过滤规则列表
|
||||||
|
reverseMode?: boolean; // 反向模式:开启后仅显示符合规则的集数
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知类型枚举
|
// 通知类型枚举
|
||||||
|
|||||||
Vendored
+43
@@ -0,0 +1,43 @@
|
|||||||
|
declare module 'sharp' {
|
||||||
|
interface ResizeOptions {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
withoutEnlargement?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PngOptions {
|
||||||
|
compressionLevel?: number;
|
||||||
|
palette?: boolean;
|
||||||
|
quality?: number;
|
||||||
|
effort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JpegOptions {
|
||||||
|
quality?: number;
|
||||||
|
mozjpeg?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Metadata {
|
||||||
|
hasAlpha?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sharp {
|
||||||
|
rotate(): Sharp;
|
||||||
|
resize(options?: ResizeOptions): Sharp;
|
||||||
|
png(options?: PngOptions): Sharp;
|
||||||
|
jpeg(options?: JpegOptions): Sharp;
|
||||||
|
metadata(): Promise<Metadata>;
|
||||||
|
toBuffer(): Promise<Buffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharpOptions {
|
||||||
|
failOn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharpConstructor {
|
||||||
|
(input?: Buffer, options?: SharpOptions): Sharp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharp: SharpConstructor;
|
||||||
|
export default sharp;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user