refactor(meta): make token parser state explicit

This commit is contained in:
jxxghp
2026-08-25 09:03:24 +08:00
parent 1e3507ac59
commit 301d5e7dce
2 changed files with 341 additions and 246 deletions
+281 -246
View File
@@ -1,4 +1,6 @@
import re
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional
from Pinyin2Hanzi import is_pinyin
@@ -51,19 +53,69 @@ SOURCE_NAMES = {
}
class _VideoTokenKind(Enum):
"""记录会影响后续词元判断的识别类型。"""
ENGLISH_NAME = auto()
CHINESE_NAME = auto()
NAME_SEASON_WORD = auto()
PART = auto()
YEAR = auto()
PIX = auto()
SEASON = auto()
SEASON_MARKER = auto()
EPISODE = auto()
EPISODE_MARKER = auto()
SOURCE = auto()
EFFECT = auto()
VIDEO_ENCODE = auto()
VIDEO_BIT = auto()
AUDIO_ENCODE = auto()
FPS = auto()
@dataclass(slots=True)
class _VideoParseState:
"""保存一次视频标题解析中跨词元延续的状态。"""
token_index: int = 0
last_token: str = ""
last_kind: Optional[_VideoTokenKind] = None
stop_name: bool = False
stop_cn_name: bool = False
pending_name: str = ""
sources: list[str] = field(default_factory=list)
effects: list[str] = field(default_factory=list)
def advance(self) -> None:
"""进入下一个由解析管线处理的词元。"""
self.token_index += 1
def remember(self, kind: _VideoTokenKind, token: Optional[str] = None) -> None:
"""原子更新后续规则依赖的上一词元类型和值。"""
self.last_kind = kind
if token is not None:
self.last_token = token
def append_source(self, source: str) -> None:
"""按出现顺序追加规范化资源类型并忽略重复项。"""
source_name = SOURCE_NAMES.get(source.upper(), source)
if source_name.casefold() not in {
item.casefold() for item in self.sources
}:
self.sources.append(source_name)
def replace_last_source(self, source: str, replacement: str) -> None:
"""将拆分的资源类型前缀替换为完整规范名称。"""
if self.sources and self.sources[-1].casefold() == source.casefold():
self.sources.pop()
self.append_source(replacement)
class MetaVideo(MetaBase):
"""
识别电影、电视剧
"""
# 控制标位区
_stop_name_flag = False
_stop_cnname_flag = False
_last_token = ""
_last_token_type = ""
_continue_flag = True
_unknown_name_str = ""
_sources = []
_effect = []
# 正则式区
_season_re = r"S(\d{3})|^S(\d{1,3})$|S(\d{1,3})E"
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
@@ -119,9 +171,7 @@ class MetaVideo(MetaBase):
if not title:
return
original_title = title
self._sources = []
self._effect = []
self._index = 0
state = _VideoParseState()
# 判断是否纯数字命名
if isfile \
and title.isdigit() \
@@ -163,51 +213,14 @@ class MetaVideo(MetaBase):
# 解析名称、年份、季、集、资源类型、分辨率等
token = tokens.get_next()
while token:
self._index += 1 # 更新当前处理的token索引
# Part
self.__init_part(token, tokens)
# 标题
if self._continue_flag:
self.__init_name(token, media_exts)
# 年份
if self._continue_flag:
self.__init_year(token)
# 分辨率
if self._continue_flag:
self.__init_resource_pix(token)
# 季
if self._continue_flag:
self.__init_season(token)
# 集
if self._continue_flag:
self.__init_episode(token)
# 资源类型
if self._continue_flag:
self.__init_resource_type(token)
# 流媒体平台
if self._continue_flag:
self.__init_web_source(token, tokens, streaming_platforms)
# 视频编码
if self._continue_flag:
self.__init_video_encode(token)
# 视频位深
if self._continue_flag:
self.__init_video_bit(token)
# 音频编码
if self._continue_flag:
self.__init_audio_encode(token)
# 帧率
if self._continue_flag:
self.__init_fps(token)
# 取下一个,直到没有为卡
state.advance()
self.__parse_token(token, tokens, streaming_platforms, media_exts, state)
token = tokens.get_next()
self._continue_flag = True
# 合成质量
if self._effect:
self._effect.reverse()
self.resource_effect = " ".join(self._effect)
if self._sources:
self.resource_type = " ".join(self._sources)
if state.effects:
self.resource_effect = " ".join(reversed(state.effects))
if state.sources:
self.resource_type = " ".join(state.sources)
# 提取原盘DIY
if self.resource_type and "BluRay" in self.resource_type:
if (self.subtitle and DIY_RE.search(self.subtitle)) \
@@ -238,6 +251,39 @@ class MetaVideo(MetaBase):
if not self.video_bit:
self.video_bit = self.extract_video_bit(self.video_encode)
def __parse_token(
self,
token: str,
tokens: Tokens,
streaming_platforms: StreamingPlatforms,
media_exts: list,
state: _VideoParseState,
) -> None:
"""按固定优先级处理单个词元,首个命中的阶段终止后续识别。"""
if self.__init_part(token, tokens, state):
return
if self.__init_name(token, media_exts, state):
return
if self.__init_year(token, state):
return
if self.__init_resource_pix(token, state):
return
if self.__init_season(token, state):
return
if self.__init_episode(token, state):
return
if self.__init_resource_type(token, state):
return
if self.__init_web_source(token, tokens, streaming_platforms, state):
return
if self.__init_video_encode(token, state):
return
if self.__init_video_bit(token, state):
return
if self.__init_audio_encode(token, state):
return
self.__init_fps(token, state)
@staticmethod
def __get_title_from_description(description: str) -> Optional[str]:
"""
@@ -285,114 +331,119 @@ class MetaVideo(MetaBase):
name = None
return name
def __init_name(self, token: Optional[str], media_exts: list):
def __init_name(
self,
token: Optional[str],
media_exts: list,
state: _VideoParseState,
) -> bool:
"""
识别名称
"""
if not token:
return
return False
# 回收标题
if self._unknown_name_str:
if state.pending_name:
if not self.cn_name:
if not self.en_name:
self.en_name = self._unknown_name_str
elif self._unknown_name_str != self.year:
self.en_name = "%s %s" % (self.en_name, self._unknown_name_str)
self._last_token_type = "enname"
self._unknown_name_str = ""
if self._stop_name_flag:
return
self.en_name = state.pending_name
elif state.pending_name != self.year:
self.en_name = "%s %s" % (self.en_name, state.pending_name)
state.remember(_VideoTokenKind.ENGLISH_NAME)
state.pending_name = ""
if state.stop_name:
return False
if token.upper() == "AKA":
self._continue_flag = False
self._stop_name_flag = True
return
state.stop_name = True
return True
if token in self._name_se_words:
self._last_token_type = 'name_se_words'
return
state.remember(_VideoTokenKind.NAME_SEASON_WORD)
return False
if text_tools.contains_chinese(token):
# 含有中文,直接做为标题(连着的数字或者英文会保留),且不再取用后面出现的中文
self._last_token_type = "cnname"
state.remember(_VideoTokenKind.CHINESE_NAME)
if not self.cn_name:
self.cn_name = token
elif not self._stop_cnname_flag:
elif not state.stop_cn_name:
if self._name_movie_words_pattern.search(token) \
or (not self._name_no_chinese_pattern.search(token)
and not any(w in token for w in self._name_se_words)):
self.cn_name = "%s %s" % (self.cn_name, token)
self._stop_cnname_flag = True
state.stop_cn_name = True
else:
is_roman_digit = self._roman_numerals_pattern.search(token)
# 阿拉伯数字或者罗马数字
if token.isdigit() or is_roman_digit:
# 第季集后面的不要
if self._last_token_type == 'name_se_words':
return
if state.last_kind == _VideoTokenKind.NAME_SEASON_WORD:
return False
if self.name:
# 名字后面以 0 开头的不要,极有可能是集
if token.startswith('0'):
return
return False
# 检查是否真正的数字
if token.isdigit():
try:
int(token)
except ValueError:
return
return False
# 中文名后面跟的数字不是年份的极有可能是集
if not is_roman_digit \
and self._last_token_type == "cnname" \
and state.last_kind == _VideoTokenKind.CHINESE_NAME \
and int(token) < 1900:
return
return False
if (token.isdigit() and len(token) < 4) or is_roman_digit:
# 4位以下的数字或者罗马数字,拼装到已有标题中
if self._last_token_type == "cnname":
if state.last_kind == _VideoTokenKind.CHINESE_NAME:
self.cn_name = "%s %s" % (self.cn_name, token)
elif self._last_token_type == "enname":
elif state.last_kind == _VideoTokenKind.ENGLISH_NAME:
self.en_name = "%s %s" % (self.en_name, token)
self._continue_flag = False
return True
elif token.isdigit() and len(token) == 4:
# 4位数字,可能是年份,也可能真的是标题的一部分,也有可能是集
if not self._unknown_name_str:
self._unknown_name_str = token
if not state.pending_name:
state.pending_name = token
else:
# 名字未出现前的第一个数字,记下来
if not self._unknown_name_str:
self._unknown_name_str = token
if not state.pending_name:
state.pending_name = token
elif self._season_pattern.search(token):
# 季的处理
if self.en_name and SEASON_SUFFIX_RE.search(self.en_name):
# 如果匹配到季,英文名结尾为Season,说明Season属于标题,不应在后续作为干扰词去除
self.en_name += ' '
self._stop_name_flag = True
return
state.stop_name = True
return False
elif self._episode_pattern.search(token) \
or self._resources_type_pattern.search(token) \
or self._resources_pix_pattern.search(token):
# 集、来源、版本等不要
self._stop_name_flag = True
return
state.stop_name = True
return False
else:
# 后缀名不要
if ".%s".lower() % token in media_exts:
return
return False
# 英文或者英文+数字,拼装起来
if self.en_name:
self.en_name = "%s %s" % (self.en_name, token)
else:
self.en_name = token
self._last_token_type = "enname"
state.remember(_VideoTokenKind.ENGLISH_NAME)
return False
def __init_part(self, token: str, tokens: Tokens):
def __init_part(self, token: str, tokens: Tokens, state: _VideoParseState) -> bool:
"""
识别Part
"""
if not self.name:
return
return False
if not self.year \
and self.begin_season is None \
and not self.begin_episode \
and not self.resource_pix \
and not self.resource_type:
return
return False
re_res = self._part_pattern.search(token)
if re_res:
if not self.part:
@@ -403,22 +454,22 @@ class MetaVideo(MetaBase):
or nextv.upper() in ['A', 'B', 'C', 'I', 'II', 'III']):
self.part = "%s%s" % (self.part, nextv)
tokens.get_next()
self._last_token_type = "part"
self._continue_flag = False
# self._stop_name_flag = False
state.remember(_VideoTokenKind.PART)
return True
return False
def __init_year(self, token: str):
def __init_year(self, token: str, state: _VideoParseState) -> bool:
"""
识别年份
"""
if not self.name:
return
return False
if not token.isdigit():
return
return False
if len(token) != 4:
return
return False
if not 1900 < int(token) < 2050:
return
return False
if self.year:
if self.en_name:
self.en_name = "%s %s" % (self.en_name.strip(), self.year)
@@ -428,21 +479,20 @@ class MetaVideo(MetaBase):
# 如果匹配到年,且英文名结尾为Season,说明Season属于标题,不应在后续作为干扰词去除
self.en_name += ' '
self.year = token
self._last_token_type = "year"
self._continue_flag = False
self._stop_name_flag = True
state.remember(_VideoTokenKind.YEAR)
state.stop_name = True
return True
def __init_resource_pix(self, token: str):
def __init_resource_pix(self, token: str, state: _VideoParseState) -> bool:
"""
识别分辨率
"""
if not self.name:
return
return False
re_res = self._resources_pix_pattern.findall(token)
if re_res:
self._last_token_type = "pix"
self._continue_flag = False
self._stop_name_flag = True
state.remember(_VideoTokenKind.PIX)
state.stop_name = True
resource_pix = None
for pixs in re_res:
if isinstance(pixs, tuple):
@@ -462,25 +512,25 @@ class MetaVideo(MetaBase):
and self.resource_pix.isdigit() \
and self.resource_pix[-1] not in 'kpi':
self.resource_pix = "%sp" % self.resource_pix
else:
re_res = self._resources_pix_pattern2.search(token)
if re_res:
self._last_token_type = "pix"
self._continue_flag = False
self._stop_name_flag = True
if not self.resource_pix:
self.resource_pix = re_res.group(1).lower()
return True
re_res = self._resources_pix_pattern2.search(token)
if re_res:
state.remember(_VideoTokenKind.PIX)
state.stop_name = True
if not self.resource_pix:
self.resource_pix = re_res.group(1).lower()
return True
return False
def __init_season(self, token: str):
def __init_season(self, token: str, state: _VideoParseState) -> bool:
"""
识别季
"""
re_res = self._season_pattern.findall(token)
if re_res:
self._last_token_type = "season"
state.remember(_VideoTokenKind.SEASON)
self.type = MediaType.TV
self._stop_name_flag = True
self._continue_flag = True
state.stop_name = True
for se in re_res:
if isinstance(se, tuple):
se_t = None
@@ -504,34 +554,35 @@ class MetaVideo(MetaBase):
if self.isfile and self.total_season > 1:
self.end_season = None
self.total_season = 1
return False
elif token.isdigit():
try:
int(token)
except ValueError:
return
if self._last_token_type == "SEASON" \
return False
if state.last_kind == _VideoTokenKind.SEASON_MARKER \
and self.begin_season is None \
and len(token) < 3:
self.begin_season = int(token)
self.total_season = 1
self._last_token_type = "season"
self._stop_name_flag = True
self._continue_flag = False
state.remember(_VideoTokenKind.SEASON)
state.stop_name = True
self.type = MediaType.TV
return True
elif token.upper() == "SEASON" and self.begin_season is None:
self._last_token_type = "SEASON"
state.remember(_VideoTokenKind.SEASON_MARKER)
elif self.type == MediaType.TV and self.begin_season is None:
self.begin_season = 1
return False
def __init_episode(self, token: str):
def __init_episode(self, token: str, state: _VideoParseState) -> bool:
"""
识别集
"""
re_res = self._episode_pattern.findall(token)
if re_res:
self._last_token_type = "episode"
self._continue_flag = False
self._stop_name_flag = True
state.remember(_VideoTokenKind.EPISODE)
state.stop_name = True
self.type = MediaType.TV
for se in re_res:
if isinstance(se, tuple):
@@ -556,122 +607,102 @@ class MetaVideo(MetaBase):
if self.isfile and self.total_episode > 2:
self.end_episode = None
self.total_episode = 1
return True
elif token.isdigit():
try:
int(token)
except ValueError:
return
return False
if self.begin_episode is not None \
and self.end_episode is None \
and len(token) < 5 \
and int(token) > self.begin_episode \
and self._last_token_type == "episode":
and state.last_kind == _VideoTokenKind.EPISODE:
self.end_episode = int(token)
self.total_episode = (self.end_episode - self.begin_episode) + 1
if self.isfile and self.total_episode > 2:
self.end_episode = None
self.total_episode = 1
self._continue_flag = False
self.type = MediaType.TV
return True
elif self.begin_episode is None \
and 1 < len(token) < 4 \
and self._last_token_type != "year" \
and self._last_token_type != "videoencode" \
and token != self._unknown_name_str:
and state.last_kind != _VideoTokenKind.YEAR \
and state.last_kind != _VideoTokenKind.VIDEO_ENCODE \
and token != state.pending_name:
self.begin_episode = int(token)
self.total_episode = 1
self._last_token_type = "episode"
self._continue_flag = False
self._stop_name_flag = True
state.remember(_VideoTokenKind.EPISODE)
state.stop_name = True
self.type = MediaType.TV
elif self._last_token_type == "EPISODE" \
return True
elif state.last_kind == _VideoTokenKind.EPISODE_MARKER \
and self.begin_episode is None \
and len(token) < 5:
self.begin_episode = int(token)
self.total_episode = 1
self._last_token_type = "episode"
self._continue_flag = False
self._stop_name_flag = True
state.remember(_VideoTokenKind.EPISODE)
state.stop_name = True
self.type = MediaType.TV
return True
elif token.upper() == "EPISODE":
self._last_token_type = "EPISODE"
state.remember(_VideoTokenKind.EPISODE_MARKER)
return False
def __append_resource_source(self, source: str) -> None:
"""
按出现顺序追加资源类型并忽略重复项。
:param source: 原始资源类型标记
"""
source_name = SOURCE_NAMES.get(source.upper(), source)
if source_name.casefold() not in {
item.casefold() for item in self._sources
}:
self._sources.append(source_name)
def __replace_last_resource_source(self, source: str, replacement: str) -> None:
"""
将拆分的资源类型前缀替换为完整规范名称。
:param source: 待替换的末尾资源类型
:param replacement: 完整资源类型
"""
if self._sources and self._sources[-1].casefold() == source.casefold():
self._sources.pop()
self.__append_resource_source(replacement)
def __init_resource_type(self, token):
def __init_resource_type(self, token: str, state: _VideoParseState) -> bool:
"""
识别资源类型
"""
if not self.name:
return
return False
if token.upper() == "DL" \
and self._last_token_type == "source" \
and self._last_token == "WEB":
self.__replace_last_resource_source("WEB", "WEB-DL")
self._continue_flag = False
return
and state.last_kind == _VideoTokenKind.SOURCE \
and state.last_token == "WEB":
state.replace_last_source("WEB", "WEB-DL")
return True
elif token.upper() == "RAY" \
and self._last_token_type == "source" \
and self._last_token == "BLU":
self.__replace_last_resource_source("BLU", "BluRay")
self._continue_flag = False
return
and state.last_kind == _VideoTokenKind.SOURCE \
and state.last_token == "BLU":
state.replace_last_source("BLU", "BluRay")
return True
elif token.upper() == "WEBDL":
self.__append_resource_source("WEB-DL")
self._continue_flag = False
return
state.append_source("WEB-DL")
return True
source_res = self._source_pattern.search(token)
if source_res:
self._last_token_type = "source"
self._continue_flag = False
self._stop_name_flag = True
state.stop_name = True
source = source_res.group(1)
self.__append_resource_source(source)
self._last_token = source.upper()
return
state.append_source(source)
state.remember(_VideoTokenKind.SOURCE, source.upper())
return True
effect_res = self._effect_pattern.search(token)
if effect_res:
self._last_token_type = "effect"
self._continue_flag = False
self._stop_name_flag = True
state.stop_name = True
effect = effect_res.group(1)
if effect not in self._effect:
self._effect.append(effect)
self._last_token = effect.upper()
if effect not in state.effects:
state.effects.append(effect)
state.remember(_VideoTokenKind.EFFECT, effect.upper())
return True
return False
def __init_web_source(self, token: str, tokens: Tokens, streaming_platforms: StreamingPlatforms):
def __init_web_source(
self,
token: str,
tokens: Tokens,
streaming_platforms: StreamingPlatforms,
state: _VideoParseState,
) -> bool:
"""
识别流媒体平台
"""
if not self.name:
return
return False
platform_name = None
query_range = 1
prev_token = None
prev_idx = self._index - 2
prev_idx = state.token_index - 2
if 0 <= prev_idx < len(tokens.tokens):
prev_token = tokens.tokens[prev_idx]
@@ -698,36 +729,35 @@ class MetaVideo(MetaBase):
break
if not platform_name:
return
return False
web_tokens = ["WEB", "DL", "WEBDL", "WEBRIP"]
match_start_idx = self._index - query_range
match_end_idx = self._index - 1
match_start_idx = state.token_index - query_range
match_end_idx = state.token_index - 1
start_index = max(0, match_start_idx - query_range)
end_index = min(len(tokens.tokens), match_end_idx + 1 + query_range)
tokens_to_check = tokens.tokens[start_index:end_index]
if any(tok and tok.upper() in web_tokens for tok in tokens_to_check):
self.web_source = platform_name
self._continue_flag = False
return True
return False
def __init_video_encode(self, token: str):
def __init_video_encode(self, token: str, state: _VideoParseState) -> bool:
"""
识别视频编码
"""
if not self.name:
return
return False
if not self.year \
and not self.resource_pix \
and not self.resource_type \
and self.begin_season is None \
and not self.begin_episode:
return
return False
re_res = self._video_encode_pattern.search(token)
if re_res:
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "videoencode"
state.stop_name = True
if not self.video_encode:
if re_res.group(2):
self.video_encode = re_res.group(2).upper()
@@ -735,69 +765,71 @@ class MetaVideo(MetaBase):
self.video_encode = re_res.group(3).lower()
else:
self.video_encode = re_res.group(1).upper()
self._last_token = self.video_encode
state.remember(_VideoTokenKind.VIDEO_ENCODE, self.video_encode)
elif self.video_encode == "10bit":
self.video_encode = f"{re_res.group(1).upper()} 10bit"
self._last_token = re_res.group(1).upper()
state.remember(_VideoTokenKind.VIDEO_ENCODE, re_res.group(1).upper())
else:
state.remember(_VideoTokenKind.VIDEO_ENCODE)
return True
elif token.upper() in ['H', 'X']:
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "videoencode"
self._last_token = token.upper() if token.upper() == "H" else token.lower()
state.stop_name = True
last_token = token.upper() if token.upper() == "H" else token.lower()
state.remember(_VideoTokenKind.VIDEO_ENCODE, last_token)
return True
elif token in ["264", "265"] \
and self._last_token_type == "videoencode" \
and self._last_token in ['H', 'X']:
self.video_encode = "%s%s" % (self._last_token, token)
and state.last_kind == _VideoTokenKind.VIDEO_ENCODE \
and state.last_token in ['H', 'X']:
self.video_encode = "%s%s" % (state.last_token, token)
elif token.isdigit() \
and self._last_token_type == "videoencode" \
and self._last_token in ['VC', 'MPEG']:
self.video_encode = "%s%s" % (self._last_token, token)
and state.last_kind == _VideoTokenKind.VIDEO_ENCODE \
and state.last_token in ['VC', 'MPEG']:
self.video_encode = "%s%s" % (state.last_token, token)
elif token.upper() == "10BIT":
self._last_token_type = "videoencode"
state.remember(_VideoTokenKind.VIDEO_ENCODE)
if not self.video_encode:
self.video_encode = "10bit"
else:
self.video_encode = f"{self.video_encode} 10bit"
return False
def __init_video_bit(self, token: str):
def __init_video_bit(self, token: str, state: _VideoParseState) -> bool:
"""
识别视频位深。
"""
if not self.name:
return
return False
if not self.year \
and not self.resource_pix \
and not self.resource_type \
and self.begin_season is None \
and not self.begin_episode:
return
return False
video_bit = self.extract_video_bit(token)
if not video_bit:
return
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "videobit"
return False
state.stop_name = True
state.remember(_VideoTokenKind.VIDEO_BIT)
if not self.video_bit:
self.video_bit = video_bit
return True
def __init_audio_encode(self, token: str):
def __init_audio_encode(self, token: str, state: _VideoParseState) -> bool:
"""
识别音频编码
"""
if not self.name:
return
return False
if not self.year \
and not self.resource_pix \
and not self.resource_type \
and self.begin_season is None \
and not self.begin_episode:
return
return False
re_res = self._audio_encode_pattern.search(token)
if re_res:
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "audioencode"
self._last_token = re_res.group(1).upper()
state.stop_name = True
state.remember(_VideoTokenKind.AUDIO_ENCODE, re_res.group(1).upper())
if not self.audio_encode:
self.audio_encode = re_res.group(1)
else:
@@ -805,29 +837,30 @@ class MetaVideo(MetaBase):
self.audio_encode = "%s-%s" % (self.audio_encode, re_res.group(1))
else:
self.audio_encode = "%s %s" % (self.audio_encode, re_res.group(1))
return True
elif token.isdigit() \
and self._last_token_type == "audioencode":
and state.last_kind == _VideoTokenKind.AUDIO_ENCODE:
if self.audio_encode:
if self._last_token.isdigit():
if state.last_token.isdigit():
self.audio_encode = "%s.%s" % (self.audio_encode, token)
elif self.audio_encode[-1].isdigit() and self.audio_encode.upper() not in {"AC3", "EAC3"}:
self.audio_encode = "%s %s.%s" % (self.audio_encode[:-1], self.audio_encode[-1], token)
else:
self.audio_encode = "%s %s" % (self.audio_encode, token)
self._last_token = token
state.last_token = token
return False
def __init_fps(self, token: str):
def __init_fps(self, token: str, state: _VideoParseState) -> bool:
"""
识别帧率
"""
if not self.name:
return
return False
re_res = self._fps_pattern.search(token)
if re_res:
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "fps"
state.stop_name = True
state.remember(_VideoTokenKind.FPS)
# 提取帧率数值
fps_value = None
if re_res.group(1): # FPS格式
@@ -836,4 +869,6 @@ class MetaVideo(MetaBase):
if fps_value and fps_value.isdigit():
# 只存储纯数值
self.fps = int(fps_value)
self._last_token = f"{self.fps}FPS"
state.last_token = f"{self.fps}FPS"
return True
return False
+60
View File
@@ -0,0 +1,60 @@
import pytest
from app.domain.meta.metavideo import MetaVideo
@pytest.mark.parametrize(
("title", "expected"),
[
(
"Show S01E01 2026 1080p AMZN WEB DL H 265 10bit DDP 5 1 60FPS",
{
"begin_season": 1,
"begin_episode": 1,
"resource_type": "WEB-DL",
"web_source": "Amazon",
"video_encode": "H265 10bit",
"video_bit": "10bit",
"audio_encode": "DDP 5.1",
"fps": 60,
},
),
(
"Movie 2026 2160p UHD Blu Ray REMUX HEVC DTS HD MA 5 1",
{
"begin_season": None,
"begin_episode": None,
"resource_type": "UHD BluRay REMUX",
"web_source": None,
"video_encode": "HEVC",
"video_bit": None,
"audio_encode": "DTS-HD MA 5.1",
"fps": None,
},
),
],
)
def test_metavideo_preserves_cross_token_transitions(title, expected):
"""拆分资源和编码词元应通过显式历史状态连续识别。"""
meta = MetaVideo(title)
actual = {field: getattr(meta, field) for field in expected}
assert actual == expected
def test_metavideo_keeps_transient_token_state_out_of_result_model():
"""解析结果只承载媒体元数据,不泄漏词元管线的临时控制状态。"""
meta = MetaVideo("Show S01E01 2026 1080p WEB DL H 265 AAC 2 0")
transient_fields = {
"_continue_flag",
"_effect",
"_index",
"_last_token",
"_last_token_type",
"_sources",
"_stop_cnname_flag",
"_stop_name_flag",
"_unknown_name_str",
}
assert transient_fields.isdisjoint(vars(meta))