""" 规则域:用户规则组配置访问、内置规则定义与规则解析器, 过滤模块与 Agent 工具共享同一事实来源。 """ import copy import threading from collections.abc import Awaitable, Callable, Iterable, Mapping from dataclasses import dataclass from typing import Any, Dict, List, Optional, Protocol from pyparsing import ( Combine, Forward, Literal, ParseResults, Word, alphanums, alphas, infix_notation, nums, opAssoc, ) from app.application.classification.reference import classification_category_resolver_snapshot from app.application.configuration import ( SystemConfigStagingPort, get_configured_system_config, ) from app.application.outbox import SyncUnitOfWork from app.application.subscription.contract import ( SubscriptionPatch, SubscriptionReferenceStagingPort, SubscriptionSnapshot, ) from app.domain.context import MediaInfo from app.schemas.common import JsonData from app.schemas.rule import CustomRule, FilterRuleGroup from app.schemas.types import SystemConfigKey _RULE_GROUP_LIST_CONFIG_KEYS = ( SystemConfigKey.SearchFilterRuleGroups, SystemConfigKey.SubscribeFilterRuleGroups, SystemConfigKey.BestVersionFilterRuleGroups, ) _RULE_GROUP_DEFAULT_CONFIG_KEYS = ( SystemConfigKey.DefaultMovieSubscribeConfig, SystemConfigKey.DefaultTvSubscribeConfig, SystemConfigKey.DefaultMusicSubscribeConfig, ) class FilterRuleParser(Protocol): """ 描述可选的过滤规则加速解析能力。 """ def parse_filter_rule(self, expression: str) -> Optional[list[Any]]: """ 返回加速解析结构;不可用时返回 None。 :param expression: 由规则标识、逻辑运算和优先级组成的过滤表达式 :return: 解析后的规则结构,原生能力不可用时为 None,由调用方回退到 Python 解析 """ ... _filter_rule_parser: Optional[FilterRuleParser] = None def configure_filter_rule_parser(parser: Optional[FilterRuleParser]) -> None: """ 由启动组合根注入或清除可选规则解析加速器。 """ global _filter_rule_parser _filter_rule_parser = parser class AsyncRuleGroupUnitOfWork(Protocol): """ 异步规则组修改事务的最小提交与回滚端口。 """ async def commit(self) -> None: """ 提交规则定义及全部引用更新。 """ async def rollback(self) -> None: """ 回滚规则定义及全部引用更新。 """ class RuleGroupMutationConflictError(RuntimeError): """ 规则定义已被并发修改,拒绝用过期快照覆盖新值。 """ @dataclass(frozen=True, slots=True) class RuleGroupSubscriptionChange: """ 一条已提交的订阅规则组引用变更。 :param subscribe_id: 受本次规则组变更影响的订阅主键 :param name: 订阅展示名称 :param season: 订阅季号,非剧集订阅可为 None :param filter_groups: 引用更新后订阅实际使用的规则组名称 """ subscribe_id: int name: str season: Optional[int] filter_groups: tuple[str, ...] def to_dict(self) -> dict[str, JsonData]: """ 返回 Agent 响应可直接序列化的兼容字典。 """ return { "subscribe_id": self.subscribe_id, "name": self.name, "season": self.season, "filter_groups": list(self.filter_groups), } @dataclass(frozen=True, slots=True) class RuleGroupMutation: """ 规则定义和全部引用在同一事务提交后的稳定结果。 :param configurations: 本次事务提交后的规则定义与相关系统配置值 :param subscriptions: 本次事务中已更新规则组引用的订阅记录 """ configurations: Mapping[SystemConfigKey, JsonData] subscriptions: tuple[RuleGroupSubscriptionChange, ...] def to_dict(self) -> dict[str, JsonData]: """ 返回现有 Agent 工具使用的引用更新响应结构。 """ return { "global_settings": { key.value: copy.deepcopy(value) for key, value in self.configurations.items() if key != SystemConfigKey.UserFilterRuleGroups }, "subscribes": [change.to_dict() for change in self.subscriptions], } def _map_rule_group_names( values: object, previous_name: Optional[str], current_name: Optional[str], valid_names: set[str], ) -> list[str]: """ 按删除或改名意图映射引用,并按最终定义清理悬空名称。 """ result: list[str] = [] for raw_name in values if isinstance(values, list) else []: name = str(raw_name) if previous_name is not None and name == previous_name: if current_name is None: continue name = current_name if name in valid_names and name not in result: result.append(name) return result def _rewrite_default_config( value: object, previous_name: Optional[str], current_name: Optional[str], valid_names: set[str], ) -> dict[str, JsonData]: """ 复制默认订阅配置并只重写其中的规则组引用。 """ original = copy.deepcopy(value) if isinstance(value, dict) else {} original_groups = original.get("filter_groups") updated_groups = _map_rule_group_names( original_groups, previous_name, current_name, valid_names ) if updated_groups == (original_groups or []): return original original["filter_groups"] = updated_groups return original def _subscription_change( subscription: SubscriptionSnapshot, filter_groups: list[str], ) -> RuleGroupSubscriptionChange: """ 把事务内订阅快照投影为提交结果。 """ return RuleGroupSubscriptionChange( subscribe_id=subscription.id, name=subscription.name, season=subscription.season, filter_groups=tuple(filter_groups), ) class SyncRuleGroupMutationService: """ 在一个同步 UoW 内原子提交规则定义、配置引用和订阅引用。 """ def __init__( self, configuration: SystemConfigStagingPort, subscriptions: SubscriptionReferenceStagingPort, unit_of_work: SyncUnitOfWork, publish: Callable[[Mapping[SystemConfigKey, JsonData]], None], ) -> None: """ 注入共享 Session 的端口、UoW 和必填提交后快照发布器。 """ self._configuration = configuration self._subscriptions = subscriptions self._unit_of_work = unit_of_work self._publish = publish def apply( self, rule_groups: list[dict[str, JsonData]], *, expected_rule_groups: list[dict[str, JsonData]], custom_rules: Optional[list[dict[str, JsonData]]] = None, expected_custom_rules: Optional[list[dict[str, JsonData]]] = None, previous_name: Optional[str] = None, current_name: Optional[str] = None, ) -> RuleGroupMutation: """ 原子保存规则组及可选自定义规则,并重写全部规则组引用。 """ if (custom_rules is None) != (expected_custom_rules is None): raise ValueError("自定义规则写入必须同时提供当前快照和目标值") try: result = self._stage( rule_groups, expected_rule_groups, custom_rules, expected_custom_rules, previous_name, current_name, ) self._unit_of_work.commit() except Exception: self._unit_of_work.rollback() raise self._publish(copy.deepcopy(dict(result.configurations))) return result def _stage( self, rule_groups: list[dict[str, JsonData]], expected_rule_groups: list[dict[str, JsonData]], custom_rules: Optional[list[dict[str, JsonData]]], expected_custom_rules: Optional[list[dict[str, JsonData]]], previous_name: Optional[str], current_name: Optional[str], ) -> RuleGroupMutation: """ 在同步 Session 中锁定事实源并暂存全部变化。 """ changes: dict[SystemConfigKey, JsonData] = {} definitions = copy.deepcopy(rule_groups) valid_names = { str(group["name"]) for group in definitions if isinstance(group.get("name"), str) and group["name"] } original_definitions = self._configuration.get_for_update( SystemConfigKey.UserFilterRuleGroups ) if ( (original_definitions or []) != expected_rule_groups and original_definitions != definitions ): raise RuleGroupMutationConflictError("规则组已被其他请求修改,请重新读取后再试") if original_definitions != definitions: definition_payload = list[JsonData](definitions) self._configuration.stage_set( SystemConfigKey.UserFilterRuleGroups, definition_payload ) changes[SystemConfigKey.UserFilterRuleGroups] = definition_payload if custom_rules is not None: assert expected_custom_rules is not None custom_definitions = copy.deepcopy(custom_rules) original_custom_rules = self._configuration.get_for_update( SystemConfigKey.CustomFilterRules ) if ( (original_custom_rules or []) != expected_custom_rules and original_custom_rules != custom_definitions ): raise RuleGroupMutationConflictError( "自定义规则已被其他请求修改,请重新读取后再试" ) if original_custom_rules != custom_definitions: custom_payload = list[JsonData](custom_definitions) self._configuration.stage_set( SystemConfigKey.CustomFilterRules, custom_payload, ) changes[SystemConfigKey.CustomFilterRules] = custom_payload for key in _RULE_GROUP_LIST_CONFIG_KEYS: original = self._configuration.get_for_update(key) list_updated = _map_rule_group_names( original, previous_name, current_name, valid_names ) if list_updated != (original or []): list_payload = list[JsonData](list_updated) self._configuration.stage_set(key, list_payload) changes[key] = list_payload for key in _RULE_GROUP_DEFAULT_CONFIG_KEYS: original = self._configuration.get_for_update(key) default_updated = _rewrite_default_config( original, previous_name, current_name, valid_names ) if default_updated != (original or {}): self._configuration.stage_set(key, default_updated) changes[key] = default_updated subscription_changes = [] for subscription in self._subscriptions.list_for_reference_rewrite(): original = list(subscription.filter_groups or []) updated = _map_rule_group_names( original, previous_name, current_name, valid_names ) if updated == original: continue self._subscriptions.stage_update( subscription.id, SubscriptionPatch({"filter_groups": list[JsonData](updated)}), ) subscription_changes.append(_subscription_change(subscription, updated)) return RuleGroupMutation(changes, tuple(subscription_changes)) class AsyncRuleGroupMutationService: """ 在一个异步 UoW 内原子提交规则定义、配置引用和订阅引用。 """ def __init__( self, configuration: SystemConfigStagingPort, subscriptions: SubscriptionReferenceStagingPort, unit_of_work: AsyncRuleGroupUnitOfWork, publish: Callable[[Mapping[SystemConfigKey, JsonData]], Awaitable[None]], ) -> None: """ 注入共享 AsyncSession 的端口、UoW 和必填提交后快照发布器。 """ self._configuration = configuration self._subscriptions = subscriptions self._unit_of_work = unit_of_work self._publish = publish async def apply( self, rule_groups: list[dict[str, JsonData]], *, expected_rule_groups: list[dict[str, JsonData]], custom_rules: Optional[list[dict[str, JsonData]]] = None, expected_custom_rules: Optional[list[dict[str, JsonData]]] = None, previous_name: Optional[str] = None, current_name: Optional[str] = None, ) -> RuleGroupMutation: """ 异步原子保存规则组及可选自定义规则,并重写全部引用。 """ if (custom_rules is None) != (expected_custom_rules is None): raise ValueError("自定义规则写入必须同时提供当前快照和目标值") try: result = await self._stage( rule_groups, expected_rule_groups, custom_rules, expected_custom_rules, previous_name, current_name, ) await self._unit_of_work.commit() except Exception: await self._unit_of_work.rollback() raise await self._publish(copy.deepcopy(dict(result.configurations))) return result async def _stage( self, rule_groups: list[dict[str, JsonData]], expected_rule_groups: list[dict[str, JsonData]], custom_rules: Optional[list[dict[str, JsonData]]], expected_custom_rules: Optional[list[dict[str, JsonData]]], previous_name: Optional[str], current_name: Optional[str], ) -> RuleGroupMutation: """ 在 AsyncSession 中锁定事实源并暂存全部变化。 """ changes: dict[SystemConfigKey, JsonData] = {} definitions = copy.deepcopy(rule_groups) valid_names = { str(group["name"]) for group in definitions if isinstance(group.get("name"), str) and group["name"] } original_definitions = await self._configuration.async_get_for_update( SystemConfigKey.UserFilterRuleGroups ) if ( (original_definitions or []) != expected_rule_groups and original_definitions != definitions ): raise RuleGroupMutationConflictError("规则组已被其他请求修改,请重新读取后再试") if original_definitions != definitions: definition_payload = list[JsonData](definitions) await self._configuration.async_stage_set( SystemConfigKey.UserFilterRuleGroups, definition_payload ) changes[SystemConfigKey.UserFilterRuleGroups] = definition_payload if custom_rules is not None: assert expected_custom_rules is not None custom_definitions = copy.deepcopy(custom_rules) original_custom_rules = ( await self._configuration.async_get_for_update( SystemConfigKey.CustomFilterRules ) ) if ( (original_custom_rules or []) != expected_custom_rules and original_custom_rules != custom_definitions ): raise RuleGroupMutationConflictError( "自定义规则已被其他请求修改,请重新读取后再试" ) if original_custom_rules != custom_definitions: custom_payload = list[JsonData](custom_definitions) await self._configuration.async_stage_set( SystemConfigKey.CustomFilterRules, custom_payload, ) changes[SystemConfigKey.CustomFilterRules] = custom_payload for key in _RULE_GROUP_LIST_CONFIG_KEYS: original = await self._configuration.async_get_for_update(key) list_updated = _map_rule_group_names( original, previous_name, current_name, valid_names ) if list_updated != (original or []): list_payload = list[JsonData](list_updated) await self._configuration.async_stage_set(key, list_payload) changes[key] = list_payload for key in _RULE_GROUP_DEFAULT_CONFIG_KEYS: original = await self._configuration.async_get_for_update(key) default_updated = _rewrite_default_config( original, previous_name, current_name, valid_names ) if default_updated != (original or {}): await self._configuration.async_stage_set(key, default_updated) changes[key] = default_updated subscription_changes = [] subscriptions = await self._subscriptions.async_list_for_reference_rewrite() for subscription in subscriptions: original = list(subscription.filter_groups or []) updated = _map_rule_group_names( original, previous_name, current_name, valid_names ) if updated == original: continue await self._subscriptions.async_stage_update( subscription.id, SubscriptionPatch({"filter_groups": list[JsonData](updated)}), ) subscription_changes.append(_subscription_change(subscription, updated)) return RuleGroupMutation(changes, tuple(subscription_changes)) class RuleHelper: """ 读取用户过滤规则配置,并按媒体上下文选择适用规则组。 """ @staticmethod def get_rule_groups() -> List[FilterRuleGroup]: """ 返回用户配置的全部过滤规则组。 """ rule_groups: List[dict[str, Any]] = get_configured_system_config().get( SystemConfigKey.UserFilterRuleGroups ) if not rule_groups: return [] return [FilterRuleGroup(**group) for group in rule_groups] def get_rule_group(self, group_name: str) -> Optional[FilterRuleGroup]: """ 按名称返回过滤规则组。 """ return next( (group for group in self.get_rule_groups() if group.name == group_name), None, ) def get_rule_group_by_media( self, media: Optional[MediaInfo] = None, group_names: Optional[list[str]] = None, ) -> List[FilterRuleGroup]: """ 按媒体类型、生效分类和候选名称筛选适用规则组。 规则组的分类名称通过活动分类策略关联媒体,兼容已有目录路径条件。 :param media: 已识别并应用最终分类的媒体;未提供时仅匹配无类型限制的规则组 :param group_names: 限定规则组名称;为空时检查全部配置规则组 :return: 保持配置顺序的适用规则组,分类不跨主媒体类型匹配 """ rule_groups = self.get_rule_groups() if group_names: rule_groups = [ group for group in rule_groups if group.name in group_names ] category_resolver = classification_category_resolver_snapshot() return [ group for group in rule_groups if not group.media_type or ( media and media.type and group.media_type == media.type.value and (not group.category or category_resolver.matches_media_category(media, group.category)) ) ] @staticmethod def get_custom_rules() -> List[CustomRule]: """ 返回用户配置的全部自定义过滤规则。 """ rules: List[dict[str, Any]] = get_configured_system_config().get( SystemConfigKey.CustomFilterRules ) if not rules: return [] return [CustomRule(**rule) for rule in rules] def get_custom_rule(self, rule_id: str) -> Optional[CustomRule]: """ 按 ID 返回一条自定义过滤规则。 """ return next( (rule for rule in self.get_custom_rules() if rule.id == rule_id), None, ) def replace_group_name_in_list( values: Optional[Iterable[str]], old_name: str, new_name: str, ) -> list[str]: """ 更新配置里的规则组名引用,并顺手去重。 """ result = [] for value in values or []: mapped = new_name if value == old_name else value if mapped not in result: result.append(mapped) return result # 内置规则只在这里维护一份,便于过滤模块和 Agent 工具共享同一套事实来源。 BUILTIN_RULE_SET: Dict[str, dict[str, Any]] = { # 蓝光原盘 "BLU": { "include": [ r"(?i)(\bBlu-?Ray\b.*\b(?:VC-?1|AVC|MPEG-?2)\b|\b(?:UHD|4K|2160p)\b(?:.*Blu-?Ray)?.*\b(?:HEVC|H\.?265)\b|\bBlu-?Ray\b.*\b(?:UHD|4K|2160p)\b.*\b(?:HEVC|H\.?265)\b|\b(?:COMPLETE|FULL)\b.*\b(?:(?:UHD|4K|2160p)\b.*)?Blu-?Ray\b|\b(BD25|BD50|BD66|BD100|BDMV|MiniBD)\b)" ], "exclude": [ r"(?i)(\b[XH]\.?264\b|\b[XH]\.?265\b|\bWEB-?DL\b|\bWEB-?RIP\b|\bHDTV(?:RIP)?\b|\bREMUX\b|\bBDRip\b|\bBRRip\b|\bHDRip\b|\bENCODE\b|\b(? None: """ 定义语法规则 """ with self._lock: if not hasattr(self._thread_local, 'initialized'): # 表达式 expr: Forward = Forward() # 原子 atom: Combine = Combine(Word(alphas, alphanums) | (Word(nums) + Word(alphas, alphanums))) # 逻辑非操作符 operator_not: Literal = Literal('!').set_parse_action(lambda t: 'not') # 逻辑或操作符 operator_or: Literal = Literal('|').set_parse_action(lambda t: 'or') # 逻辑与操作符 operator_and: Literal = Literal('&').set_parse_action(lambda t: 'and') # 定义表达式的语法规则 expr <<= (operator_not + expr) | atom | ('(' + expr + ')') # 运算符优先级 self.expr = infix_notation(expr, [(operator_not, 1, opAssoc.RIGHT), (operator_and, 2, opAssoc.LEFT), (operator_or, 2, opAssoc.LEFT)]) self._thread_local.expr = self.expr self._thread_local.initialized = True else: self.expr = self._thread_local.expr def parse(self, expression: str) -> ParseResults: """ 解析给定的表达式。 :param expression: 待解析的完整规则表达式 :return: 逻辑表达式解析结果 :raises ParseException: 包含不合法语法或无法消费的尾部内容 """ if _filter_rule_parser is not None: try: accelerated = _filter_rule_parser.parse_filter_rule(expression) except Exception: accelerated = None if accelerated is not None: return _RustParseResults(accelerated) return self.expr.parse_string(expression, parse_all=True) class _RustParseResults(list[Any]): """ 包装 Rust 解析结果,提供本模块调用方使用的 as_list/asList 接口。 """ def as_list(self) -> list[Any]: """ 返回兼容 pyparsing.ParseResults.as_list 的列表结构。 """ return list(self) # 保留 pyparsing 的历史驼峰方法名,兼容既有调用方。 def asList(self) -> list[Any]: # noqa: N802 """ 返回兼容 pyparsing.ParseResults.asList 的列表结构。 """ return self.as_list() if __name__ == '__main__': expression_str = """ SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > SPECSUB & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & WEBDL & !DOLBY & !3D > CNSUB & 4K & WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > 4K & !BLU & !REMUX & !DOLBY & HDR & !3D > 4K & !BLURAY & !REMUX & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & !3D > CNSUB & 1080P & WEBDL & !DOLBY & !3D > 1080P & !BLU & !REMUX & !DOLBY & HDR & !3D > 1080P & !BLU & !REMUX & !DOLBY & !3D """ for exp in expression_str.split('>'): parsed_expr = RuleParser().parse(exp.strip()) print(parsed_expr.asList())