This commit is contained in:
noone
2025-12-29 17:35:16 +08:00
10 changed files with 466 additions and 111 deletions

View File

@@ -810,13 +810,15 @@
"name": "ntfy消息推送",
"description": "支持使用ntfy发送消息通知。",
"labels": "消息通知",
"version": "1.1",
"version": "1.3",
"icon": "Ntfy_A.png",
"author": "lethargicScribe",
"level": 1,
"v2": true,
"history": {
"v1.1": "添加Token认证和用户动作"
"v1.1": "添加Token认证和用户动作",
"v1.2": "修复 ntfy 通知图标链接失效的问题",
"v1.3": "修复标题或文本为空时,通知发送失败的问题"
}
},
"GotifyMsg": {
@@ -847,7 +849,6 @@
"icon": "Macos_Sierra.png",
"author": "jxxghp",
"level": 1,
"v2": true,
"history": {
"v1.4.1": "修复Bing壁纸命名问题",
"v1.3": "适配MoviePilot v2.5.3+版本",
@@ -1053,4 +1054,4 @@
"level": 1,
"v2": true
}
}
}

View File

@@ -468,11 +468,12 @@
"name": "IMDb源",
"description": "让探索推荐和媒体识别支持IMDb数据源。",
"labels": "探索",
"version": "1.6.4",
"version": "1.6.5",
"icon": "IMDb_IOS-OSX_App.png",
"author": "wumode",
"level": 1,
"history": {
"v1.6.5": "仪表盘组件支持图片缓存",
"v1.6.4": "为元数据增加背景图",
"v1.6.3": "优化媒体识别速度; 适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
"v1.6.2": "修复 API 查询错误重试问题",
@@ -546,11 +547,12 @@
"name": "美剧生词标注",
"description": "根据CEFR等级为英语影视剧标注高级词汇。",
"labels": "英语",
"version": "1.2.0",
"version": "1.2.1",
"icon": "LexiAnnot.png",
"author": "wumode",
"level": 1,
"history": {
"v1.2.1": "改进字幕样式获取方法",
"v1.2.0": "引入大模型候选词决策和词义丰富处理链; 支持读取系统智能体配置; 添加智能体工具; 优化通知样式; 改进 UI",
"v1.1.4": "优化字幕选择决策",
"v1.1.3": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
@@ -587,5 +589,17 @@
"v1.2": "优化上报信息量",
"v1.1": "加强脱敏处理"
}
},
"TmdbWallpaper": {
"name": "登录壁纸本地化",
"description": "将MoviePilot的登录壁纸下载到本地。",
"labels": "壁纸,本地化",
"version": "1.4.1",
"icon": "Macos_Sierra.png",
"author": "jxxghp",
"level": 1,
"history": {
"v1.4.1": "MoviePilot V2 版本登录壁纸本地化插件"
}
}
}

View File

@@ -47,14 +47,14 @@ class DoubanRank(_PluginBase):
# 私有属性
_scheduler = None
_douban_address = {
'movie-ustop': 'https://rsshub.app/douban/movie/ustop',
'movie-weekly': 'https://rsshub.app/douban/movie/weekly',
'movie-real-time': 'https://rsshub.app/douban/movie/weekly/movie_real_time_hotest',
'show-domestic': 'https://rsshub.app/douban/movie/weekly/show_domestic',
'movie-hot-gaia': 'https://rsshub.app/douban/movie/weekly/movie_hot_gaia',
'tv-hot': 'https://rsshub.app/douban/movie/weekly/tv_hot',
'movie-top250': 'https://rsshub.app/douban/movie/weekly/movie_top250',
'movie-top250-full': 'https://rsshub.app/douban/list/movie_top250',
'movie-ustop': '/douban/movie/ustop',
'movie-weekly': '/douban/movie/weekly',
'movie-real-time': '/douban/movie/weekly/movie_real_time_hotest',
'show-domestic': '/douban/movie/weekly/show_domestic',
'movie-hot-gaia': '/douban/movie/weekly/movie_hot_gaia',
'tv-hot': '/douban/movie/weekly/tv_hot',
'movie-top250': '/douban/movie/weekly/movie_top250',
'movie-top250-full': '/douban/list/movie_top250',
}
_enabled = False
_cron = ""
@@ -65,6 +65,7 @@ class DoubanRank(_PluginBase):
_clear = False
_clearflag = False
_proxy = False
_rsshub = "https://rsshub.app"
def init_plugin(self, config: dict = None):
@@ -74,6 +75,7 @@ class DoubanRank(_PluginBase):
self._proxy = config.get("proxy")
self._onlyonce = config.get("onlyonce")
self._vote = float(config.get("vote")) if config.get("vote") else 0
self._rsshub = config.get("rsshub") or "https://rsshub.app"
rss_addrs = config.get("rss_addrs")
if rss_addrs:
if isinstance(rss_addrs, str):
@@ -237,7 +239,7 @@ class DoubanRank(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
'md': 4
},
'content': [
{
@@ -254,7 +256,7 @@ class DoubanRank(_PluginBase):
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
'md': 4
},
'content': [
{
@@ -266,6 +268,23 @@ class DoubanRank(_PluginBase):
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'rsshub',
'label': 'RSSHub地址',
'placeholder': 'https://rsshub.app'
}
}
]
}
]
},
@@ -345,6 +364,7 @@ class DoubanRank(_PluginBase):
"proxy": False,
"onlyonce": False,
"vote": "",
"rsshub": "https://rsshub.app",
"ranks": [],
"rss_addrs": "",
"clear": False
@@ -508,6 +528,7 @@ class DoubanRank(_PluginBase):
"cron": self._cron,
"onlyonce": self._onlyonce,
"vote": self._vote,
"rsshub": self._rsshub,
"ranks": self._ranks,
"rss_addrs": '\n'.join(map(str, self._rss_addrs)),
"clear": self._clear
@@ -518,7 +539,10 @@ class DoubanRank(_PluginBase):
刷新RSS
"""
logger.info(f"开始刷新豆瓣榜单 ...")
addr_list = self._rss_addrs + [self._douban_address.get(rank) for rank in self._ranks]
# 构建完整的RSS地址
rsshub_base = self._rsshub.rstrip('/')
rank_addrs = [f"{rsshub_base}{self._douban_address.get(rank)}" for rank in self._ranks if self._douban_address.get(rank)]
addr_list = self._rss_addrs + rank_addrs
if not addr_list:
logger.info(f"未设置榜单RSS地址")
return

View File

@@ -2,6 +2,7 @@ import re
import urllib.parse
from datetime import datetime
from typing import Any, Callable, Coroutine, Dict, Optional, List, Tuple
from urllib.parse import quote
import zhconv
from apscheduler.triggers.cron import CronTrigger
@@ -20,7 +21,8 @@ from app.plugins.imdbsource.officialapi import INTERESTS_ID
from app.plugins.imdbsource.schema import StaffPickEntry, ImdbTitle, StaffPickApiResponse, ImdbMediaInfo, SearchParams
from app.log import logger
from app.schemas import DiscoverSourceEventData, MediaRecognizeConvertEventData, RecommendSourceEventData
from app.schemas.types import ChainEventType, MediaType
from app.schemas.types import ChainEventType, MediaType, EventType
from app.scheduler import Scheduler
from app.utils.http import AsyncRequestUtils, RequestUtils
@@ -32,7 +34,7 @@ class ImdbSource(_PluginBase):
# 插件图标
plugin_icon = "IMDb_IOS-OSX_App.png"
# 插件版本
plugin_version = "1.6.4"
plugin_version = "1.6.5"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -57,7 +59,7 @@ class ImdbSource(_PluginBase):
# 私有属性
_imdb_helper: ImdbHelper = None
_img_proxy_prefix: str = ''
_img_proxy_prefix: str = '/api/v1/system/cache/image?url='
_original_method: Optional[Callable] = None
_original_async_method: Optional[Callable[..., Coroutine[Any, Any, Optional[MediaInfo]]]] = None
_staff_picks_cache: Optional[StaffPickApiResponse] = None
@@ -134,7 +136,6 @@ class ImdbSource(_PluginBase):
if "media-imdb.com" not in settings.SECURITY_IMAGE_DOMAINS:
settings.SECURITY_IMAGE_DOMAINS.append("media-imdb.com")
if self._enabled:
if self._recognize_media and self._recognition_mode == 'auxiliary':
# 替换 ChainBase.recognize_media
if not (getattr(ChainBase.recognize_media, "_patched_by", object()) == id(self)):
@@ -203,15 +204,11 @@ class ImdbSource(_PluginBase):
if not self._staff_picks:
return None
def year_and_type(imdb_entry: StaffPickEntry, imdb_titles: List[ImdbTitle]) -> Tuple[MediaType, str | None, str | None]:
title = next((t for t in imdb_titles if t.id == imdb_entry.ttconst), None)
if not title:
return MediaType.MOVIE, datetime.now().date().strftime("%Y"), ''
def year_and_type(title: ImdbTitle) -> Tuple[MediaType, str | None]:
media_id = title.title_type.id
release_year = f"{title.release_year.year}" if title.release_year else datetime.now().date().strftime("%Y")
media_type = ImdbHelper.type_to_mtype(media_id.value)
media_plot = title.plot.plot_text.plain_text if title.plot and title.plot.plot_text else ''
return media_type, release_year, media_plot
return media_type, release_year
# 列配置
size_config = {
@@ -264,12 +261,15 @@ class ImdbSource(_PluginBase):
titles = imdb_items.titles
contents = []
for entry in entries:
imdb_title = next((t for t in titles if t.id == entry.ttconst), None)
if not imdb_title:
continue
cast = [name for related in entry.relatedconst for name in names if name.id == related]
mtype, year, plot = year_and_type(entry, titles)
mtype, year = year_and_type(imdb_title)
mp_url = f"/media?mediaid=imdb:{entry.ttconst}&title={entry.name}&year={year}&type={mtype.value}"
primary_img_url = next((f"{image.url}" for image in images
if image.id == entry.rmconst), '')
primary_img_url = f'{self._img_proxy_prefix}{primary_img_url}'
primary_img_url = f'{self._img_proxy_prefix}{quote(primary_img_url)}'
item1 = {
'component': 'VCarouselItem',
'props': {
@@ -290,19 +290,20 @@ class ImdbSource(_PluginBase):
'to': mp_url,
'class': 'no-underline'
},
'content': [{
'component': 'h1',
'props': {
'class': 'mb-1 text-white text-shadow font-extrabold text-2xl line-clamp-2 overflow-hidden text-ellipsis ...'
'content': [
{
'component': 'h1',
'props': {
'class': 'mb-1 text-white text-shadow font-extrabold text-2xl line-clamp-2 overflow-hidden text-ellipsis ...'
},
'html': f"{entry.name} <span class='text-base font-normal'>{year}</span>",
},
'html': f"{entry.name} <span class='text-base font-normal'>{year_and_type(entry, titles)[1]}</span>",
},
{
'component': 'span',
'props': {
'class': 'text-shadow line-clamp-2 overflow-hidden text-ellipsis ...'
},
'html': plot,
'html': imdb_title.plot_text,
}
]
},
@@ -334,15 +335,16 @@ class ImdbSource(_PluginBase):
{
'component': 'VAvatar',
'props': {
'size': f'{48 if is_mobile else 64}',
'class': 'mb-1'
'size': f'{54 if is_mobile else 64}',
'class': 'mb-1 hover-card',
},
'content': [
{
'component': 'VImg',
'props': {
'src': f"{self._img_proxy_prefix}"
f"{cs.primary_image.url if cs.primary_image else ''}",
f"{quote(cs.primary_image.url
if cs.primary_image else '')}",
'alt': cs.name_text.text,
'cover': True
}
@@ -367,14 +369,14 @@ class ImdbSource(_PluginBase):
}
poster_url = next((f"{title.primary_image.url if title.primary_image else ''}" for title in titles if
title.id == entry.ttconst), None)
poster_url = f"{self._img_proxy_prefix}{poster_url}"
poster_url = f"{self._img_proxy_prefix}{quote(poster_url or '')}"
poster_com = {
'component': 'VImg',
'props': {
'src': poster_url,
'alt': '海报',
'cover': True,
'class': 'rounded',
'class': 'rounded hover-poster',
'max-width': '160',
'max-height': '240',
'style': 'height: auto; aspect-ratio: 2/3;',
@@ -395,8 +397,49 @@ class ImdbSource(_PluginBase):
'style': 'display: flex; justify-content: center;'
},
'content': [
poster_com
poster_com,
]
},
]
}
rating_ui = {
'component': 'div',
'props': {
'class': 'mb-2 d-flex align-center',
},
'content': [
{
'component': 'div',
'props': {
'class': 'd-flex align-center',
},
'content': [
{
'component': 'VIcon',
'props': {
'color': 'warning',
'size': 16
},
'text': 'mdi-star'
},
{
'component': 'span',
'props': {
'class': 'text-body-2 ml-1',
'style': 'color: rgba(231, 227, 252, 0.8);'
},
'text': f"{imdb_title.rating_text}/10",
},
]
},
{
'component': 'span',
'props': {
'class': 'text-warning font-weight-bold ml-4',
'color': 'warning'
},
'text': entry.detail,
}
]
}
@@ -431,13 +474,7 @@ class ImdbSource(_PluginBase):
}
]
},
{
'component': 'div',
'props': {
'class': 'text-yellow font-weight-bold mb-2',
},
'html': entry.detail
},
rating_ui,
{
'component': 'span',
'props': {
@@ -510,7 +547,33 @@ class ImdbSource(_PluginBase):
contents.append(item1)
contents.append(item2)
style = {
'component': 'style',
'text': """
.hover-card {
border: 2px solid transparent;
transition: border-color 0.3s ease-in-out;
box-sizing: border-box;
}
.hover-card:hover {
border-color: #ff8400;
cursor: pointer;
}
.hover-poster {
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
transition: all 0.3s ease;
backface-visibility: hidden;
}
.hover-poster:hover {
transform: translateY(-6px);
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.4),
0 10px 10px -5px rgba(0,0,0,0.2);
cursor: pointer;
}
"""
}
elements = [
style,
{
'component': 'VCard',
'props': {
@@ -1988,3 +2051,13 @@ class ImdbSource(_PluginBase):
if not data:
return None
return ImdbSource._match_results(data, media_info)
@eventmanager.register(EventType.PluginReload)
def reload(self, event):
"""
响应插件重载事件
"""
plugin_id = event.event_data.get("plugin_id")
if plugin_id == self.__class__.__name__:
Scheduler().update_plugin_job(plugin_id)

View File

@@ -147,6 +147,15 @@ class ImdbTitle(BaseModel):
original_title_text: Optional[TextField] = Field(default=None, alias='originalTitleText')
runtime: Optional[SecondsField] = Field(default=None, alias='runtime')
@property
def plot_text(self) -> str:
return self.plot.plot_text.plain_text if self.plot and self.plot.plot_text else ''
@property
def rating_text(self) -> str:
if self.ratings_summary and self.ratings_summary.aggregate_rating:
return f"{self.ratings_summary.aggregate_rating:.1f}"
return "-"
class Thumbnail(BaseModel):
url: str

View File

@@ -61,7 +61,7 @@ class LexiAnnot(_PluginBase):
# 插件图标
plugin_icon = "LexiAnnot.png"
# 插件版本
plugin_version = "1.2.0"
plugin_version = "1.2.1"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -756,6 +756,7 @@ class LexiAnnot(_PluginBase):
{"title": "0.3", "value": "0.3"},
{"title": "0.4", "value": "0.4"},
{"title": "0.5", "value": "0.5"},
{"title": "1.0", "value": "1.0"},
],
},
}
@@ -887,7 +888,7 @@ class LexiAnnot(_PluginBase):
"ffmpeg_path": "",
"english_only": True,
"when_file_trans": True,
"model_temperature": "0.1",
"model_temperature": "0.3",
"custom_files": "",
"accent_color": "",
"font_scaling": "1",
@@ -1339,6 +1340,7 @@ class LexiAnnot(_PluginBase):
)
ret_message = ""
stat = None
ret_status: TaskStatus = TaskStatus.FAILED
if embedded_subtitles:
logger.info(f"提取到 {len(embedded_subtitles)} 条英语文本字幕")
for embedded_subtitle in embedded_subtitles:
@@ -1364,10 +1366,11 @@ class LexiAnnot(_PluginBase):
ass_subtitle.save(str(ass_file))
ret_message = "字幕已保存"
logger.info(f"字幕已保存:{str(ass_file)}")
ret_status = TaskStatus.COMPLETED
break
except Exception as e:
ret_message = f"字幕文件 {ass_file} 保存失败"
logger.error(f"字幕文件 {ass_file} 保存失败, {e}")
break
else:
logger.info(
f"处理字幕{embedded_subtitle['codec_id']}-{embedded_subtitle['stream_id']}失败"
@@ -1378,7 +1381,7 @@ class LexiAnnot(_PluginBase):
ret_message = "未能找到可提取的英文字幕"
logger.info(f"✅ Finished: {path}")
return ProcessResult(status=TaskStatus.COMPLETED, message=ret_message, statistics=stat)
return ProcessResult(status=ret_status, message=ret_message, statistics=stat)
@cached(maxsize=1, ttl=1800)
def __load_lexicon_version(self) -> Optional[str]:
@@ -1513,13 +1516,8 @@ class LexiAnnot(_PluginBase):
mediainfo: MediaInfo | None = event_info.get("mediainfo")
if self._english_only and mediainfo:
if mediainfo.original_language and mediainfo.original_language not in {
"en",
"eng",
}:
logger.info(
f"原始语言 ({mediainfo.original_language}) 不为英语, 跳过 {mediainfo.title} "
)
if mediainfo.original_language and mediainfo.original_language not in {"en","eng"}:
logger.info(f"原始语言 ({mediainfo.original_language}) 不为英语, 跳过 {mediainfo.title} ")
return
for new_path in transfer_info.file_list_new or []:
self.add_media_file(new_path)
@@ -1537,10 +1535,7 @@ class LexiAnnot(_PluginBase):
new_list = []
replacements.sort(key=lambda x: x["end"] - x["start"], reverse=True)
for r in replacements:
if any(
(r["start"] >= new["start"] and r["end"] <= new["end"])
for new in new_list
):
if any((r["start"] >= new["start"] and r["end"] <= new["end"]) for new in new_list):
continue
new_list.append(r)
return new_list
@@ -1591,12 +1586,21 @@ class LexiAnnot(_PluginBase):
@staticmethod
def analyze_ass_language(ass_file: SSAFile):
def _replace_with_spaces(_text):
"""
使用等长的空格替换文本中的 (xxx) 模式。
例如:"(Hi)" 会被替换成 " " (4个空格)
"""
pattern = r"(\([^()]*\)|\[[^\[\]]*\])"
return re.sub(pattern, lambda match: " " * len(match.group(1)), _text)
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
text = _replace_with_spaces(dialogue.plaintext)
sub_text = text.split("\n")
if style not in styles or not text:
continue
@@ -1638,13 +1642,11 @@ class LexiAnnot(_PluginBase):
return style_language_analysis
@staticmethod
def select_main_style_weighted(
language_analysis: Dict[str, Any], known_language: str, weights=None
):
def select_main_style_weighted(analysis: Dict[str, Any], known_language: str, weights = None):
"""
根据语言分析结果和已知的字幕语言,使用加权评分选择主要样式
:params language_analysis: `analyze_ass_language` 函数的输出结果
:params analysis: `analyze_ass_language` 函数的输出结果
:params known_language: 已知的字幕语言代码
:params weights: 各个维度的权重,权重之和应为 1
:returns: 主要字幕的样式名称,如果没有匹配的样式则返回 None
@@ -1652,20 +1654,10 @@ class LexiAnnot(_PluginBase):
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():
max_times = max([analysis.get("times", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
max_text_size = max([analysis.get("text_size", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
max_duration = max([analysis.get("duration", 0) for _, analysis in analysis.items() if analysis] or [0]) or 1
for style, analysis in analysis.items():
if not analysis:
continue
if analysis.get("main_language") == known_language:
@@ -1898,7 +1890,7 @@ class LexiAnnot(_PluginBase):
)
)
# model_temperature = float(self._model_temperature) if self._model_temperature else 0.1
model_temperature = float(self._model_temperature) if self._model_temperature else 0.3
logger.info("通过 spaCy 分词...")
for seg in segments:
if self._shutdown_event.is_set():
@@ -1925,7 +1917,7 @@ class LexiAnnot(_PluginBase):
model_name=llm_model_name,
base_url=llm_base_url,
api_key=llm_apikey,
temperature=self._model_temperature,
temperature=model_temperature,
max_retries=self._max_retries,
proxy=self._use_proxy,
)
@@ -1958,9 +1950,7 @@ class LexiAnnot(_PluginBase):
) # &H00FFFFFF&
statistical_res = LexiAnnot.analyze_ass_language(ass_file)
main_style: str | None = LexiAnnot.select_main_style_weighted(
statistical_res, lang
)
main_style: str | None = LexiAnnot.select_main_style_weighted(statistical_res, lang)
if not main_style:
logger.error("无法确定主要字幕样式")
return None, None
@@ -1996,16 +1986,8 @@ class LexiAnnot(_PluginBase):
dialogue.start = main_processor[seg.index].start
dialogue.end = main_processor[seg.index].end
dialogue.style = "Annotation EN"
cefr_text = (
f" {style_text('Annotation CEFR', word.cefr)}"
if word.cefr
else ""
)
exam_text = (
f" {style_text('Annotation EXAM', ' '.join(exams))}"
if exams
else ""
)
cefr_text = f" {style_text('Annotation CEFR', word.cefr)}" if word.cefr else ""
exam_text = f" {style_text('Annotation EXAM', ' '.join(exams))}" if exams else ""
phone_text = (
f"{__N}{style_text('Annotation PHONE', f'/{word.phonetics}/')}"
if word.phonetics and self._show_phonetics
@@ -2050,10 +2032,10 @@ class LexiAnnot(_PluginBase):
)
if self._sentence_translation:
chinese = seg.Chinese
if chinese and chinese[-1] in ["", ""]:
if chinese and chinese[-1] in {"", ""}:
chinese = chinese[:-1]
main_processor[seg.index].text = (
main_processor[seg.index].text + f"\\N{{\\fs{int(main_style_fs * 0.75)}}}{chinese}{{\\r}}"
main_processor[seg.index].text + f"\\N{{\\fs{int(main_style_fs * 0.75)}}}{chinese}{{\\r}}"
)
# 避免 Infuse 显示乱码

View File

@@ -14,9 +14,7 @@ class VocabularyAnnotatingTool(MoviePilotTool):
# 工具名称
name: str = "vocabulary_annotating_tool"
# 工具描述
description: str = (
"Add new vocabulary annotation task to plugin LexiAnnot's task queue."
)
description: str = "Add new vocabulary annotation task to plugin LexiAnnot's task queue."
# 输入参数模型
args_schema: Type[BaseModel] = VocabularyAnnotatingToolInput
@@ -74,9 +72,7 @@ class QueryAnnotationTasksTool(MoviePilotTool):
# 工具名称
name: str = "query_annotation_tasks_tool"
# 工具描述
description: str = (
"Query the latest vocabulary annotation tasks from plugin LexiAnnot."
)
description: str = "Query the latest vocabulary annotation tasks from plugin LexiAnnot."
# 输入参数模型
args_schema: Type[BaseModel] = QueryAnnotationTasksToolInput

View File

@@ -500,7 +500,7 @@ Your goal is to identify **only** content that helps them reach native-level pro
* Avoid repeating words already listed in `candidate_words`.
* Must exist in the exact form in `context_text`.
* Provide lemma and POS.
* **Do NOT include** simple high-frequency words, common fillers ('gonna', 'gotta'), or basic swear words.
* **Do NOT include** simple high-frequency words, common fillers ('gonna', 'gotta'), onomatopoeia, or basic swear words.
-------------------------
You MUST return output strictly matching the provided Pydantic schema.

View File

@@ -0,0 +1,256 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, List, Dict, Tuple
from urllib.parse import urlparse, parse_qs
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from app.core.config import settings
from app.helper.wallpaper import WallpaperHelper
from app.log import logger
from app.plugins import _PluginBase
from app.utils.http import RequestUtils
class TmdbWallpaper(_PluginBase):
# 插件名称
plugin_name = "登录壁纸本地化"
# 插件描述
plugin_desc = "将MoviePilot的登录壁纸下载到本地。"
# 插件图标
plugin_icon = "Macos_Sierra.png"
# 插件版本
plugin_version = "1.4.1"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "tmdbwallpaper_"
# 加载顺序
plugin_order = 99
# 可使用的用户级别
auth_level = 1
# 私有属性
_hours = None
_savepath = None
_enabled = False
_onlyonce = False
_scheduler = None
def init_plugin(self, config: dict = None):
if config:
self._enabled = config.get("enabled")
self._hours = int(config.get("hours")) if config.get("hours") else None
self._savepath = config.get('savepath')
self._onlyonce = config.get("onlyonce")
if self._enabled or self._onlyonce:
savepath = Path(self._savepath)
if self._savepath and not savepath.exists():
logger.info(f"创建保存目录:{self._savepath}")
savepath.mkdir(parents=True, exist_ok=True)
# 立即运行一次
if self._onlyonce:
# 定时服务
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info(f"登录壁纸本地化服务启动,立即运行一次")
self._scheduler.add_job(self.wallpaper_local, 'date',
run_date=datetime.now(
tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3)
)
# 关闭一次性开关
self._onlyonce = False
# 保存配置
self.update_config({
"enabled": self._enabled,
"hours": self._hours,
"savepath": self._savepath,
"onlyonce": self._onlyonce
})
if self._scheduler.get_jobs():
# 启动服务
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self) -> bool:
return True if self._enabled and self._hours and self._savepath else False
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
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': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'hours',
'label': '更新频率(小时)',
'placeholder': '1'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 8
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'savepath',
'label': '保存路径',
'placeholder': '/config/wallpapers'
}
}
]
}
]
},
]
}
], {
"enabled": False,
"hours": 1,
"savepath": "/config/wallpapers"
}
def get_page(self) -> List[dict]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self.get_state():
return [{
"id": "TmdbWallpaper",
"name": "登录壁纸本地化服务",
"trigger": "interval",
"func": self.wallpaper_local,
"kwargs": {
"minutes": self._hours * 60
}
}]
return []
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._scheduler.shutdown()
self._scheduler = None
except Exception as e:
print(str(e))
def wallpaper_local(self):
"""
下载MoviePilot的登录壁纸到本地
"""
def __save_file(_url: str, _filename: str):
"""
保存文件
"""
try:
savepath = Path(self._savepath)
logger.info(f"下载壁纸:{_url}")
r = RequestUtils().get_res(_url)
if r and r.status_code == 200:
with open(savepath / _filename, "wb") as f:
f.write(r.content)
except Exception as e:
logger.error(f"下载壁纸失败:{str(e)}")
if not self._savepath:
return
urls = WallpaperHelper().get_wallpapers(10) or []
for url in urls:
if settings.WALLPAPER == "tmdb":
filename = url.split("/")[-1]
elif settings.WALLPAPER == "bing":
# 解析url参数获取id的值
parsed_url = urlparse(url)
query_params = parse_qs(parsed_url.query)
param_value = query_params.get("id")
filename = param_value[0] if param_value else None
else:
# 其他壁纸类型直接使用url的文件名hash
filename = url.split("/")[-1]
# 没有后缀的文件名,添加.jpg后缀
if not filename.endswith(".jpg"):
filename += ".jpg"
__save_file(url, filename)

View File

@@ -16,7 +16,7 @@ class NtfyClient:
headers = {
"Title": title.encode(encoding='utf-8'),
"Markdown": "true" if format_as_markdown else "false",
"Icon": "https://movie-pilot.org/images/logo.png",
"Icon": "https://cdn.jsdelivr.net/gh/jxxghp/MoviePilot-Frontend@v2/public/logo.png",
}
if self._token:
@@ -62,7 +62,7 @@ class NtfyMsg(_PluginBase):
# 插件图标
plugin_icon = "Ntfy_A.png"
# 插件版本
plugin_version = "1.1"
plugin_version = "1.3"
# 插件作者
plugin_author = "lethargicScribe"
# 作者主页
@@ -353,9 +353,9 @@ class NtfyMsg(_PluginBase):
# 类型
msg_type: NotificationType = msg_body.get("type")
# 标题
title = msg_body.get("title")
title = msg_body.get("title") or "\u200b"
# 文本
text = msg_body.get("text")
text = msg_body.get("text") or "\u200b"
if not title and not text:
logger.warn("标题和内容不能同时为空")