From 1cb893428899944f8f689212415222336c5fe3d7 Mon Sep 17 00:00:00 2001 From: wumode Date: Mon, 9 Jun 2025 23:59:43 +0800 Subject: [PATCH 1/2] add: LexiAnnot --- icons/LexiAnnot.png | Bin 0 -> 53047 bytes package.v2.json | 12 + plugins.v2/lexiannot/README.md | 54 + plugins.v2/lexiannot/__init__.py | 1561 +++++++++++++++++++++++++ plugins.v2/lexiannot/query_gemini.py | 220 ++++ plugins.v2/lexiannot/requirements.txt | 5 + 6 files changed, 1852 insertions(+) create mode 100644 icons/LexiAnnot.png create mode 100644 plugins.v2/lexiannot/README.md create mode 100644 plugins.v2/lexiannot/__init__.py create mode 100644 plugins.v2/lexiannot/query_gemini.py create mode 100644 plugins.v2/lexiannot/requirements.txt diff --git a/icons/LexiAnnot.png b/icons/LexiAnnot.png new file mode 100644 index 0000000000000000000000000000000000000000..651e0977e025746940550ff42e40bc07d1c5a876 GIT binary patch literal 53047 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 KrCiD8)&Bvg?Hy?V literal 0 HcmV?d00001 diff --git a/package.v2.json b/package.v2.json index 1cd9dc4..b4e2d50 100644 --- a/package.v2.json +++ b/package.v2.json @@ -450,5 +450,17 @@ "history": { "v0.1.0": "新增ClashRuleProvider" } + }, + "LexiAnnot": { + "name": "美剧生词标注", + "description": "根据CEFR等级,为英语影视剧标注高级词汇。", + "labels": "英语", + "version": "1.0", + "icon": "LexiAnnot.png", + "author": "wumode", + "level": 1, + "history": { + "v1.0": "新增LexiAnnot" + } } } diff --git a/plugins.v2/lexiannot/README.md b/plugins.v2/lexiannot/README.md new file mode 100644 index 0000000..4b08489 --- /dev/null +++ b/plugins.v2/lexiannot/README.md @@ -0,0 +1,54 @@ +# 美剧生词标注 + +根据CEFR等级,为英语影视剧标注高级词汇。 + +在影视剧入库后,LexiAnnot会读取媒体文件的MediaInfo和文件列表,如果视频的原始语言为英语并且包含英文文本字幕,LexiAnnot将为其生成包含词汇注释的.ass字幕文件。 + +![](https://images2.imgbox.com/d6/b6/kZu6EH2a_o.png) +![](https://images2.imgbox.com/c8/3a/rEJBWu5v_o.png) +![](https://images2.imgbox.com/97/b7/d6RXFtwD_o.png) + +# Gemini + +- **[获取APIKEY](https://aistudio.google.com/app/apikey)** +- **[速率限制](https://ai.google.dev/gemini-api/docs/rate-limits)** + +**确保可以正常访问下面的域名** + +- googleapis.com +- google.dev +- aistudio.google.com + +# CEFR + +CEFR全称是Common European Framework of Reference for Languages。 + +它是一个国际标准,用于描述语言学习者的语言能力水平。CEFR 将语言能力分为六个级别,并进一步归类为三大使用者类型: + +- **A - 基础使用者 (Basic User)** + - **A1** (初学者/Beginner):能够理解并使用日常熟悉的表达和非常基本的短语。 + - **A2** (初级/Elementary):能够理解基本的表达方式,并以简单的方式进行交流。 +- **B - 独立使用者 (Independent User)** + - **B1** (中级/Intermediate):能够理解熟悉主题的主要观点,可以处理旅行中可能遇到的多数情况,并能就熟悉的话题发表意见和描述。 + - **B2** (中高级/Upper-Intermediate):能够理解复杂文本的主要思想,并能与母语者进行一定程度的流利、自然的互动,可以就广泛的主题进行清晰、详细的阐述。 +- **C - 熟练使用者 (Proficient User)** + - **C1** (高级/Advanced):能够理解各种较长、要求较高的文本,并能识别隐含意义,表达流利、自然,能灵活有效地使用语言来应对各种目的。 + - **C2** (精通/Proficient):能够轻松理解几乎所有听到的或读到的内容,能够非常流利、准确、精细地表达自己,即使在复杂的情况下也能区分细微的含义。 + +# 计划 + +- 双语字幕支持 +- 考试词汇标注 + +# FAQ + +- **为什么需要用到Gemini** + - LexiAnnot使用的词典仅包含约18000个单词,无法覆盖影视剧中的海量的俚语、习语、流行语等更广泛的表达形式 +- **只能处理已有字幕的视频吗?** + - 是的,视频需要包含**英文文本字幕** +- **为什么无法处理一些包含字幕视频** + - 目前无法识别基于图片的字幕(通常是特效字幕) + +# 感谢 + +- [coca-vocabulary-20000](https://github.com/llt22/coca-vocabulary-20000) \ No newline at end of file diff --git a/plugins.v2/lexiannot/__init__.py b/plugins.v2/lexiannot/__init__.py new file mode 100644 index 0000000..c143024 --- /dev/null +++ b/plugins.v2/lexiannot/__init__.py @@ -0,0 +1,1561 @@ +import os +import re +import sys +import json +import subprocess +import time +import threading +import queue +import shutil +from typing import Any, List, Dict, Tuple, Optional, Union, Type, TypeVar +import venv +from pathlib import Path +from collections import Counter + +from apscheduler.schedulers.background import BackgroundScheduler +import pysubs2 +from pysubs2 import SSAFile, SSAEvent +import spacy +from spacy.tokens import Token +import pymediainfo +from langdetect import detect + +from app.core.config import settings +from app.log import logger +from app.plugins import _PluginBase +from app.core.cache import cached +from app.core.event import eventmanager, Event +from app.utils.system import SystemUtils +from app.schemas.types import NotificationType +from app.utils.http import RequestUtils +from app.utils.string import StringUtils +from app.schemas import TransferInfo +from app.schemas.types import EventType +from app.core.context import MediaInfo +from app.plugins.lexiannot.query_gemini import DialogueTranslationTask, VocabularyTranslationTask, Vocabulary, Context + +T = TypeVar('T', VocabularyTranslationTask, DialogueTranslationTask) + +class LexiAnnot(_PluginBase): + # 插件名称 + plugin_name = "美剧生词标注" + # 插件描述 + plugin_desc = "根据CEFR等级,为英语影视剧标注高级词汇。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/wumode/LexiAnnot/refs/heads/master/LexiAnnot.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "wumode" + # 作者主页 + author_url = "https://github.com/wumode" + # 插件配置项ID前缀 + plugin_config_prefix = "lexiannot_" + # 加载顺序 + plugin_order = 50 + # 可使用的用户级别 + auth_level = 1 + + _enabled: bool = False + _annot_level = '' + _send_notify = False + _onlyonce = False + _show_vocabulary_detail = False + _show_phonetics = False + _sentence_translation = False + _in_place = False + _enable_gemini = False + _gemini_model = False + _gemini_apikey = '' + _context_window = 0 + _max_retries = 0 + _request_interval = 0 + _ffmpeg_path = '' + _english_only = False + _when_file_trans = False + _model_temperature = '' + _custom_files = '' + _accent_color = '' + _font_scaling = '' + _opacity = '' + + # 插件数据 + _lexicon_version = '' + _swear_words = None + _cefr_lexicon = None + _coca2k_lexicon = None + + # protected variables + _lexicon_repo = 'https://raw.githubusercontent.com/wumode/LexiAnnot/' + _spacy_model_name = "en_core_web_sm" + _scheduler: Optional[BackgroundScheduler] = None + _nlp = None + _worker_thread = None + _task_queue = None + _shutdown_event = None + _client = None + _total_token_count = 0 + _venv_python = None + _query_gemini_script = '' + _gemini_available = False + _accent_color_rgb = None + _color_alpha = 0 + + def init_plugin(self, config=None): + self._task_queue = queue.Queue() + self.stop_service() + if config: + self._enabled = config.get("enabled") + self._annot_level = config.get("annot_level") + self._send_notify = config.get("send_notify") + self._onlyonce = config.get("onlyonce") + self._show_vocabulary_detail = config.get("show_vocabulary_detail") + self._sentence_translation = config.get("sentence_translation") + self._in_place = config.get("in_place") + self._enable_gemini = config.get("enable_gemini") + self._gemini_model = config.get("gemini_model") + self._gemini_apikey = config.get("gemini_apikey") + self._context_window = config.get("context_window") + self._max_retries = config.get("max_retries") + self._request_interval = config.get("request_interval") + self._ffmpeg_path = config.get("ffmpeg_path") + self._english_only = config.get("english_only") + self._when_file_trans = config.get("when_file_trans") + self._model_temperature = config.get("model_temperature") + self._show_phonetics = config.get("show_phonetics") + self._custom_files = config.get("custom_files") + self._accent_color = config.get("accent_color") + self._font_scaling = config.get("font_scaling") + self._opacity = config.get("opacity") + + self._accent_color_rgb = LexiAnnot.hex_to_rgb(self._accent_color) or (255, 255, 0) + self._color_alpha = int(self._opacity) if self._opacity and len(self._opacity) else 0 + if self._enabled: + self._query_gemini_script = f"{settings.ROOT_PATH}/app/plugins/lexiannot/query_gemini.py" + self._cefr_lexicon = self.get_data("cefr_lexicon") + self._coca2k_lexicon = self.get_data("coca2k_lexicon") + self._swear_words = self.get_data("swear_words") + self._lexicon_version = self.get_data("lexicon_version") + latest = self.__load_lexicon_version() + if not self._lexicon_version or StringUtils.compare_version(self._lexicon_version, '<', latest): + self.__load_lexicon() + try: + if self._nlp is None: + self._nlp = spacy.load(self._spacy_model_name) + except OSError: + self._nlp = LexiAnnot.__load_spacy_model(self._spacy_model_name) + if not (self._nlp and self._cefr_lexicon and self._coca2k_lexicon and self._swear_words): + _loaded = False + else: + _loaded = True + if not _loaded: + logger.warn(f"插件数据未加载,初始化失败") + self._enabled = False + self.__update_config() + return + if self._enable_gemini: + self._gemini_available = True + res = self.init_venv() + if not res: + self._gemini_available = False + if not self._gemini_apikey: + logger.warn(f"未提供GEMINI APIKEY") + self._gemini_available = False + self._shutdown_event = threading.Event() + self._worker_thread = threading.Thread(target=self.__process_tasks, daemon=True) + self._worker_thread.start() + if self._onlyonce: + for file_path in self._custom_files.split("\n"): + if not file_path: + continue + self.add_media_file(file_path) + self._onlyonce = False + self.__update_config() + + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """ + 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 + """ + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'when_file_trans', + 'label': '监控入库', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'send_notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'annot_level', + 'label': '标注词汇的最低CEFR等级', + 'items': [ + {'title': 'B1', 'value': 'B1'}, + {'title': 'B2', 'value': 'B2'}, + {'title': 'C1', 'value': 'C1'}, + {'title': 'C2', 'value': 'C2'}, + {'title': 'C2+', 'value': 'C2+'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'font_scaling', + 'label': '字体缩放', + 'items': [ + {'title': '50%', 'value': '0.5'}, + {'title': '75%', 'value': '0.75'}, + {'title': '100%', 'value': '1'}, + {'title': '125%', 'value': '1.25'}, + {'title': '150%', 'value': '1.5'}, + {'title': '200%', 'value': '2'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'accent_color', + 'label': '强调色', + 'placeholder': '#FFFF00' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'opacity', + 'label': '不透明度', + 'items': [ + {'title': '0', 'value': '0'}, + {'title': '25%', 'value': '63'}, + {'title': '50%', 'value': '127'}, + {'title': '75%', 'value': '191'}, + {'title': '100%', 'value': '255'}, + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'show_phonetics', + 'label': '标注音标', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'in_place', + 'label': '在原字幕插入注释', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'english_only', + 'label': '仅英语影视剧', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'show_vocabulary_detail', + 'label': '显示完整释义', + } + } + ] + }, + + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6, + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enable_gemini', + 'label': '启用Gemini翻译', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'sentence_translation', + 'label': '整句翻译', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6, + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'gemini_model', + 'label': '模型', + 'items': [ + {'title': 'gemini-2.5-flash-preview-05-20', + 'value': 'gemini-2.5-flash-preview-05-20'}, + {'title': 'gemini-2.5-pro-preview-05-06', + 'value': 'gemini-2.5-pro-preview-05-06'}, + {'title': 'gemini-2.0-flash', 'value': 'gemini-2.0-flash'}, + {'title': 'gemini-2.0-flash-lite', 'value': 'gemini-2.0-flash-lite'}, + {'title': 'gemini-1.5-flash', 'value': 'gemini-1.5-flash'}, + {'title': 'gemini-1.5-pro', 'value': 'gemini-1.5-pro'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'gemini_apikey', + 'label': 'Gemini APIKEY', + 'placeholder': '' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'context_window', + 'label': '上下文窗口大小', + 'placeholder': '10', + 'type': 'number', + 'max': 20, + 'min': 1, + 'hint': '向Gemini发送的上下文长度' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'model_temperature', + 'label': '模型温度', + 'items': [ + {'title': '0', 'value': '0'}, + {'title': '0.1', 'value': '0.1'}, + {'title': '0.2', 'value': '0.2'}, + {'title': '0.3', 'value': '0.3'}, + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'max_retries', + 'label': '请求重试次数', + 'placeholder': '3', + 'type': 'number', + 'min': 1, + 'hint': '请求失败重试次数' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'request_interval', + 'label': '请求间隔', + 'type': 'number', + 'placeholder': 5 , + 'min': 1, + 'suffix': '秒', + 'hint': '请求间隔时间,建议不少于3秒' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'ffmpeg_path', + 'label': 'FFmpeg 路径', + 'placeholder': 'ffmpeg' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'custom_files', + 'label': '视频路径', + 'rows': 3, + 'placeholder': '每行一个文件' + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'success', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '配置说明:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://github.com/jxxghp/MoviePilot-Plugins/tree/main/plugins.v2/lexiannot/README.md', + 'target': '_blank' + }, + 'content': [ + { + 'component': 'u', + 'text': 'README' + } + ] + }] + } + + ] + } + ] + } + ] + } + ], { + "enabled": False, + "annot_level": 'C1', + "send_notify": False, + "onlyonce": False, + "show_vocabulary_detail": False, + "show_phonetics": False, + "sentence_translation": False, + "in_place": False, + "enable_gemini": False, + "gemini_model": 'gemini-2.0-flash', + "gemini_apikey": '', + "context_window": 10, + "max_retries": 3, + 'request_interval': 3, + "ffmpeg_path": "", + "english_only": True, + "when_file_trans": True, + "model_temperature": '0.3', + "custom_files": '', + "accent_color": '', + "font_scaling": '1', + "opacity": '0', + } + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_page(self) -> List[dict]: + pass + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + pass + + def get_state(self) -> bool: + """ + 获取插件状态,如果插件正在运行, 则返回True + """ + return self._enabled + + def stop_service(self): + """ + 退出插件 + """ + self.shutdown() + + def shutdown(self): + """ + 关闭插件 + """ + if self._worker_thread and self._worker_thread.is_alive(): + logger.debug("🔻 Stopping existing worker thread...") + self._shutdown_event.set() + self._worker_thread.join() + logger.debug("✅ Existing worker thread stopped.") + else: + logger.debug("ℹ️ No running worker thread to stop.") + + def add_media_file(self, path: str): + """ + 添加新任务 + """ + if not self._shutdown_event.is_set(): + self._task_queue.put(path) + else: + raise RuntimeError("Plugin is shutting down. Cannot add new tasks.") + + def __update_config(self): + self.update_config({'enabled': self._enabled, + 'annot_level': self._annot_level, + 'send_notify': self._send_notify, + 'onlyonce': self._onlyonce, + 'show_vocabulary_detail': self._show_vocabulary_detail, + 'sentence_translation': self._sentence_translation, + 'in_place': self._in_place, + 'enable_gemini': self._enable_gemini, + 'gemini_model': self._gemini_model, + 'gemini_apikey': self._gemini_apikey, + 'context_window': self._context_window, + 'max_retries': self._max_retries, + 'request_interval': self._request_interval, + 'ffmpeg_path': self._ffmpeg_path, + 'english_only': self._english_only, + 'when_file_trans': self._when_file_trans, + 'model_temperature': self._model_temperature, + 'show_phonetics': self._show_phonetics, + 'custom_files': self._custom_files, + 'accent_color': self._accent_color, + 'font_scaling': self._font_scaling, + 'opacity': self._opacity + }) + + def __process_tasks(self): + """ + 后台线程:处理任务队列 + """ + logger.debug("👷 Worker thread started.") + while not self._shutdown_event.is_set(): + try: + task = self._task_queue.get(timeout=1) # 最多等待1秒 + if task is None: + continue + self.__process_file(task) + except queue.Empty: + continue + logger.debug("🛑 Worker received shutdown signal, exiting...") + + def __process_file(self, path: str): + """ + 处理视频文件 + """ + video = Path(path) + if video.suffix.lower() not in settings.RMT_MEDIAEXT: + return + if not video.exists() or not video.is_file(): + logger.warn(f"文件 {str(video)} 不存在, 跳过") + return + subtitle = video.with_suffix(".ass") + if subtitle.exists(): + logger.warn(f"字幕文件 ({subtitle}) 已存在, 跳过") + return + logger.info(f"📂 Processing file: {path}") + if self._send_notify: + message = f"正在处理文件: {path}" + self.post_message(title=f"【{self.plugin_name}】", + mtype=NotificationType.Plugin, + text=f"{message}") + ffmpeg_path = self._ffmpeg_path if self._ffmpeg_path else 'ffmpeg' + embedded_subtitles = LexiAnnot.__extract_subtitles_by_lang(path, 'en', ffmpeg_path) + ret_message = '' + if embedded_subtitles: + logger.info(f'提取到 {len(embedded_subtitles)} 条英语文本字幕') + for embedded_subtitle in embedded_subtitles: + if self._shutdown_event.is_set(): + return + ass_subtitle = pysubs2.SSAFile.from_string(embedded_subtitle['subtitle'], format_='ass') + if embedded_subtitle.get('codec_id') == 'S_TEXT/UTF8': + ass_subtitle = LexiAnnot.set_srt_style(ass_subtitle) + ass_subtitle = self.__set_style(ass_subtitle) + ass_subtitle = self.process_subtitles(ass_subtitle) + if self._shutdown_event.is_set(): + return + if ass_subtitle: + try: + ass_subtitle.save(str(subtitle)) + ret_message = f"字幕已保存:{str(subtitle)}" + except Exception as e: + logger.error(f"字幕文件 {subtitle} 保存失败, {e}") + break + else: + logger.info(f"处理字幕{embedded_subtitle['codec_id']}-{embedded_subtitle['stream_id']}失败") + else: + logger.warn(f"未能在{path}中找到可提取的英文字幕") + if not ret_message: + ret_message= f"未能在{path}中找到可提取的英文字幕" + logger.info(f"✅ Finished: {path}") + if self._send_notify: + self.post_message(title=f"【{self.plugin_name}】", + mtype=NotificationType.Plugin, + text=f"{ret_message}") + + @cached(maxsize=1000, ttl=1800) + def __load_lexicon_version(self) -> Optional[str]: + logger.info(f"正在检查远程词库版本...") + url = f'{self._lexicon_repo}master/version' + version = RequestUtils().get(url, headers=settings.REPO_GITHUB_HEADERS()) + if version is None: + return None + return version + + def __load_lexicon(self): + url = f'{self._lexicon_repo}master/cefr.json' + res = RequestUtils().get_res(url, headers=settings.REPO_GITHUB_HEADERS()) + if res: + self._cefr_lexicon = res.json() + url = f'{self._lexicon_repo}master/coca20k.json' + res = RequestUtils().get_res(url, headers=settings.REPO_GITHUB_HEADERS()) + if res: + self._coca2k_lexicon = res.json() + url = f'{self._lexicon_repo}master/swear_words.json' + res = RequestUtils().get_res(url, headers=settings.REPO_GITHUB_HEADERS()) + if res: + self._swear_words = res.json() + self._lexicon_version = self.__load_lexicon_version() + self.save_data("cefr_lexicon", self._cefr_lexicon) + self.save_data("coca2k_lexicon", self._coca2k_lexicon) + self.save_data("swear_words", self._swear_words) + self.save_data("lexicon_version", self._lexicon_version) + + @staticmethod + def __load_spacy_model(model_name: str): + try: + result = subprocess.run( + [sys.executable, "-m", "spacy", "download", model_name], + capture_output=True, + text=True, + check=True + ) + nlp = spacy.load(model_name) + logger.info(f"spaCy 模型 '{model_name}' 加载成功!") + return nlp + except subprocess.CalledProcessError as e: + logger.error(f"下载 spaCy 模型 '{model_name}' 失败。") + logger.error(f"命令返回非零退出码:{e.returncode}") + logger.error(f"Stdout:\n{e.stdout}") + logger.error(f"Stderr:\n{e.stderr}") + return None + except Exception as e: + logger.error(f"下载或加载 spaCy 模型时发生意外错误:{e}") + return None + + + @eventmanager.register(EventType.TransferComplete) + def check_media(self, event: Event): + if not self._enabled or not self._when_file_trans: + return + event_info: dict = event.event_data + if not event_info: + return + + # 入库数据 + transfer_info: TransferInfo = event_info.get("transferinfo") + if not transfer_info or not transfer_info.target_diritem or not transfer_info.target_diritem.path: + return + mediainfo: MediaInfo = event_info.get("mediainfo") + if self._english_only: + if mediainfo.original_language != 'en': + logger.info(f"原始语言 ({mediainfo.original_language}) 不为英语, 跳过 {mediainfo.title}: ") + return + for new_path in transfer_info.file_list_new: + self.add_media_file(new_path) + + @staticmethod + def query_cefr(word, cefr_lexicon): + word = word.lower().strip("-*'") + if word in cefr_lexicon: + return cefr_lexicon[word] + else: + return None + + @staticmethod + def query_coca20k(word: str, lexicon: Dict[str, Any]): + word = word.lower().strip("-*'") + return lexicon.get(word) + + @staticmethod + def convert_pos_to_spacy(pos: str): + """ + 将给定的词性列表转换为spaCy库中使用的词性标签。 + + Args: + pos: 一个字符串形式词性。 + + Returns: + 一个包含对应spaCy词性标签的列表。对于无法直接映射的词性, + 将返回None。 + """ + spacy_pos_map = { + 'noun': 'NOUN', + 'adjective': 'ADJ', + 'adverb': 'ADV', + 'verb': 'VERB', + 'preposition': 'ADP', + 'conjunction': 'CCONJ', + 'determiner': 'DET', + 'pronoun': 'PRON', + 'interjection': 'INTJ', + 'number': 'NUM' + } + + pos_lower = pos.lower() + if pos_lower in spacy_pos_map: + spacy_pos = spacy_pos_map[pos_lower] + elif pos_lower == 'be-verb': + spacy_pos = 'AUX' # Auxiliary verb (e.g., be, do, have) + elif pos_lower == 'vern': + spacy_pos = 'VERB' # Assuming 'vern' is a typo for 'verb' + elif pos_lower == 'modal auxiliary': + spacy_pos = 'AUX' # Modal verbs are also auxiliaries + elif pos_lower == 'do-verb': + spacy_pos = 'AUX' + elif pos_lower == 'have-verb': + spacy_pos = 'AUX' + elif pos_lower == 'infinitive-to': + spacy_pos = 'PART' # Particle (e.g., to in "to go") + elif not pos_lower: # Handle empty strings + spacy_pos = None + else: + spacy_pos = None # For unmapped POS tags + return spacy_pos + + @staticmethod + def get_cefr_by_spacy(token: Token, cefr_lexicon: Dict[str, Any]) -> Optional[str]: + result = LexiAnnot.query_cefr(token.lemma_, cefr_lexicon) + if result: + all_cefr = [] + if len(result) > 0: + for entry in result: + if token.pos_ == LexiAnnot.convert_pos_to_spacy(entry['pos']): + return entry['cefr'] + all_cefr.append(entry['cefr']) + return min(all_cefr) + return None + + @staticmethod + def format_duration(ms): + total_seconds, milliseconds = divmod(ms, 1000) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + hundredths = milliseconds // 10 + return f"{hours}:{minutes:02}:{seconds:02}.{hundredths:02}" + + @staticmethod + def replace_by_plaintext_positions(line: SSAEvent, replacements: List[dict]): + """ + 替换 line.text 中的内容,使用 replacements 中的 plaintext 位置信息。 + replacement + {'start': int, 'end': int, 'old_text': str, 'new_text': str} + """ + text = line.text + tag_pattern = re.compile(r"{.*?}") # 匹配 {xxx} 格式控制符 + special_pattern = re.compile(r"\\[Nh]") + # 构建 plaintext 位置到 text 索引的映射 + mapping = {} # plaintext_index -> text_index + p_index = 0 # 当前 plaintext 索引 + t_index = 0 # 当前 text 索引 + + while t_index < len(text): + if text[t_index] == "{": + # 跳过格式标签 + match = tag_pattern.match(text, t_index) + if match: + t_index = match.end() + continue + elif text[t_index] == "\\": + match = special_pattern.match(text, t_index) + if match: + t_index = match.end() - 1 + continue + # 非格式字符 + mapping[p_index] = t_index + p_index += 1 + t_index += 1 + + # 按照 mapping 执行替换(倒序替换防止位置错位) + new_text = text + for r in sorted(replacements, key=lambda x: x["start"], reverse=True): + start = mapping.get(r["start"]) + end = mapping.get(r["end"] - 1) + if start is None or end is None: + continue + end += 1 # 因为 Python 切片不包含结束索引 + new_text = new_text[:start] + r["new_text"] + new_text[end:] + + line.text = new_text + + @staticmethod + def analyze_ass_language(ass_file: SSAFile): + styles = {} + for style in ass_file.styles: + styles[style] = {'text': [], 'duration': 0, 'text_size': 0, 'times': 0} + for dialogue in ass_file: + style = dialogue.style + text = dialogue.plaintext + sub_text = text.split('\n') + if style not in styles or not text: continue + styles[style]['text'].extend(sub_text) + styles[style]['duration'] += dialogue.duration + styles[style]['text_size'] += len(text) + styles[style]['times'] += 1 + style_language_analysis = {} + for style_name, data in styles.items(): + all_text = ' '.join(data['text']) + if not all_text.strip(): + style_language_analysis[style_name] = None + continue + + languages = [] + # 对每个文本片段进行语言检测 + for text_fragment in data['text']: + try: + lang = detect(text_fragment) + languages.append(lang) + except: + pass # 无法检测的文本 + + if languages: + language_counts = Counter(languages) + most_common_language = language_counts.most_common(1)[0] + style_language_analysis[style_name] = {"main_language": most_common_language[0], + "proportion": most_common_language[1] / len(languages), + "duration": data['duration'], + "text_size": data['text_size'], + "times": data['times']} + else: + style_language_analysis[style_name] = None + + return style_language_analysis + + @staticmethod + def select_main_style_weighted(language_analysis: Dict[str, Any], known_language: str, + weights=None): + """ + 根据语言分析结果和已知的字幕语言,使用加权评分选择主要样式。 + + Args: + language_analysis (dict): `analyze_ass_language` 函数的输出结果。 + known_language (str): 已知的字幕语言代码. + weights (dict): 各个维度的权重,权重之和应为 1. + + Returns: + str or None: 主要字幕的样式名称,如果没有匹配的样式则返回 None。 + """ + if weights is None: + weights = {'times': 0.5, 'text_size': 0.4, 'duration': 0.1} + matching_styles = [] + max_times = max([analysis.get('times', 0) for _, analysis in language_analysis.items() if analysis]) or 1 + max_text_size = max( + [analysis.get('text_size', 0) for _, analysis in language_analysis.items() if analysis]) or 1 + max_duration = max([analysis.get('duration', 0) for _, analysis in language_analysis.items() if analysis]) or 1 + for style, analysis in language_analysis.items(): + if not analysis: + continue + if analysis.get('main_language') == known_language: + # 跳过多语言 + if analysis.get('proportion', 0) < 0.5: + continue + score = 0 + score += analysis.get('times', 0) * weights.get('times', 0) / max_times + score += analysis.get('text_size', 0) * weights.get('text_size', 0) / max_text_size + score += analysis.get('duration', 0) * weights.get('duration', 0) / max_duration + matching_styles.append((style, score)) + + if not matching_styles: + return None + + sorted_styles = sorted(matching_styles, key=lambda item: item[1], reverse=True) + return sorted_styles[0][0] + + @staticmethod + def set_srt_style(ass: SSAFile) -> SSAFile: + ass.info['ScaledBorderAndShadow'] = 'no' + play_res_y = int(ass.info.get('PlayResY')) + play_res_x = int(ass.info.get('PlayResX')) + if 'Default' in ass.styles: + ass.styles['Default'].marginv = play_res_y // 16 + ass.styles['Default'].fontname = 'Microsoft YaHei' + ass.styles['Default'].fontsize = play_res_y // 16 + return ass + + def __set_style(self, ass: SSAFile) -> SSAFile: + font_scaling = float(self._font_scaling) if self._font_scaling and len(self._font_scaling) else 1 + play_res_y = int(ass.info.get('PlayResY')) + play_res_x = int(ass.info.get('PlayResX')) + # 创建一个新样式 + fs = play_res_y // 16*font_scaling + new_style = pysubs2.SSAStyle() + new_style.name = 'Annotation EN' + new_style.fontname = 'Times New Roman' + new_style.fontsize = fs + new_style.primarycolor = pysubs2.Color(self._accent_color_rgb[0], + self._accent_color_rgb[1], + self._accent_color_rgb[2], + self._color_alpha) # 黄色 (BGR, alpha) + new_style.bold = True + new_style.italic = False + new_style.outline = 1 + new_style.shadow = 0 + new_style.alignment = pysubs2.Alignment.TOP_LEFT + new_style.marginl = play_res_x // 20 + new_style.marginr = play_res_x // 20 + new_style.marginv = fs + ass.styles['Annotation EN'] = new_style + zh_style = new_style.copy() + zh_style.name = 'Annotation ZH' + zh_style.fontname = 'Microsoft YaHei' + zh_style.primarycolor = pysubs2.Color(255, 255, 255, self._color_alpha) + ass.styles['Annotation ZH'] = zh_style + + pos_style = zh_style.copy() + pos_style.name = 'Annotation POS' + pos_style.fontname = 'Times New Roman' + pos_style.fontsize = fs * 0.75 + pos_style.italic = True + ass.styles['Annotation POS'] = pos_style + + phone_style = pos_style.copy() + phone_style.name = 'Annotation PHONE' + phone_style.fontname = 'Arial' + phone_style.fontsize = fs * 0.75 + phone_style.bold = False + phone_style.italic = False + ass.styles['Annotation PHONE'] = phone_style + + pos_def_cn_style = zh_style.copy() + pos_def_cn_style.name = 'DETAIL CN' + pos_def_cn_style.fontsize = fs * 0.7 + ass.styles['DETAIL CN'] = pos_def_cn_style + + pos_def_pos_style = pos_style.copy() + pos_def_pos_style.name = 'DETAIL POS' + pos_def_pos_style.fontsize = fs * 0.6 + ass.styles['DETAIL POS'] = pos_def_pos_style + + cefr_style = pos_style.copy() + cefr_style.name = "Annotation CEFR" + cefr_style.fontname = "Times New Roman" + cefr_style.fontsize = fs * 0.5 + cefr_style.bold = True + cefr_style.italic = False + cefr_style.primarycolor = pysubs2.Color(self._accent_color_rgb[0], + self._accent_color_rgb[1], + self._accent_color_rgb[2], + self._color_alpha) + cefr_style.outline = 1 + cefr_style.shadow = 0 + ass.styles['Annotation CEFR'] = cefr_style + return ass + + @staticmethod + def hex_to_rgb(hex_color) -> Optional[Tuple]: + if not hex_color: + return None + pattern = r'^#[0-9a-fA-F]{6}$' + if re.match(pattern, hex_color) is None: + return None + hex_color = hex_color.lstrip('#') # 去掉前面的 # + return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) + + @staticmethod + def __extract_subtitle(video_path: str, + subtitle_stream_index: str, + ffmpeg_path: str='ffmpeg', + sub_format='ass') -> Optional[str]: + if sub_format not in ['srt', 'ass']: + raise ValueError('Invalid subtitle format') + try: + map_parameter = f"0:s:{subtitle_stream_index}" + command = [ + ffmpeg_path, + '-i', video_path, + '-map', map_parameter, + '-f', sub_format, + '-' + ] + result = subprocess.run(command, capture_output=True, text=True, encoding='utf-8', check=True) + return result.stdout + except FileNotFoundError: + logger.warn(f"错误:找不到视频文件 '{video_path}'") + return None + except subprocess.CalledProcessError as e: + logger.warn(f"错误:提取字幕失败。\n错误信息:{e}") + logger.warn(f"FFmpeg 输出 (stderr):\n{e.stderr.decode('utf-8', errors='ignore')}") + return None + + @staticmethod + def __extract_subtitles_by_lang(video_path: str, lang: str = 'en', ffmpeg: str = 'ffmpeg') -> Optional[List[Dict]]: + """提取视频文件中的内嵌英文字幕,使用 MediaInfo 查找字幕流。""" + supported_codec = ['S_TEXT/UTF8', 'S_TEXT/ASS'] + subtitles = [] + try: + media_info: pymediainfo.MediaInfo = pymediainfo.MediaInfo.parse(video_path) + for track in media_info.tracks: + if track.track_type == 'Text' and track.language == lang and track.codec_id in supported_codec: + if track.title and 'SDH' in track.title: + continue + subtitle_stream_index = track.stream_identifier # MediaInfo 的 stream_id 从 1 开始,ffmpeg 从 0 开始 + subtitle = LexiAnnot.__extract_subtitle(video_path, subtitle_stream_index, ffmpeg) + if subtitle: + subtitles.append({'title': track.title, 'subtitle': subtitle, 'codec_id': track.codec_id, + 'stream_id': subtitle_stream_index}) + if subtitles: + return subtitles + else: + logger.warn('未找到标记为英语的文本字幕流') + return None + + except FileNotFoundError: + logger.error(f"找不到视频文件 '{video_path}'") + return None + except subprocess.CalledProcessError as e: + logger.error(f"错误:提取字幕失败。\n错误信息:{e}") + logger.error(f"FFmpeg 输出 (stderr):\n{e.stderr.decode('utf-8', errors='ignore')}") + return None + except Exception as e: + logger.error(f"使用 MediaInfo 提取字幕时发生错误:{e}") + return None + + def init_venv(self) -> bool: + venv_dir = os.path.join(self.get_data_path(), "venv_genai") + python_path = os.path.join(venv_dir, "bin", "python") if os.name != "nt" else os.path.join(venv_dir, "Scripts", + "python.exe") + # 创建虚拟环境 + try: + if not os.path.exists(venv_dir): + logger.info(f"为 google-genai 初始化虚拟环境: {venv_dir}") + venv.create(venv_dir, with_pip=True, symlinks=True, clear=True) + logger.info(f"虚拟环境创建成功: {venv_dir}") + SystemUtils.execute_with_subprocess([python_path, "-m", "pip", "install", 'google-genai']) + except subprocess.CalledProcessError: + logger.warn(f"虚拟环境创建失败") + shutil.rmtree(venv_dir) + return False + self._venv_python = python_path + + return True + + def __query_gemini( + self, + tasks: List[T], + task_type: Type[T], + api_key: str, + system_instruction: str, + model: str, + temperature: float + ) -> List[T]: + input_dict = { + 'tasks': [task.dict() for task in tasks], # 保证是可序列化格式 + 'params': { + 'api_key': api_key, + 'system_instruction': system_instruction, + 'schema': task_type.__name__, + 'model': model, + 'temperature': temperature, + 'max_retries': self._max_retries + } + } + + try: + result = subprocess.run( + [self._venv_python, self._query_gemini_script], + input=json.dumps(input_dict), + capture_output=True, + text=True, + check=True + ) + except subprocess.CalledProcessError as e: + logger.warning(f"Subprocess failed: {str(e)}") + return tasks + + try: + response = json.loads(result.stdout) + except json.JSONDecodeError: + logger.warning(f"Invalid JSON from subprocess:\n{result.stdout}") + return tasks + + if not response.get("success"): + logger.warning(f"Error in subprocess response: {response.get('message')}") + return tasks + + try: + return [task_type(**task_data) for task_data in response["data"]["tasks"]] + except Exception as e: + logger.warning(f"Failed to reconstruct tasks: {str(e)}") + return tasks + + def __process_by_ai(self, lines_to_process: List[Dict[str, Any]], cefr_lexicon, swear_words, coca20k_lexicon): + simple_vocabulary = list(filter(lambda x:x= self._context_window or (len(task_bulk) and i == len(lines_to_process)): + logger.info(f"processing dialogues: " + f"{LexiAnnot.format_duration(lines_to_process[task_bulk[0].index]['time_code'][0])} -> " + f"{LexiAnnot.format_duration(lines_to_process[i - 1]['time_code'][1])}") + answer: Optional[List[VocabularyTranslationTask]] = self.__query_gemini(task_bulk, + VocabularyTranslationTask, + self._gemini_apikey, + vocabulary_trans_instruction, + self._gemini_model, + model_temperature) + if not answer: + continue + time.sleep(self._request_interval) + for answer_line in answer: + answer_lemma = tuple(v.lemma for v in answer_line.vocabulary) + filtered_raw = [x for x in lines_to_process if x.get('index') == answer_line.index] + if not len(filtered_raw): + logger.warn(f'Unknown answer: {answer_line.index}: {answer_line.context.original_text}') + available_answer = False + for item in filtered_raw: + lemma = tuple(v['lemma'] for v in item['new_vocab']) + if lemma == answer_lemma: + available_answer = True + for i_, v in enumerate(item['new_vocab']): + v['Chinese'] = answer_line.vocabulary[i_].Chinese + break + if not available_answer: + logger.warn(f'Unknown answer: {answer_line.index}: {answer_line.context.original_text}') + task_bulk = [] + if not self._sentence_translation: + return lines_to_process + if self._gemini_available: + logger.info(f"查询整句翻译...") + # 查询整句翻译 + translation_tasks: List[DialogueTranslationTask] = [] + for line_data in lines_to_process: + translation_tasks.append(DialogueTranslationTask(index=line_data['index'], + original_text=line_data['raw_subtitle'].replace('\n', ' '), + Chinese='')) + i = 0 + dialog_trans_instruction = '''You are an expert translator. You will be given a list of dialogue translation tasks in JSON format. For each entry, provide the most appropriate translation in Simplified Chinese based on the context. + Only complete the `Chinese` field. Do not include pinyin, explanations, or any additional information.''' + while i < len(translation_tasks): + if self._shutdown_event.is_set(): + return lines_to_process + if not self._gemini_available: + break + start_index = max(0, i - 1) + end_index = min(len(translation_tasks), i + self._context_window + 1) + task_bulk: List[DialogueTranslationTask] = translation_tasks[start_index:end_index] + logger.info(f"processing dialogues: " + f"{LexiAnnot.format_duration(lines_to_process[i]['time_code'][0])} -> " + f"{LexiAnnot.format_duration(lines_to_process[min(len(translation_tasks), i + self._context_window)-1]['time_code'][1])}") + answer: List[DialogueTranslationTask] = self.__query_gemini(task_bulk, + DialogueTranslationTask, + self._gemini_apikey, + dialog_trans_instruction, + self._gemini_model, + model_temperature) + time.sleep(self._request_interval) + for answer_line in answer: + if answer_line.index not in range(i, i+self._context_window): + continue + filtered_raw = [x for x in lines_to_process if x.get('index') == answer_line.index] + if not len(filtered_raw): + logger.warn(f'Unknown answer: {answer_line.index}: {answer_line.original_text}') + available_answer = False + for item in filtered_raw: + if item['raw_subtitle'].replace('\n', ' ') == answer_line.original_text: + available_answer = True + item['Chinese'] = answer_line.Chinese + break + if not available_answer: + logger.warn(f'Unknown answer: {answer_line.index}: {answer_line.original_text}') + i += self._context_window + return lines_to_process + + def process_subtitles(self, ass_file: SSAFile) -> Optional[SSAFile]: + """ + 处理字幕内容,标记词汇并添加翻译。 + """ + lang = 'en' + cefr_lexicon = self._cefr_lexicon + swear_words = self._swear_words + coca20k_lexicon = self._coca2k_lexicon + abgr_str = (f'&H{self._color_alpha:02x}{self._accent_color_rgb[2]:02x}' + f'{self._accent_color_rgb[1]:02x}{self._accent_color_rgb[0]:02x}&') #&H00FFFFFF& + pos_map = { + 'NOUN': 'n.', + 'AUX': 'aux.', + 'VERB': 'v.', + 'ADJ': 'adj.', + 'ADV': 'adv.', + 'ADP': 'prep.', + 'CCONJ': 'conj.', + 'SCONJ': 'conj.' + } + statistical_res = LexiAnnot.analyze_ass_language(ass_file) + main_style = LexiAnnot.select_main_style_weighted(statistical_res, lang) + if not main_style: + logger.error(f'无法确定主要字幕样式') + return None + index = 0 + lines_to_process = [] + main_dialogue: Dict[int, SSAEvent] = {} + for dialogue in ass_file: + if dialogue.style != main_style: + continue + time_code = (dialogue.start, dialogue.end) + text_raw = dialogue.plaintext + line_data = {'index': index, 'time_code': time_code, 'raw_subtitle': text_raw, 'new_vocab': [], + 'Chinese': ''} + lines_to_process.append(line_data) + main_dialogue[index] = dialogue + index += 1 + lines_to_process = self.__process_by_ai(lines_to_process, cefr_lexicon, swear_words, coca20k_lexicon) + + # 在原字幕添加标注 + main_style_fs = ass_file.styles[main_style].fontsize + for line_data in lines_to_process: + if self._shutdown_event.is_set(): + return None + if line_data['new_vocab']: + replacements = line_data['new_vocab'] + for replacement in replacements: + part_of_speech = f"{{\\fnTimes New Roman\\fs{int(main_style_fs * 0.75)}\\i1}}{pos_map[replacement['pos']]}{{\\r}}" + new_text = f"{{\\c{abgr_str}}}{replacement['text']}{{\\r}}" + if self._in_place: + new_text = new_text + f" ({replacement['Chinese']} {part_of_speech})" if replacement[ + 'Chinese'] else "" + else: + dialogue = pysubs2.SSAEvent() + dialogue.start = main_dialogue[line_data['index']].start + dialogue.end = main_dialogue[line_data['index']].end + dialogue.style = 'Annotation EN' + cefr_text = f" {{\\rAnnotation CEFR}}{replacement['cefr']}{{\\r}}" if replacement[ + 'cefr'] else "" + __N = r'\N' + phone_text = f"{__N}{{\\rAnnotation PHONE}}/{replacement['phonetics']}/{{\\r}}" if replacement[ + 'phonetics'] and self._show_phonetics else "" + annot_text = f"{replacement['lemma']} {{\\rAnnotation POS}}{pos_map[replacement['pos']]}{{\\r}} {{\\rAnnotation ZH}}{replacement['Chinese']}{{\\r}}{cefr_text}{phone_text}" + dialogue.text = annot_text + ass_file.append(dialogue) + if self._show_vocabulary_detail and replacement['pos_defs']: + dialogue = pysubs2.SSAEvent() + dialogue.start = main_dialogue[line_data['index']].start + dialogue.end = main_dialogue[line_data['index']].end + dialogue.style = 'DETAIL CN' + detail_text = [] + for pos_def in replacement['pos_defs']: + meaning_str = ', '.join(pos_def['meanings']) + pos_text = f"{{\\rDETAIL POS}}{pos_def['pos']}{{\\r}} {meaning_str}" + detail_text.append(pos_text) + dialogue.text = '\\N'.join(detail_text) + ass_file.append(dialogue) + replacement['new_text'] = new_text + LexiAnnot.replace_by_plaintext_positions(main_dialogue[line_data['index']], replacements) + if self._sentence_translation: + chinese = line_data['Chinese'] + if chinese and chinese[-1] in ['。', ',']: + chinese = chinese[:-1] + main_dialogue[line_data['index']].text = main_dialogue[line_data['index']].text + f"\\N{chinese}" + return ass_file + diff --git a/plugins.v2/lexiannot/query_gemini.py b/plugins.v2/lexiannot/query_gemini.py new file mode 100644 index 0000000..243e0c4 --- /dev/null +++ b/plugins.v2/lexiannot/query_gemini.py @@ -0,0 +1,220 @@ +import sys +import json +import time +from typing import List, Dict, Any, Type, Union + +from pydantic import BaseModel, ValidationError + + +class Context(BaseModel): + original_text: str + + +class Vocabulary(BaseModel): + lemma: str + Chinese: str + + +class VocabularyTranslationTask(BaseModel): + index: int + vocabulary: List[Vocabulary] + context: Context + + +class DialogueTranslationTask(BaseModel): + index: int + original_text: str + Chinese: str + + +class GeminiResponse(BaseModel): + tasks: List[Union[VocabularyTranslationTask, DialogueTranslationTask]] + total_token_count: int + success: bool + message: str = "" + + +def validate_input_data(request_data: Dict[str, Any]) -> None: + """Validate the input data structure""" + if not isinstance(request_data, dict): + raise ValueError("Input data must be a dictionary") + if "tasks" not in request_data: + raise ValueError("Missing 'tasks' in input data") + if "params" not in request_data: + raise ValueError("Missing 'params' in input data") + + params = request_data["params"] + required_params = ["api_key", "system_instruction", "schema"] + for param in required_params: + if param not in params: + raise ValueError(f"Missing required parameter: {param}") + + +def get_task_schema(schema_name: str) -> Type[BaseModel]: + """Get the appropriate schema class based on the schema name""" + schema_map = { + 'DialogueTranslationTask': DialogueTranslationTask, + 'VocabularyTranslationTask': VocabularyTranslationTask + } + if schema_name not in schema_map: + raise ValueError(f"Unknown schema name: {schema_name}") + return schema_map[schema_name] + + +def query_gemini( + api_key: str, + translation_tasks: List[Dict[str, Any]], + task_schema: Type[Union[VocabularyTranslationTask, DialogueTranslationTask]], + system_instruction: str, + gemini_model: str = "gemini-2.0-flash", + temperature: float = 0.3, + max_retries: int = 3, + retry_delay: int = 10 +) -> GeminiResponse: + """ + Query the Gemini API for translation tasks with retry logic. + + Args: + api_key: Gemini API key + translation_tasks: List of translation tasks + task_schema: Pydantic model for the task type + system_instruction: System instruction for the model + gemini_model: Model name to use + temperature: Generation temperature + max_retries: Number of retry attempts + retry_delay: Delay between retries in seconds + + Returns: + GeminiResponse containing the results + """ + from google import genai + from google.genai import types + from google.genai.types import SchemaUnion + client = genai.Client(api_key=api_key) + messages = [] + translation_res = [] + total_token_count = 0 + + # Validate input tasks before sending to API + try: + translation_res = [task_schema(**task) for task in translation_tasks] + except ValidationError as e: + return GeminiResponse( + tasks=[], + total_token_count=0, + success=False, + message=f"Input validation failed: {str(e)}" + ) + + for attempt in range(1, max_retries + 1): + try: + response = client.models.generate_content( + model=gemini_model, + contents=json.dumps(translation_tasks, ensure_ascii=False), + config=types.GenerateContentConfig( + system_instruction=system_instruction, + response_mime_type="application/json", + response_schema=list[task_schema], + temperature=temperature + ), + ) + + if not response.parsed: + raise ValueError("Empty response from Gemini API") + + translation_res = response.parsed + total_token_count = response.usage_metadata.total_token_count + return GeminiResponse( + tasks=translation_res, + total_token_count=total_token_count, + success=True + ) + + except Exception as e: + messages.append(f"Attempt {attempt} failed: {str(e)}") + if attempt < max_retries: + time.sleep(retry_delay) + + return GeminiResponse( + tasks=[], + total_token_count=0, + success=False, + message="All retry attempts failed. " + "\n".join(messages) + ) + + +def main(): + try: + # Read and parse input + '''{ + "tasks": [{ + "index": 0, + "original_text": "That was eight years ago.", + "Chinese": "" + }, { + "index": 1, + "original_text": "Much has changed.", + "Chinese": "" + }], + "params": { + "api_key": "", + "system_instruction": "You are an expert translator. You will be given a list of dialogue translation tasks in JSON format. For each entry, provide the most appropriate translation in Simplified Chinese based on the context. \\nOnly complete the `Chinese` field. Do not include pinyin, explanations, or any additional information.", + "schema": "DialogueTranslationTask" + } + }''' + input_text = sys.stdin.read() + if not input_text: + raise ValueError("No input provided") + + request_data = json.loads(input_text) + validate_input_data(request_data) + + # Extract parameters + tasks = request_data["tasks"] + params = request_data["params"] + + # Get schema and make API call + schema = get_task_schema(params["schema"]) + response = query_gemini( + api_key=params["api_key"], + translation_tasks=tasks, + task_schema=schema, + system_instruction=params["system_instruction"], + gemini_model=params.get("model", "gemini-2.0-flash"), + temperature=float(params.get("temperature", 0.3)), + max_retries=int(params.get("max_retries", 3)) + ) + + # Prepare output + if response.success: + result = { + "success": True, + "data": { + "tasks": [task.model_dump() for task in response.tasks], + "total_token_count": response.total_token_count + } + } + else: + result = { + "success": False, + "message": response.message + } + + print(json.dumps(result, ensure_ascii=False)) + + except json.JSONDecodeError as e: + error = { + "success": False, + "message": f"Invalid JSON input: {str(e)}" + } + print(json.dumps(error)) + except Exception as e: + error = { + "success": False, + "message": f"Unexpected error: {str(e)}" + } + print(json.dumps(error)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/plugins.v2/lexiannot/requirements.txt b/plugins.v2/lexiannot/requirements.txt new file mode 100644 index 0000000..f1dd2bf --- /dev/null +++ b/plugins.v2/lexiannot/requirements.txt @@ -0,0 +1,5 @@ +pysubs2~=1.8.0 +thinc==8.3.4 +spacy==3.8.7 +langdetect~=1.0.9 +pymediainfo~=7.0.1 \ No newline at end of file From d5e0f4c298ff2d9a4c02900f085727ff0e1db2c7 Mon Sep 17 00:00:00 2001 From: wumode Date: Tue, 10 Jun 2025 00:05:01 +0800 Subject: [PATCH 2/2] fix(ToBypassTrackers): typo --- package.v2.json | 5 +++-- plugins.v2/tobypasstrackers/__init__.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.v2.json b/package.v2.json index b4e2d50..26bb884 100644 --- a/package.v2.json +++ b/package.v2.json @@ -411,7 +411,7 @@ "name": "绕过Trackers", "description": "提供tracker服务器IP地址列表,帮助IPv6连接绕过OpenClash", "labels": "工具", - "version": "1.4", + "version": "1.4.1", "icon": "Clash_A.png", "author": "wumode", "level": 2, @@ -420,7 +420,8 @@ "v1.1": "更新列表后发送通知", "v1.2": "修复Trackers加载错误", "v1.3": "新增一些Trackers", - "v1.4": "异步查询DNS" + "v1.4": "异步查询DNS", + "v1.4.1": "修复通知类型错误" } }, "ImdbSource": { diff --git a/plugins.v2/tobypasstrackers/__init__.py b/plugins.v2/tobypasstrackers/__init__.py index c87f64f..c89781a 100644 --- a/plugins.v2/tobypasstrackers/__init__.py +++ b/plugins.v2/tobypasstrackers/__init__.py @@ -29,7 +29,7 @@ class ToBypassTrackers(_PluginBase): # 插件图标 plugin_icon = "Clash_A.png" # 插件版本 - plugin_version = "1.4" + plugin_version = "1.4.1" # 插件作者 plugin_author = "wumode" # 作者主页 @@ -698,6 +698,6 @@ class ToBypassTrackers(_PluginBase): res_message = success_msg + failed_msg res_message = "\n".join(res_message) self.post_message(title=f"【绕过Trackers】", - mtype=NotificationType.SiteMessage, + mtype=NotificationType.Plugin, text=f"{res_message}" )