Merge branch 'jxxghp:main' into main

This commit is contained in:
CKun
2025-01-16 20:55:43 +08:00
committed by GitHub
7 changed files with 1476 additions and 66 deletions

View File

@@ -79,7 +79,6 @@
"icon": "douban.png",
"author": "jxxghp",
"level": 2,
"v2": true,
"history": {
"v1.9.1": "修复版本兼容问题",
"v1.9": "请求豆瓣RSS时增加请求头",
@@ -123,7 +122,6 @@
"icon": "movie.jpg",
"author": "jxxghp",
"level": 2,
"v2": true,
"history": {
"v1.9.1": "优化媒体类型的判断处理",
"v1.9": "增强API安全性",
@@ -491,11 +489,14 @@
"name": "Bark消息推送",
"description": "支持使用Bark发送消息通知。",
"labels": "消息通知",
"version": "1.1",
"version": "1.2",
"icon": "Bark_A.png",
"author": "jxxghp",
"level": 1,
"v2": true
"v2": true,
"history": {
"v1.2": "支持多人消息发送"
}
},
"PushDeerMsg": {
"name": "PushDeer消息推送",

View File

@@ -3,11 +3,12 @@
"name": "站点数据统计",
"description": "站点统计数据图表。",
"labels": "站点,仪表板",
"version": "1.5",
"version": "1.6",
"icon": "statistic.png",
"author": "lightolly,jxxghp",
"level": 2,
"history": {
"v1.6": "优化了站点数据获取失败时的回退逻辑",
"v1.5": "修复了发送增量通知失败等一些问题",
"v1.4.1": "支持数据刷新时发送消息通知",
"v1.3": "远程刷新命令移植到主程序",
@@ -20,11 +21,13 @@
"name": "站点刷流",
"description": "自动托管刷流,将会提高对应站点的访问频率。",
"labels": "刷流,仪表板",
"version": "4.2",
"version": "4.3.1",
"icon": "brush.jpg",
"author": "jxxghp,InfinityPacer",
"level": 2,
"history": {
"v4.3.1": "修复了一些细节问题",
"v4.3": "支持带宽采样并计算平均值,以优化刷流效率",
"v4.2": "优化执行周期输入需要MoviePilot v2.2.1+",
"v4.1": "支持通过CRON表达式配置开启时间固定10分钟为执行周期",
"v4.0": "站点独立配置项支持配置NexusPHP 站点自动跳过下载提示页",
@@ -317,5 +320,29 @@
"v2.1": "优化执行周期输入需要MoviePilot v2.2.1+",
"v2.0": "兼容MoviePilot V2"
}
},
"DoubanRank": {
"name": "豆瓣榜单订阅",
"description": "监控豆瓣热门榜单,自动添加订阅。",
"labels": "订阅",
"version": "2.0.0",
"icon": "movie.jpg",
"author": "jxxghp",
"level": 2,
"history": {
"v2.0.0": "优化cron表达式输入"
}
},
"DoubanSync": {
"name": "豆瓣想看",
"description": "同步豆瓣想看数据,自动添加订阅。",
"labels": "订阅",
"version": "2.0.0",
"icon": "douban.png",
"author": "jxxghp",
"level": 2,
"history": {
"v2.0.0": "优化cron表达式输入"
}
}
}

View File

@@ -251,7 +251,7 @@ class BrushFlow(_PluginBase):
# 插件图标
plugin_icon = "brush.jpg"
# 插件版本
plugin_version = "4.2"
plugin_version = "4.3.1"
# 插件作者
plugin_author = "jxxghp,InfinityPacer"
# 作者主页
@@ -435,7 +435,7 @@ class BrushFlow(_PluginBase):
if self._task_brush_enable:
if brush_config.cron:
values = brush_config.cron.split()
values[0] = f"{datetime.now().minute % 10}/5"
values[0] = f"{datetime.now().minute % 10}/10"
cron = " ".join(values)
logger.info(f"站点刷流定时服务启动,执行周期 {cron}")
cron_trigger = CronTrigger.from_crontab(cron)
@@ -2142,16 +2142,15 @@ class BrushFlow(_PluginBase):
]
if include_network_conditions:
downloader_info = self.__get_downloader_info()
if downloader_info:
current_upload_speed = downloader_info.upload_speed or 0
current_download_speed = downloader_info.download_speed or 0
# 获取平均带宽
avg_upload_speed, avg_download_speed = self.__get_average_bandwidth()
if avg_upload_speed is not None and avg_download_speed is not None:
reasons.extend([
("maxupspeed", lambda config: current_upload_speed >= float(config) * 1024,
lambda config: f"当前总上传带宽 {StringUtils.str_filesize(current_upload_speed)}"
("maxupspeed", lambda config: avg_upload_speed >= float(config) * 1024,
lambda config: f"当前总上传带宽 {StringUtils.str_filesize(avg_upload_speed)}"
f"已达到最大值 {config} KB/s暂时停止新增任务"),
("maxdlspeed", lambda config: current_download_speed >= float(config) * 1024,
lambda config: f"当前总下载带宽 {StringUtils.str_filesize(current_download_speed)}"
("maxdlspeed", lambda config: avg_download_speed >= float(config) * 1024,
lambda config: f"当前总下载带宽 {StringUtils.str_filesize(avg_download_speed)}"
f"已达到最大值 {config} KB/s暂时停止新增任务"),
])
@@ -3508,6 +3507,32 @@ class BrushFlow(_PluginBase):
total_size = sum([task.get("size") or 0 for task in task_info.values()])
return total_size
def __get_average_bandwidth(self, sample_count: int = 5, interval: float = 3.0) \
-> Tuple[Optional[float], Optional[float]]:
"""
多次采样上传和下载带宽,取平均值
"""
upload_speeds = []
download_speeds = []
start_time = time.time()
for _ in range(sample_count):
downloader_info = self.__get_downloader_info()
if downloader_info:
upload_speeds.append(downloader_info.upload_speed or 0)
download_speeds.append(downloader_info.download_speed or 0)
# 采样间隔
time.sleep(interval)
end_time = time.time()
total_duration = end_time - start_time
if not upload_speeds or not download_speeds:
return None, None
avg_upload_speed = sum(upload_speeds) / len(upload_speeds) if upload_speeds else 0
avg_download_speed = sum(download_speeds) / len(download_speeds) if download_speeds else 0
logger.debug(f"平均上传带宽 {StringUtils.str_filesize(avg_upload_speed)}, "
f"平均下载带宽 {StringUtils.str_filesize(avg_download_speed)}, "
f"采样次数={sample_count}, 时长={total_duration:.2f}")
return avg_upload_speed, avg_download_speed
def __get_downloader_info(self) -> schemas.DownloaderInfo:
"""
获取下载器实时信息(所有下载器)

View File

@@ -0,0 +1,693 @@
import datetime
import re
import xml.dom.minidom
from threading import Event
from typing import Tuple, List, Dict, Any
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app import schemas
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.log import logger
from app.plugins import _PluginBase
from app.schemas import MediaType
from app.utils.dom import DomUtils
from app.utils.http import RequestUtils
class DoubanRank(_PluginBase):
# 插件名称
plugin_name = "豆瓣榜单订阅"
# 插件描述
plugin_desc = "监控豆瓣热门榜单,自动添加订阅。"
# 插件图标
plugin_icon = "movie.jpg"
# 插件版本
plugin_version = "2.0.0"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "doubanrank_"
# 加载顺序
plugin_order = 6
# 可使用的用户级别
auth_level = 2
# 退出事件
_event = Event()
# 私有属性
downloadchain: DownloadChain = None
subscribechain: SubscribeChain = None
mediachain: MediaChain = None
_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',
}
_enabled = False
_cron = ""
_onlyonce = False
_rss_addrs = []
_ranks = []
_vote = 0
_clear = False
_clearflag = False
_proxy = False
def init_plugin(self, config: dict = None):
self.downloadchain = DownloadChain()
self.subscribechain = SubscribeChain()
self.mediachain = MediaChain()
if config:
self._enabled = config.get("enabled")
self._cron = config.get("cron")
self._proxy = config.get("proxy")
self._onlyonce = config.get("onlyonce")
self._vote = float(config.get("vote")) if config.get("vote") else 0
rss_addrs = config.get("rss_addrs")
if rss_addrs:
if isinstance(rss_addrs, str):
self._rss_addrs = rss_addrs.split('\n')
else:
self._rss_addrs = rss_addrs
else:
self._rss_addrs = []
self._ranks = config.get("ranks") or []
self._clear = config.get("clear")
# 停止现有任务
self.stop_service()
# 启动服务
if self._enabled or self._onlyonce:
if self._onlyonce:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info("豆瓣榜单订阅服务启动,立即运行一次")
self._scheduler.add_job(func=self.__refresh_rss, trigger='date',
run_date=datetime.datetime.now(
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
)
if self._scheduler.get_jobs():
# 启动服务
self._scheduler.print_jobs()
self._scheduler.start()
if self._onlyonce or self._clear:
# 关闭一次性开关
self._onlyonce = False
# 记录缓存清理标志
self._clearflag = self._clear
# 关闭清理缓存
self._clear = False
# 保存配置
self.__update_config()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
"""
获取插件API
[{
"path": "/xx",
"endpoint": self.xxx,
"methods": ["GET", "POST"],
"summary": "API说明"
}]
"""
return [
{
"path": "/delete_history",
"endpoint": self.delete_history,
"methods": ["GET"],
"summary": "删除豆瓣榜单订阅历史记录"
}
]
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and self._cron:
return [
{
"id": "DoubanRank",
"name": "豆瓣榜单订阅服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.__refresh_rss,
"kwargs": {}
}
]
elif self._enabled:
return [
{
"id": "DoubanRank",
"name": "豆瓣榜单订阅服务",
"trigger": CronTrigger.from_crontab("0 8 * * *"),
"func": self.__refresh_rss,
"kwargs": {}
}
]
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'proxy',
'label': '使用代理服务器',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VCronField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '5位cron表达式留空自动'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'vote',
'label': '评分',
'placeholder': '评分大于等于该值才订阅'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'content': [
{
'component': 'VSelect',
'props': {
'chips': True,
'multiple': True,
'model': 'ranks',
'label': '热门榜单',
'items': [
{'title': '电影北美票房榜', 'value': 'movie-ustop'},
{'title': '一周口碑电影榜', 'value': 'movie-weekly'},
{'title': '实时热门电影', 'value': 'movie-real-time'},
{'title': '热门综艺', 'value': 'show-domestic'},
{'title': '热门电影', 'value': 'movie-hot-gaia'},
{'title': '热门电视剧', 'value': 'tv-hot'},
{'title': '电影TOP10', 'value': 'movie-top250'},
{'title': '电影TOP250', 'value': 'movie-top250-full'},
]
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'rss_addrs',
'label': '自定义榜单地址',
'placeholder': '每行一个地址https://rsshub.app/douban/movie/ustop'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'clear',
'label': '清理历史记录',
}
}
]
}
]
}
]
}
], {
"enabled": False,
"cron": "",
"proxy": False,
"onlyonce": False,
"vote": "",
"ranks": [],
"rss_addrs": "",
"clear": False
}
def get_page(self) -> List[dict]:
"""
拼装插件详情页面,需要返回页面配置,同时附带数据
"""
# 查询历史记录
historys = self.get_data('history')
if not historys:
return [
{
'component': 'div',
'text': '暂无数据',
'props': {
'class': 'text-center',
}
}
]
# 数据按时间降序排序
historys = sorted(historys, key=lambda x: x.get('time'), reverse=True)
# 拼装页面
contents = []
for history in historys:
title = history.get("title")
poster = history.get("poster")
mtype = history.get("type")
time_str = history.get("time")
doubanid = history.get("doubanid")
contents.append(
{
'component': 'VCard',
'content': [
{
"component": "VDialogCloseBtn",
"props": {
'innerClass': 'absolute top-0 right-0',
},
'events': {
'click': {
'api': 'plugin/DoubanRank/delete_history',
'method': 'get',
'params': {
'key': f"doubanrank: {title} (DB:{doubanid})",
'apikey': settings.API_TOKEN
}
}
},
},
{
'component': 'div',
'props': {
'class': 'd-flex justify-space-start flex-nowrap flex-row',
},
'content': [
{
'component': 'div',
'content': [
{
'component': 'VImg',
'props': {
'src': poster,
'height': 120,
'width': 80,
'aspect-ratio': '2/3',
'class': 'object-cover shadow ring-gray-500',
'cover': True
}
}
]
},
{
'component': 'div',
'content': [
{
'component': 'VCardTitle',
'props': {
'class': 'ps-1 pe-5 break-words whitespace-break-spaces'
},
'content': [
{
'component': 'a',
'props': {
'href': f"https://movie.douban.com/subject/{doubanid}",
'target': '_blank'
},
'text': title
}
]
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{mtype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{time_str}'
}
]
}
]
}
]
}
)
return [
{
'component': 'div',
'props': {
'class': 'grid gap-3 grid-info-card',
},
'content': contents
}
]
def stop_service(self):
"""
停止服务
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._event.set()
self._scheduler.shutdown()
self._event.clear()
self._scheduler = None
except Exception as e:
print(str(e))
def delete_history(self, key: str, apikey: str):
"""
删除同步历史记录
"""
if apikey != settings.API_TOKEN:
return schemas.Response(success=False, message="API密钥错误")
# 历史记录
historys = self.get_data('history')
if not historys:
return schemas.Response(success=False, message="未找到历史记录")
# 删除指定记录
historys = [h for h in historys if h.get("unique") != key]
self.save_data('history', historys)
return schemas.Response(success=True, message="删除成功")
def __update_config(self):
"""
列新配置
"""
self.update_config({
"enabled": self._enabled,
"cron": self._cron,
"onlyonce": self._onlyonce,
"vote": self._vote,
"ranks": self._ranks,
"rss_addrs": '\n'.join(map(str, self._rss_addrs)),
"clear": self._clear
})
def __refresh_rss(self):
"""
刷新RSS
"""
logger.info(f"开始刷新豆瓣榜单 ...")
addr_list = self._rss_addrs + [self._douban_address.get(rank) for rank in self._ranks]
if not addr_list:
logger.info(f"未设置榜单RSS地址")
return
else:
logger.info(f"{len(addr_list)} 个榜单RSS地址需要刷新")
# 读取历史记录
if self._clearflag:
history = []
else:
history: List[dict] = self.get_data('history') or []
for addr in addr_list:
if not addr:
continue
try:
logger.info(f"获取RSS{addr} ...")
rss_infos = self.__get_rss_info(addr)
if not rss_infos:
logger.error(f"RSS地址{addr} ,未查询到数据")
continue
else:
logger.info(f"RSS地址{addr} ,共 {len(rss_infos)} 条数据")
for rss_info in rss_infos:
if self._event.is_set():
logger.info(f"订阅服务停止")
return
mtype = None
title = rss_info.get('title')
douban_id = rss_info.get('doubanid')
year = rss_info.get('year')
type_str = rss_info.get('type')
if type_str == "movie":
mtype = MediaType.MOVIE
elif type_str:
mtype = MediaType.TV
unique_flag = f"doubanrank: {title} (DB:{douban_id})"
# 检查是否已处理过
if unique_flag in [h.get("unique") for h in history]:
continue
# 元数据
meta = MetaInfo(title)
meta.year = year
if mtype:
meta.type = mtype
# 识别媒体信息
if douban_id:
# 识别豆瓣信息
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = self.mediachain.get_tmdbinfo_by_doubanid(doubanid=douban_id, mtype=meta.type)
if not tmdbinfo:
logger.warn(f'未能通过豆瓣ID {douban_id} 获取到TMDB信息标题{title}豆瓣ID{douban_id}')
continue
mediainfo = self.chain.recognize_media(meta=meta, tmdbid=tmdbinfo.get("id"))
if not mediainfo:
logger.warn(f'TMDBID {tmdbinfo.get("id")} 未识别到媒体信息')
continue
else:
mediainfo = self.chain.recognize_media(meta=meta, doubanid=douban_id)
if not mediainfo:
logger.warn(f'豆瓣ID {douban_id} 未识别到媒体信息')
continue
else:
# 匹配媒体信息
mediainfo: MediaInfo = self.chain.recognize_media(meta=meta)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{title}豆瓣ID{douban_id}')
continue
# 判断评分是否符合要求
if self._vote and mediainfo.vote_average < self._vote:
logger.info(f'{mediainfo.title_year} 评分不符合要求')
continue
# 查询缺失的媒体信息
exist_flag, _ = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo)
if exist_flag:
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
continue
# 判断用户是否已经添加订阅
if self.subscribechain.exists(mediainfo=mediainfo, meta=meta):
logger.info(f'{mediainfo.title_year} 订阅已存在')
continue
# 添加订阅
self.subscribechain.add(title=mediainfo.title,
year=mediainfo.year,
mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
season=meta.begin_season,
exist_ok=True,
username="豆瓣榜单")
# 存储历史记录
history.append({
"title": title,
"type": mediainfo.type.value,
"year": mediainfo.year,
"poster": mediainfo.get_poster_image(),
"overview": mediainfo.overview,
"tmdbid": mediainfo.tmdb_id,
"doubanid": douban_id,
"time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"unique": unique_flag
})
except Exception as e:
logger.error(str(e))
# 保存历史记录
self.save_data('history', history)
# 缓存只清理一次
self._clearflag = False
logger.info(f"所有榜单RSS刷新完成")
def __get_rss_info(self, addr) -> List[dict]:
"""
获取RSS
"""
try:
if self._proxy:
ret = RequestUtils(proxies=settings.PROXY).get_res(addr)
else:
ret = RequestUtils().get_res(addr)
if not ret:
return []
ret_xml = ret.text
ret_array = []
# 解析XML
dom_tree = xml.dom.minidom.parseString(ret_xml)
rootNode = dom_tree.documentElement
items = rootNode.getElementsByTagName("item")
for item in items:
try:
rss_info = {}
# 标题
title = DomUtils.tag_value(item, "title", default="")
# 链接
link = DomUtils.tag_value(item, "link", default="")
# 年份
description = DomUtils.tag_value(item, "description", default="")
if not title and not link:
logger.warn(f"条目标题和链接均为空,无法处理")
continue
rss_info['title'] = title
rss_info['link'] = link
doubanid = re.findall(r"/(\d+)/", link)
if doubanid:
doubanid = doubanid[0]
if doubanid and not str(doubanid).isdigit():
logger.warn(f"解析的豆瓣ID格式不正确{doubanid}")
continue
rss_info['doubanid'] = doubanid
# 匹配4位独立数字1900-2099年
year = re.findall(r"\b(19\d{2}|20\d{2})\b", description)
if year:
rss_info['year'] = year[0]
# 返回对象
ret_array.append(rss_info)
except Exception as e1:
logger.error("解析RSS条目失败" + str(e1))
continue
return ret_array
except Exception as e:
logger.error("获取RSS失败" + str(e))
return []

View File

@@ -0,0 +1,622 @@
import datetime
from pathlib import Path
from threading import Lock
from typing import Optional, Any, List, Dict, Tuple
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app import schemas
from app.chain.media import MediaChain
from app.schemas.types import MediaType
from app.chain.download import DownloadChain
from app.chain.search import SearchChain
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.event import Event
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.helper.rss import RssHelper
from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import EventType
lock = Lock()
class DoubanSync(_PluginBase):
# 插件名称
plugin_name = "豆瓣想看"
# 插件描述
plugin_desc = "同步豆瓣想看数据,自动添加订阅。"
# 插件图标
plugin_icon = "douban.png"
# 插件版本
plugin_version = "2.0.0"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "doubansync_"
# 加载顺序
plugin_order = 3
# 可使用的用户级别
auth_level = 2
# 私有变量
_interests_url: str = "https://www.douban.com/feed/people/%s/interests"
_scheduler: Optional[BackgroundScheduler] = None
_cache_path: Optional[Path] = None
rsshelper = None
downloadchain = None
searchchain = None
subscribechain = None
mediachain = None
# 配置属性
_enabled: bool = False
_onlyonce: bool = False
_cron: str = ""
_notify: bool = False
_days: int = 7
_users: str = ""
_clear: bool = False
_clearflag: bool = False
def init_plugin(self, config: dict = None):
self.rsshelper = RssHelper()
self.downloadchain = DownloadChain()
self.searchchain = SearchChain()
self.subscribechain = SubscribeChain()
self.mediachain = MediaChain()
# 停止现有任务
self.stop_service()
# 配置
if config:
self._enabled = config.get("enabled")
self._cron = config.get("cron")
self._notify = config.get("notify")
self._days = config.get("days")
self._users = config.get("users")
self._onlyonce = config.get("onlyonce")
self._clear = config.get("clear")
if self._enabled or self._onlyonce:
if self._onlyonce:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info(f"豆瓣想看服务启动,立即运行一次")
self._scheduler.add_job(func=self.sync, trigger='date',
run_date=datetime.datetime.now(
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
)
# 启动任务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
if self._onlyonce or self._clear:
# 关闭一次性开关
self._onlyonce = False
# 记录缓存清理标志
self._clearflag = self._clear
# 关闭清理缓存
self._clear = False
# 保存配置
self.__update_config()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
定义远程控制命令
:return: 命令关键字、事件、描述、附带数据
"""
return [{
"cmd": "/douban_sync",
"event": EventType.PluginAction,
"desc": "同步豆瓣想看",
"category": "订阅",
"data": {
"action": "douban_sync"
}
}]
def get_api(self) -> List[Dict[str, Any]]:
"""
获取插件API
[{
"path": "/xx",
"endpoint": self.xxx,
"methods": ["GET", "POST"],
"summary": "API说明"
}]
"""
return [
{
"path": "/delete_history",
"endpoint": self.delete_history,
"methods": ["GET"],
"summary": "删除豆瓣同步历史记录"
}
]
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and self._cron:
return [
{
"id": "DoubanSync",
"name": "豆瓣想看同步服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.sync,
"kwargs": {}
}
]
elif self._enabled:
return [
{
"id": "DoubanSync",
"name": "豆瓣想看同步服务",
"trigger": "interval",
"func": self.sync,
"kwargs": {"minutes": 30}
}
]
return []
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': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '发送通知',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VCronField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '5位cron表达式留空自动'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'days',
'label': '同步天数'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'content': [
{
'component': 'VTextField',
'props': {
'model': 'users',
'label': '用户列表',
'placeholder': '豆瓣用户ID多个用英文逗号分隔'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'clear',
'label': '清理历史记录',
}
}
]
}
]
}
]
}
], {
"enabled": False,
"notify": True,
"onlyonce": False,
"cron": "*/30 * * * *",
"days": 7,
"users": "",
"clear": False
}
def get_page(self) -> List[dict]:
"""
拼装插件详情页面,需要返回页面配置,同时附带数据
"""
# 查询同步详情
historys = self.get_data('history')
if not historys:
return [
{
'component': 'div',
'text': '暂无数据',
'props': {
'class': 'text-center',
}
}
]
# 数据按时间降序排序
historys = sorted(historys, key=lambda x: x.get('time'), reverse=True)
# 拼装页面
contents = []
for history in historys:
title = history.get("title")
poster = history.get("poster")
mtype = history.get("type")
time_str = history.get("time")
doubanid = history.get("doubanid")
contents.append(
{
'component': 'VCard',
'content': [
{
"component": "VDialogCloseBtn",
"props": {
'innerClass': 'absolute top-0 right-0',
},
'events': {
'click': {
'api': 'plugin/DoubanSync/delete_history',
'method': 'get',
'params': {
'doubanid': doubanid,
'apikey': settings.API_TOKEN
}
}
},
},
{
'component': 'div',
'props': {
'class': 'd-flex justify-space-start flex-nowrap flex-row',
},
'content': [
{
'component': 'div',
'content': [
{
'component': 'VImg',
'props': {
'src': poster,
'height': 120,
'width': 80,
'aspect-ratio': '2/3',
'class': 'object-cover shadow ring-gray-500',
'cover': True
}
}
]
},
{
'component': 'div',
'content': [
{
'component': 'VCardTitle',
'props': {
'class': 'ps-1 pe-5 break-words whitespace-break-spaces'
},
'content': [
{
'component': 'a',
'props': {
'href': f"https://movie.douban.com/subject/{doubanid}",
'target': '_blank'
},
'text': title
}
]
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{mtype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{time_str}'
}
]
}
]
}
]
}
)
return [
{
'component': 'div',
'props': {
'class': 'grid gap-3 grid-info-card',
},
'content': contents
}
]
def __update_config(self):
"""
更新配置
"""
self.update_config({
"enabled": self._enabled,
"notify": self._notify,
"onlyonce": self._onlyonce,
"cron": self._cron,
"days": self._days,
"users": self._users,
"clear": self._clear
})
def delete_history(self, doubanid: str, apikey: str):
"""
删除同步历史记录
"""
if apikey != settings.API_TOKEN:
return schemas.Response(success=False, message="API密钥错误")
# 历史记录
historys = self.get_data('history')
if not historys:
return schemas.Response(success=False, message="未找到历史记录")
# 删除指定记录
historys = [h for h in historys if h.get("doubanid") != doubanid]
self.save_data('history', historys)
return schemas.Response(success=True, message="删除成功")
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:
logger.error("退出插件失败:%s" % str(e))
def sync(self):
"""
通过用户RSS同步豆瓣想看数据
"""
if not self._users:
return
# 版本
if hasattr(settings, 'VERSION_FLAG'):
version = settings.VERSION_FLAG # V2
else:
version = "v1"
# 读取历史记录
if self._clearflag:
history = []
else:
history: List[dict] = self.get_data('history') or []
for user_id in self._users.split(","):
# 同步每个用户的豆瓣数据
if not user_id:
continue
logger.info(f"开始同步用户 {user_id} 的豆瓣想看数据 ...")
url = self._interests_url % user_id
if version == "v2":
results = self.rsshelper.parse(url, headers={
"User-Agent": settings.USER_AGENT
})
else:
results = self.rsshelper.parse(url)
if not results:
logger.warn(f"未获取到用户 {user_id} 豆瓣RSS数据{url}")
continue
else:
logger.info(f"获取到用户 {user_id} 豆瓣RSS数据{len(results)}")
# 解析数据
for result in results:
try:
dtype = result.get("title", "")[:2]
title = result.get("title", "")[2:]
if dtype not in ["想看"]:
logger.info(f'标题:{title},非想看数据,跳过')
continue
if not result.get("link"):
logger.warn(f'标题:{title},未获取到链接,跳过')
continue
# 判断是否在天数范围
pubdate: Optional[datetime.datetime] = result.get("pubdate")
if pubdate:
if (datetime.datetime.now(datetime.timezone.utc) - pubdate).days > float(self._days):
logger.info(f'已超过同步天数,标题:{title},发布时间:{pubdate}')
continue
douban_id = result.get("link", "").split("/")[-2]
# 检查是否处理过
if not douban_id or douban_id in [h.get("doubanid") for h in history]:
logger.info(f'标题:{title}豆瓣ID{douban_id} 已处理过')
continue
# 识别媒体信息
meta = MetaInfo(title=title)
douban_info = self.chain.douban_info(doubanid=douban_id)
meta.type = MediaType.MOVIE if douban_info.get("type") == "movie" else MediaType.TV
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = self.mediachain.get_tmdbinfo_by_doubanid(doubanid=douban_id, mtype=meta.type)
if not tmdbinfo:
logger.warn(f'未能通过豆瓣ID {douban_id} 获取到TMDB信息标题{title}豆瓣ID{douban_id}')
continue
mediainfo = self.chain.recognize_media(meta=meta, tmdbid=tmdbinfo.get("id"))
if not mediainfo:
logger.warn(f'TMDBID {tmdbinfo.get("id")} 未识别到媒体信息')
continue
else:
mediainfo = self.chain.recognize_media(meta=meta, doubanid=douban_id)
if not mediainfo:
logger.warn(f'豆瓣ID {douban_id} 未识别到媒体信息')
continue
# 查询缺失的媒体信息
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=meta, mediainfo=mediainfo)
if exist_flag:
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
action = "exist"
else:
# 添加订阅
logger.info(f'{mediainfo.title_year} 媒体库中不存在或不完整,添加订阅 ...')
self.subscribechain.add(title=mediainfo.title,
year=mediainfo.year,
mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
season=meta.begin_season,
exist_ok=True,
username="豆瓣想看")
action = "subscribe"
# 存储历史记录
history.append({
"action": action,
"title": title,
"type": mediainfo.type.value,
"year": mediainfo.year,
"poster": mediainfo.get_poster_image(),
"overview": mediainfo.overview,
"tmdbid": mediainfo.tmdb_id,
"doubanid": douban_id,
"time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
except Exception as err:
logger.error(f'同步用户 {user_id} 豆瓣想看数据出错:{str(err)}')
logger.info(f"用户 {user_id} 豆瓣想看同步完成")
# 保存历史记录
self.save_data('history', history)
# 缓存只清理一次
self._clearflag = False
@eventmanager.register(EventType.PluginAction)
def remote_sync(self, event: Event):
"""
豆瓣想看同步
"""
if event:
event_data = event.event_data
if not event_data or event_data.get("action") != "douban_sync":
return
logger.info("收到命令,开始执行豆瓣想看同步 ...")
self.post_message(channel=event.event_data.get("channel"),
title="开始同步豆瓣想看 ...",
userid=event.event_data.get("user"))
self.sync()
if event:
self.post_message(channel=event.event_data.get("channel"),
title="同步豆瓣想看数据完成!", userid=event.event_data.get("user"))

View File

@@ -1,4 +1,5 @@
import warnings
from collections import defaultdict
from datetime import datetime, timedelta
from threading import Lock
from typing import Optional, Any, List, Dict, Tuple
@@ -31,7 +32,7 @@ class SiteStatistic(_PluginBase):
# 插件图标
plugin_icon = "statistic.png"
# 插件版本
plugin_version = "1.5"
plugin_version = "1.6"
# 插件作者
plugin_author = "lightolly,jxxghp"
# 作者主页
@@ -264,28 +265,67 @@ class SiteStatistic(_PluginBase):
def __get_data(self) -> Tuple[str, List[SiteUserData], List[SiteUserData]]:
"""
获取今天的日期、今天的站点数据、昨天的站点数据
获取最近一次统计的日期、最近一次统计的站点数据、上一次的站点数据
如果上一次某个站点数据缺失,则 fallback 到该站点之前最近有数据的日期
"""
# 获取最近所有数据
data_list: List[SiteUserData] = self.siteoper.get_userdata()
if not data_list:
# 获取所有原始数据
raw_data_list: List[SiteUserData] = self.siteoper.get_userdata()
if not raw_data_list:
return "", [], []
# 每个日期、每个站点只保留最后一条数据
data_list = list({f"{data.updated_day}_{data.name}": data for data in data_list}.values())
data_list = list({f"{data.updated_day}_{data.name}": data for data in raw_data_list}.values())
# 按日期倒序排序
data_list.sort(key=lambda x: x.updated_day, reverse=True)
# 获取今天的日期
today = data_list[0].updated_day
# 获取昨天的日期
yestoday = (datetime.strptime(today, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
# 今天的数据
stattistic_data = [data for data in data_list if data.updated_day == today]
# 今日数据按数据量降序排序
stattistic_data.sort(key=lambda x: x.upload, reverse=True)
# 昨天的数据
yesterday_sites_data = [data for data in data_list if data.updated_day == yestoday]
return today, stattistic_data, yesterday_sites_data
# 按日期分组数据
data_by_day = defaultdict(list)
for data in data_list:
data_by_day[data.updated_day].append(data)
# 获取最近一次统计的日期
latest_day = data_list[0].updated_day
# 筛选最近一次统计的数据(可能为空)
latest_data = [data for data in data_list if data.updated_day == latest_day]
# 最近一次统计按上传量降序排序
latest_data.sort(key=lambda x: x.upload, reverse=True)
# 获取所有日期倒序排序后的列表
sorted_dates = sorted(data_by_day.keys(), reverse=True)
# 计算前一天的日期字符串(相对于最近一次日期)
previous_day_str = (datetime.strptime(latest_day, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
# 获取前一天的站点数据
previous_day_sites = data_by_day.get(previous_day_str, [])
# 构建前一天站点到数据的映射
previous_by_site = {data.name: data for data in previous_day_sites}
# 准备查找早于前一天的日期列表,用于 fallback
fallback_dates = [d for d in sorted_dates if d < previous_day_str]
# 按站点细化进行上一次数据的 fallback 处理
previous_data = []
for current_site in latest_data:
site_name = current_site.name
# 优先尝试获取前一天的同一站点数据
site_prev = previous_by_site.get(site_name)
# 如果前一天没有该站点的数据,则进行逐日回退查找
if site_prev is None or site_prev.err_msg:
for d in fallback_dates:
# 在每个候选日期中查找对应站点数据
candidate = next((x for x in data_by_day[d] if x.name == site_name), None)
if candidate:
site_prev = candidate
break
# 如果找到了上一次的数据,加入结果列表
if site_prev:
previous_data.append(site_prev)
return latest_day, latest_data, previous_data
@staticmethod
def __get_total_elements(today: str, stattistic_data: List[SiteUserData], yesterday_sites_data: List[SiteUserData],

View File

@@ -16,7 +16,7 @@ class BarkMsg(_PluginBase):
# 插件图标
plugin_icon = "Bark_A.png"
# 插件版本
plugin_version = "1.1"
plugin_version = "1.2"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
@@ -109,23 +109,7 @@ class BarkMsg(_PluginBase):
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'apikey',
'label': '密钥',
'placeholder': '',
}
}
]
},
{
'component': 'VCol',
'props': {
@@ -142,6 +126,23 @@ class BarkMsg(_PluginBase):
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'apikey',
'label': '密钥',
'placeholder': '每行一个用户密钥',
}
}
]
}
]
},
@@ -216,22 +217,23 @@ class BarkMsg(_PluginBase):
try:
if not self._server or not self._apikey:
return False, "参数未配置"
sc_url = "%s/%s/%s/%s" % (self._server, self._apikey, quote_plus(title), quote_plus(text))
if self._params:
sc_url = "%s?%s" % (sc_url, self._params)
res = RequestUtils().post_res(sc_url)
if res and res.status_code == 200:
ret_json = res.json()
code = ret_json['code']
message = ret_json['message']
if code == 200:
logger.info("Bark消息发送成功")
for apikey in self._apikey.split():
sc_url = "%s/%s/%s/%s" % (self._server, apikey, quote_plus(title), quote_plus(text))
if self._params:
sc_url = "%s?%s" % (sc_url, self._params)
res = RequestUtils().post_res(sc_url)
if res and res.status_code == 200:
ret_json = res.json()
code = ret_json['code']
message = ret_json['message']
if code == 200:
logger.info(f"{apikey} Bark消息发送成功")
else:
logger.warn(f"{apikey} Bark消息发送失败{message}")
elif res is not None:
logger.warn(f"{apikey} Bark消息发送失败错误码{res.status_code},错误原因:{res.reason}")
else:
logger.warn(f"Bark消息发送失败{message}")
elif res is not None:
logger.warn(f"Bark消息发送失败错误码{res.status_code},错误原因:{res.reason}")
else:
logger.warn(f"Bark消息发送失败未获取到返回信息")
logger.warn(f"{apikey} Bark消息发送失败未获取到返回信息")
except Exception as msg_e:
logger.error(f"Bark消息发送失败{str(msg_e)}")