nas-tools/web/main.py

1754 lines
68 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import base64
import datetime
import os.path
import re
import shutil
import sqlite3
import time
import traceback
import urllib
import xml.dom.minidom
from functools import wraps
from math import floor
from pathlib import Path
from threading import Lock
from urllib import parse
from flask import Flask, request, json, render_template, make_response, session, send_from_directory, send_file
from flask_compress import Compress
from flask_login import LoginManager, login_user, login_required, current_user
import log
from app.brushtask import BrushTask
from app.conf import ModuleConf, SystemConfig
from app.downloader import Downloader
from app.filter import Filter
from app.helper import SecurityHelper, MetaHelper, ChromeHelper, ThreadHelper
from app.indexer import Indexer
from app.media.meta import MetaInfo
from app.mediaserver import MediaServer
from app.message import Message
from app.plugins import EventManager, PluginManager
from app.rsschecker import RssChecker
from app.sites import Sites, SiteUserInfo
from app.subscribe import Subscribe
from app.sync import Sync
from app.torrentremover import TorrentRemover
from app.utils import DomUtils, SystemUtils, ExceptionUtils, StringUtils
from app.utils.types import *
from config import PT_TRANSFER_INTERVAL, Config
from web.action import WebAction
from web.apiv1 import apiv1_bp
from web.backend.WXBizMsgCrypt3 import WXBizMsgCrypt
from web.backend.user import User
from web.backend.wallpaper import get_login_wallpaper
from web.backend.web_utils import WebUtils
from web.security import require_auth
# 配置文件锁
ConfigLock = Lock()
# Flask App
App = Flask(__name__)
App.config['JSON_AS_ASCII'] = False
App.secret_key = os.urandom(24)
App.permanent_session_lifetime = datetime.timedelta(days=30)
# 启用压缩
Compress(App)
# 登录管理模块
LoginManager = LoginManager()
LoginManager.login_view = "login"
LoginManager.init_app(App)
# API注册
App.register_blueprint(apiv1_bp, url_prefix="/api/v1")
@App.after_request
def add_header(r):
"""
统一添加Http头标用缓存避免Flask多线程+Chrome内核会发生的静态资源加载出错的问题
r.headers["Cache-Control"] = "no-cache, no-store, max-age=0"
r.headers["Pragma"] = "no-cache"
r.headers["Expires"] = "0"
"""
return r
# 定义获取登录用户的方法
@LoginManager.user_loader
def load_user(user_id):
return User().get(user_id)
# 页面不存在
@App.errorhandler(404)
def page_not_found(error):
return render_template("404.html", error=error), 404
# 服务错误
@App.errorhandler(500)
def page_server_error(error):
return render_template("500.html", error=error), 500
def action_login_check(func):
"""
Action安全认证
"""
@wraps(func)
def login_check(*args, **kwargs):
if not current_user.is_authenticated:
return {"code": -1, "msg": "用户未登录"}
return func(*args, **kwargs)
return login_check
# 主页面
@App.route('/', methods=['GET', 'POST'])
def login():
def redirect_to_navigation(userinfo):
"""
跳转到导航页面
"""
# 判断当前的运营环境
SystemFlag = SystemUtils.get_system()
SyncMod = Config().get_config('pt').get('rmt_mode')
TMDBFlag = 1 if Config().get_config('app').get('rmt_tmdbkey') else 0
if not SyncMod:
SyncMod = "link"
RmtModeDict = WebAction().get_rmt_modes()
RestypeDict = ModuleConf.TORRENT_SEARCH_PARAMS.get("restype")
PixDict = ModuleConf.TORRENT_SEARCH_PARAMS.get("pix")
SiteFavicons = Sites().get_site_favicon()
Indexers = Indexer().get_indexers()
SearchSource = "douban" if Config().get_config("laboratory").get("use_douban_titles") else "tmdb"
CustomScriptCfg = SystemConfig().get_system_config("CustomScript")
return render_template('navigation.html',
GoPage=GoPage,
UserName=userinfo.username,
UserPris=str(userinfo.pris).split(","),
SystemFlag=SystemFlag.value,
TMDBFlag=TMDBFlag,
AppVersion=WebUtils.get_current_version(),
RestypeDict=RestypeDict,
PixDict=PixDict,
SyncMod=SyncMod,
SiteFavicons=SiteFavicons,
RmtModeDict=RmtModeDict,
Indexers=Indexers,
SearchSource=SearchSource,
CustomScriptCfg=CustomScriptCfg)
def redirect_to_login(errmsg=''):
"""
跳转到登录页面
"""
return render_template('login.html',
GoPage=GoPage,
LoginWallpaper=get_login_wallpaper(),
err_msg=errmsg)
# 登录认证
if request.method == 'GET':
GoPage = request.args.get("next") or ""
if GoPage.startswith('/'):
GoPage = GoPage[1:]
if current_user.is_authenticated:
userid = current_user.id
username = current_user.username
if userid is None or username is None:
return redirect_to_login()
else:
# 登录成功
return redirect_to_navigation(User().get_user(username))
else:
return redirect_to_login()
else:
GoPage = request.form.get('next') or ""
if GoPage.startswith('/'):
GoPage = GoPage[1:]
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember')
if not username:
return redirect_to_login('请输入用户名')
user_info = User().get_user(username)
if not user_info:
return redirect_to_login('用户名或密码错误')
# 校验密码
if user_info.verify_password(password):
# 创建用户 Session
login_user(user_info)
session.permanent = True if remember else False
# 登录成功
return redirect_to_navigation(user_info)
else:
return redirect_to_login('用户名或密码错误')
# 开始
@App.route('/index', methods=['POST', 'GET'])
@login_required
def index():
# 媒体服务器类型
MSType = Config().get_config('media').get('media_server')
# 获取媒体数量
MediaCounts = WebAction().get_library_mediacount()
if MediaCounts.get("code") == 0:
ServerSucess = True
else:
ServerSucess = False
# 获得活动日志
Activity = WebAction().get_library_playhistory().get("result")
# 磁盘空间
LibrarySpaces = WebAction().get_library_spacesize()
# 转移历史统计
TransferStatistics = WebAction().get_transfer_statistics()
return render_template("index.html",
ServerSucess=ServerSucess,
MediaCount={'MovieCount': MediaCounts.get("Movie"),
'SeriesCount': MediaCounts.get("Series"),
'SongCount': MediaCounts.get("Music"),
"EpisodeCount": MediaCounts.get("Episodes")},
Activitys=Activity,
UserCount=MediaCounts.get("User"),
FreeSpace=LibrarySpaces.get("FreeSpace"),
TotalSpace=LibrarySpaces.get("TotalSpace"),
UsedSapce=LibrarySpaces.get("UsedSapce"),
UsedPercent=LibrarySpaces.get("UsedPercent"),
MovieChartLabels=TransferStatistics.get("MovieChartLabels"),
TvChartLabels=TransferStatistics.get("TvChartLabels"),
MovieNums=TransferStatistics.get("MovieNums"),
TvNums=TransferStatistics.get("TvNums"),
AnimeNums=TransferStatistics.get("AnimeNums"),
MediaServerType=MSType
)
# 资源搜索页面
@App.route('/search', methods=['POST', 'GET'])
@login_required
def search():
# 权限
if current_user.is_authenticated:
username = current_user.username
pris = User().get_user(username).get("pris")
else:
pris = ""
# 结果
res = WebAction().get_search_result()
SearchResults = res.get("result")
Count = res.get("total")
return render_template("search.html",
UserPris=str(pris).split(","),
Count=Count,
Results=SearchResults,
SiteDict=Indexer().get_indexer_hash_dict(),
UPCHAR=chr(8593))
# 电影订阅页面
@App.route('/movie_rss', methods=['POST', 'GET'])
@login_required
def movie_rss():
RssItems = WebAction().get_movie_rss_list().get("result")
RuleGroups = {str(group["id"]): group["name"] for group in Filter().get_rule_groups()}
DownloadSettings = Downloader().get_download_setting()
return render_template("rss/movie_rss.html",
Count=len(RssItems),
RuleGroups=RuleGroups,
DownloadSettings=DownloadSettings,
Items=RssItems
)
# 电视剧订阅页面
@App.route('/tv_rss', methods=['POST', 'GET'])
@login_required
def tv_rss():
RssItems = WebAction().get_tv_rss_list().get("result")
RuleGroups = {str(group["id"]): group["name"] for group in Filter().get_rule_groups()}
DownloadSettings = Downloader().get_download_setting()
return render_template("rss/tv_rss.html",
Count=len(RssItems),
RuleGroups=RuleGroups,
DownloadSettings=DownloadSettings,
Items=RssItems
)
# 订阅历史页面
@App.route('/rss_history', methods=['POST', 'GET'])
@login_required
def rss_history():
mtype = request.args.get("t")
RssHistory = WebAction().get_rss_history({"type": mtype}).get("result")
return render_template("rss/rss_history.html",
Count=len(RssHistory),
Items=RssHistory,
Type=mtype
)
# 订阅日历页面
@App.route('/rss_calendar', methods=['POST', 'GET'])
@login_required
def rss_calendar():
Today = datetime.datetime.strftime(datetime.datetime.now(), '%Y-%m-%d')
# 电影订阅
RssMovieItems = [
{
"tmdbid": movie.get("tmdbid"),
"rssid": movie.get("id")
} for movie in Subscribe().get_subscribe_movies().values() if movie.get("tmdbid")
]
# 电视剧订阅
RssTvItems = [
{
"id": tv.get("tmdbid"),
"rssid": tv.get("id"),
"season": int(str(tv.get('season')).replace("S", "")),
"name": tv.get("name"),
} for tv in Subscribe().get_subscribe_tvs().values() if tv.get('season') and tv.get("tmdbid")
]
# 自定义订阅
RssTvItems += RssChecker().get_userrss_mediainfos()
# 电视剧订阅去重
Uniques = set()
UniqueTvItems = []
for item in RssTvItems:
unique = f"{item.get('id')}_{item.get('season')}"
if unique not in Uniques:
Uniques.add(unique)
UniqueTvItems.append(item)
return render_template("rss/rss_calendar.html",
Today=Today,
RssMovieItems=RssMovieItems,
RssTvItems=UniqueTvItems)
# 站点维护页面
@App.route('/site', methods=['POST', 'GET'])
@login_required
def sites():
CfgSites = Sites().get_sites()
RuleGroups = {str(group["id"]): group["name"] for group in Filter().get_rule_groups()}
DownloadSettings = {did: attr["name"] for did, attr in Downloader().get_download_setting().items()}
ChromeOk = ChromeHelper().get_status()
CookieCloudCfg = SystemConfig().get_system_config('CookieCloud')
CookieUserInfoCfg = SystemConfig().get_system_config('CookieUserInfo')
return render_template("site/site.html",
Sites=CfgSites,
RuleGroups=RuleGroups,
DownloadSettings=DownloadSettings,
ChromeOk=ChromeOk,
CookieCloudCfg=CookieCloudCfg,
CookieUserInfoCfg=CookieUserInfoCfg)
# 站点列表页面
@App.route('/sitelist', methods=['POST', 'GET'])
@login_required
def sitelist():
IndexerSites = Indexer().get_builtin_indexers(check=False, public=False)
return render_template("site/sitelist.html",
Sites=IndexerSites,
Count=len(IndexerSites))
# 站点资源页面
@App.route('/resources', methods=['POST', 'GET'])
@login_required
def resources():
site_id = request.args.get("site")
site_name = request.args.get("title")
page = request.args.get("page") or 0
keyword = request.args.get("keyword")
Results = WebAction().action("list_site_resources", {"id": site_id, "page": page, "keyword": keyword}).get(
"data") or []
return render_template("site/resources.html",
Results=Results,
SiteId=site_id,
Title=site_name,
KeyWord=keyword,
TotalCount=len(Results),
PageRange=range(0, 10),
CurrentPage=int(page),
TotalPage=10)
# 推荐页面
@App.route('/recommend', methods=['POST', 'GET'])
@login_required
def recommend():
Type = request.args.get("type") or ""
SubType = request.args.get("subtype") or ""
Title = request.args.get("title") or ""
SubTitle = request.args.get("subtitle") or ""
CurrentPage = request.args.get("page") or 1
Week = request.args.get("week") or ""
TmdbId = request.args.get("tmdbid") or ""
PersonId = request.args.get("personid") or ""
Keyword = request.args.get("keyword") or ""
Source = request.args.get("source") or ""
FilterKey = request.args.get("filter") or ""
Params = json.loads(request.args.get("params")) if request.args.get("params") else {}
return render_template("discovery/recommend.html",
Type=Type,
SubType=SubType,
Title=Title,
CurrentPage=CurrentPage,
Week=Week,
TmdbId=TmdbId,
PersonId=PersonId,
SubTitle=SubTitle,
Keyword=Keyword,
Source=Source,
Filter=FilterKey,
FilterConf=ModuleConf.DISCOVER_FILTER_CONF.get(FilterKey) if FilterKey else {},
Params=Params)
# 推荐页面
@App.route('/ranking', methods=['POST', 'GET'])
@login_required
def ranking():
return render_template("discovery/ranking.html",
DiscoveryType="RANKING")
# 豆瓣电影
@App.route('/douban_movie', methods=['POST', 'GET'])
@login_required
def douban_movie():
return render_template("discovery/recommend.html",
Type="DOUBANTAG",
SubType="MOV",
Title="豆瓣电影",
Filter="douban_movie",
FilterConf=ModuleConf.DISCOVER_FILTER_CONF.get('douban_movie'))
# 豆瓣电视剧
@App.route('/douban_tv', methods=['POST', 'GET'])
@login_required
def douban_tv():
return render_template("discovery/recommend.html",
Type="DOUBANTAG",
SubType="TV",
Title="豆瓣电视剧",
Filter="douban_tv",
FilterConf=ModuleConf.DISCOVER_FILTER_CONF.get('douban_tv'))
@App.route('/tmdb_movie', methods=['POST', 'GET'])
@login_required
def tmdb_movie():
return render_template("discovery/recommend.html",
Type="DISCOVER",
SubType="MOV",
Title="TMDB电影",
Filter="tmdb_movie",
FilterConf=ModuleConf.DISCOVER_FILTER_CONF.get('tmdb_movie'))
@App.route('/tmdb_tv', methods=['POST', 'GET'])
@login_required
def tmdb_tv():
return render_template("discovery/recommend.html",
Type="DISCOVER",
SubType="TV",
Title="TMDB电视剧",
Filter="tmdb_tv",
FilterConf=ModuleConf.DISCOVER_FILTER_CONF.get('tmdb_tv'))
# Bangumi每日放送
@App.route('/bangumi', methods=['POST', 'GET'])
@login_required
def discovery_bangumi():
return render_template("discovery/ranking.html",
DiscoveryType="BANGUMI")
# 媒体详情页面
@App.route('/media_detail', methods=['POST', 'GET'])
@login_required
def media_detail():
TmdbId = request.args.get("id")
Type = request.args.get("type")
return render_template("discovery/mediainfo.html",
TmdbId=TmdbId,
Type=Type)
# 演职人员页面
@App.route('/discovery_person', methods=['POST', 'GET'])
@login_required
def discovery_person():
TmdbId = request.args.get("tmdbid")
Title = request.args.get("title")
SubTitle = request.args.get("subtitle")
Type = request.args.get("type")
return render_template("discovery/person.html",
TmdbId=TmdbId,
Title=Title,
SubTitle=SubTitle,
Type=Type)
# 正在下载页面
@App.route('/downloading', methods=['POST', 'GET'])
@login_required
def downloading():
DispTorrents = WebAction().get_downloading().get("result")
return render_template("download/downloading.html",
DownloadCount=len(DispTorrents),
Torrents=DispTorrents,
Client=Config().get_config("pt").get("pt_client"))
# 近期下载页面
@App.route('/downloaded', methods=['POST', 'GET'])
@login_required
def downloaded():
CurrentPage = request.args.get("page") or 1
return render_template("discovery/recommend.html",
Type='DOWNLOADED',
Title='近期下载',
CurrentPage=CurrentPage)
@App.route('/torrent_remove', methods=['POST', 'GET'])
@login_required
def torrent_remove():
TorrentRemoveTasks = TorrentRemover().get_torrent_remove_tasks()
return render_template("download/torrent_remove.html",
DownloaderConfig=ModuleConf.TORRENTREMOVER_DICT,
Count=len(TorrentRemoveTasks),
TorrentRemoveTasks=TorrentRemoveTasks)
# 数据统计页面
@App.route('/statistics', methods=['POST', 'GET'])
@login_required
def statistics():
# 刷新单个site
refresh_site = request.args.getlist("refresh_site")
# 强制刷新所有
refresh_force = True if request.args.get("refresh_force") else False
# 总上传下载
TotalUpload = 0
TotalDownload = 0
TotalSeedingSize = 0
TotalSeeding = 0
# 站点标签及上传下载
SiteNames = []
SiteUploads = []
SiteDownloads = []
SiteRatios = []
SiteErrs = {}
# 站点上传下载
SiteData = SiteUserInfo().get_pt_date(specify_sites=refresh_site, force=refresh_force)
if isinstance(SiteData, dict):
for name, data in SiteData.items():
if not data:
continue
up = data.get("upload", 0)
dl = data.get("download", 0)
ratio = data.get("ratio", 0)
seeding = data.get("seeding", 0)
seeding_size = data.get("seeding_size", 0)
err_msg = data.get("err_msg", "")
SiteErrs.update({name: err_msg})
if not up and not dl and not ratio:
continue
if not str(up).isdigit() or not str(dl).isdigit():
continue
if name not in SiteNames:
SiteNames.append(name)
TotalUpload += int(up)
TotalDownload += int(dl)
TotalSeeding += int(seeding)
TotalSeedingSize += int(seeding_size)
SiteUploads.append(int(up))
SiteDownloads.append(int(dl))
SiteRatios.append(round(float(ratio), 1))
# 近期上传下载各站点汇总
CurrentUpload, CurrentDownload, _, _, _ = SiteUserInfo().get_pt_site_statistics_history(
days=2)
# 站点用户数据
SiteUserStatistics = WebAction().get_site_user_statistics({"encoding": "DICT"}).get("data")
return render_template("site/statistics.html",
CurrentDownload=CurrentDownload,
CurrentUpload=CurrentUpload,
TotalDownload=TotalDownload,
TotalUpload=TotalUpload,
TotalSeedingSize=TotalSeedingSize,
TotalSeeding=TotalSeeding,
SiteDownloads=SiteDownloads,
SiteUploads=SiteUploads,
SiteRatios=SiteRatios,
SiteNames=SiteNames,
SiteErr=SiteErrs,
SiteUserStatistics=SiteUserStatistics)
# 刷流任务页面
@App.route('/brushtask', methods=['POST', 'GET'])
@login_required
def brushtask():
# 站点列表
CfgSites = Sites().get_sites(brush=True)
# 下载器列表
Downloaders = BrushTask().get_downloader_info()
# 任务列表
Tasks = BrushTask().get_brushtask_info()
return render_template("site/brushtask.html",
Count=len(Tasks),
Sites=CfgSites,
Tasks=Tasks,
Downloaders=Downloaders)
# 自定义下载器页面
@App.route('/userdownloader', methods=['POST', 'GET'])
@login_required
def userdownloader():
downloaders = BrushTask().get_downloader_info()
return render_template("download/userdownloader.html",
Count=len(downloaders),
Downloaders=downloaders)
# 服务页面
@App.route('/service', methods=['POST', 'GET'])
@login_required
def service():
scheduler_cfg_list = []
RuleGroups = Filter().get_rule_groups()
pt = Config().get_config('pt')
if pt:
# RSS订阅
pt_check_interval = pt.get('pt_check_interval')
if str(pt_check_interval).isdigit():
tim_rssdownload = str(round(int(pt_check_interval) / 60)) + " 分钟"
rss_state = 'ON'
else:
tim_rssdownload = ""
rss_state = 'OFF'
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-cloud-download" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4"></path>
<line x1="12" y1="13" x2="12" y2="22"></line>
<polyline points="9 19 12 22 15 19"></polyline>
</svg>
'''
scheduler_cfg_list.append(
{'name': 'RSS订阅', 'time': tim_rssdownload, 'state': rss_state, 'id': 'rssdownload', 'svg': svg,
'color': "blue"})
search_rss_interval = pt.get('search_rss_interval')
if str(search_rss_interval).isdigit():
if int(search_rss_interval) < 6:
search_rss_interval = 6
tim_rsssearch = str(int(search_rss_interval)) + " 小时"
rss_search_state = 'ON'
else:
tim_rsssearch = ""
rss_search_state = 'OFF'
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="10" cy="10" r="7"></circle>
<line x1="21" y1="21" x2="15" y2="15"></line>
</svg>
'''
scheduler_cfg_list.append(
{'name': '订阅搜索', 'time': tim_rsssearch, 'state': rss_search_state, 'id': 'subscribe_search_all',
'svg': svg,
'color': "blue"})
# 下载文件转移
pt_monitor = pt.get('pt_monitor')
if pt_monitor:
tim_pttransfer = str(round(PT_TRANSFER_INTERVAL / 60)) + " 分钟"
sta_pttransfer = 'ON'
else:
tim_pttransfer = ""
sta_pttransfer = 'OFF'
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-replace" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<rect x="3" y="3" width="6" height="6" rx="1"></rect>
<rect x="15" y="15" width="6" height="6" rx="1"></rect>
<path d="M21 11v-3a2 2 0 0 0 -2 -2h-6l3 3m0 -6l-3 3"></path>
<path d="M3 13v3a2 2 0 0 0 2 2h6l-3 -3m0 6l3 -3"></path>
</svg>
'''
scheduler_cfg_list.append(
{'name': '下载文件转移', 'time': tim_pttransfer, 'state': sta_pttransfer, 'id': 'pttransfer', 'svg': svg,
'color': "green"})
# 删种
torrent_remove_tasks = TorrentRemover().get_torrent_remove_tasks()
if torrent_remove_tasks:
sta_autoremovetorrents = 'ON'
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-trash" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<line x1="4" y1="7" x2="20" y2="7"></line>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"></path>
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path>
</svg>
'''
scheduler_cfg_list.append(
{'name': '自动删种', 'state': sta_autoremovetorrents,
'id': 'autoremovetorrents', 'svg': svg, 'color': "twitter"})
# 自动签到
tim_ptsignin = pt.get('ptsignin_cron')
if tim_ptsignin:
if str(tim_ptsignin).find(':') == -1:
tim_ptsignin = "%s 小时" % tim_ptsignin
sta_ptsignin = 'ON'
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-user-check" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path>
<path d="M16 11l2 2l4 -4"></path>
</svg>
'''
scheduler_cfg_list.append(
{'name': '站点签到', 'time': tim_ptsignin, 'state': sta_ptsignin, 'id': 'ptsignin', 'svg': svg,
'color': "facebook"})
# 目录同步
sync_paths = Sync().get_sync_dirs()
if sync_paths:
sta_sync = 'ON'
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-refresh" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"></path>
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"></path>
</svg>
'''
scheduler_cfg_list.append(
{'name': '目录同步', 'time': '实时监控', 'state': sta_sync, 'id': 'sync', 'svg': svg,
'color': "orange"})
# 豆瓣同步
douban_cfg = Config().get_config('douban')
if douban_cfg:
interval = douban_cfg.get('interval')
if interval:
interval = "%s 小时" % interval
sta_douban = "ON"
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-bookmarks" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M13 7a2 2 0 0 1 2 2v12l-5 -3l-5 3v-12a2 2 0 0 1 2 -2h6z"></path>
<path d="M9.265 4a2 2 0 0 1 1.735 -1h6a2 2 0 0 1 2 2v12l-1 -.6"></path>
</svg>
'''
scheduler_cfg_list.append(
{'name': '豆瓣想看', 'time': interval, 'state': sta_douban, 'id': 'douban', 'svg': svg,
'color': "pink"})
# 清理文件整理缓存
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-eraser" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19 20h-10.5l-4.21 -4.3a1 1 0 0 1 0 -1.41l10 -10a1 1 0 0 1 1.41 0l5 5a1 1 0 0 1 0 1.41l-9.2 9.3"></path>
<path d="M18 13.3l-6.3 -6.3"></path>
</svg>
'''
scheduler_cfg_list.append(
{'name': '清理转移缓存', 'time': '手动', 'state': 'OFF', 'id': 'blacklist', 'svg': svg, 'color': 'red'})
# 清理RSS缓存
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-eraser" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19 20h-10.5l-4.21 -4.3a1 1 0 0 1 0 -1.41l10 -10a1 1 0 0 1 1.41 0l5 5a1 1 0 0 1 0 1.41l-9.2 9.3"></path>
<path d="M18 13.3l-6.3 -6.3"></path>
</svg>
'''
scheduler_cfg_list.append(
{'name': '清理RSS缓存', 'time': '手动', 'state': 'OFF', 'id': 'rsshistory', 'svg': svg, 'color': 'purple'})
# 名称识别测试
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alphabet-greek" width="40" height="40" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M10 10v7"></path>
<rect x="5" y="10" width="5" height="7" rx="2"></rect>
<path d="M14 20v-11a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2"></path>
</svg>
'''
scheduler_cfg_list.append(
{'name': '名称识别测试', 'time': '', 'state': 'OFF', 'id': 'nametest', 'svg': svg, 'color': 'lime'})
# 过滤规则测试
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-adjustments-horizontal" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="14" cy="6" r="2"></circle>
<line x1="4" y1="6" x2="12" y2="6"></line>
<line x1="16" y1="6" x2="20" y2="6"></line>
<circle cx="8" cy="12" r="2"></circle>
<line x1="4" y1="12" x2="6" y2="12"></line>
<line x1="10" y1="12" x2="20" y2="12"></line>
<circle cx="17" cy="18" r="2"></circle>
<line x1="4" y1="18" x2="15" y2="18"></line>
<line x1="19" y1="18" x2="20" y2="18"></line>
</svg>
'''
scheduler_cfg_list.append(
{'name': '过滤规则测试', 'time': '', 'state': 'OFF', 'id': 'ruletest', 'svg': svg, 'color': 'yellow'})
# 网络连通性测试
svg = '''
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-network" width="40" height="40" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="12" cy="9" r="6"></circle>
<path d="M12 3c1.333 .333 2 2.333 2 6s-.667 5.667 -2 6"></path>
<path d="M12 3c-1.333 .333 -2 2.333 -2 6s.667 5.667 2 6"></path>
<path d="M6 9h12"></path>
<path d="M3 19h7"></path>
<path d="M14 19h7"></path>
<circle cx="12" cy="19" r="2"></circle>
<path d="M12 15v2"></path>
</svg>
'''
targets = ModuleConf.NETTEST_TARGETS
scheduler_cfg_list.append(
{'name': '网络连通性测试', 'time': '', 'state': 'OFF', 'id': 'nettest', 'svg': svg, 'color': 'cyan',
"targets": targets})
# 备份
svg = '''
<svg t="1660720525544" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1559" width="16" height="16">
<path d="M646 1024H100A100 100 0 0 1 0 924V258a100 100 0 0 1 100-100h546a100 100 0 0 1 100 100v31a40 40 0 1 1-80 0v-31a20 20 0 0 0-20-20H100a20 20 0 0 0-20 20v666a20 20 0 0 0 20 20h546a20 20 0 0 0 20-20V713a40 40 0 0 1 80 0v211a100 100 0 0 1-100 100z" fill="#ffffff" p-id="1560"></path>
<path d="M924 866H806a40 40 0 0 1 0-80h118a20 20 0 0 0 20-20V100a20 20 0 0 0-20-20H378a20 20 0 0 0-20 20v8a40 40 0 0 1-80 0v-8A100 100 0 0 1 378 0h546a100 100 0 0 1 100 100v666a100 100 0 0 1-100 100z" fill="#ffffff" p-id="1561"></path>
<path d="M469 887a40 40 0 0 1-27-10L152 618a40 40 0 0 1 1-60l290-248a40 40 0 0 1 66 30v128a367 367 0 0 0 241-128l94-111a40 40 0 0 1 70 35l-26 109a430 430 0 0 1-379 332v142a40 40 0 0 1-40 40zM240 589l189 169v-91a40 40 0 0 1 40-40c144 0 269-85 323-214a447 447 0 0 1-323 137 40 40 0 0 1-40-40v-83z" fill="#ffffff" p-id="1562"></path>
</svg>
'''
scheduler_cfg_list.append(
{'name': '备份&恢复', 'time': '', 'state': 'OFF', 'id': 'backup', 'svg': svg, 'color': 'green'})
return render_template("service.html",
Count=len(scheduler_cfg_list),
RuleGroups=RuleGroups,
SchedulerTasks=scheduler_cfg_list)
# 历史记录页面
@App.route('/history', methods=['POST', 'GET'])
@login_required
def history():
pagenum = request.args.get("pagenum")
keyword = request.args.get("s") or ""
current_page = request.args.get("page")
Result = WebAction().get_transfer_history({"keyword": keyword, "page": current_page, "pagenum": pagenum})
PageRange = WebUtils.get_page_range(current_page=Result.get("currentPage"),
total_page=Result.get("totalPage"))
return render_template("rename/history.html",
TotalCount=Result.get("total"),
Count=len(Result.get("result")),
Historys=Result.get("result"),
Search=keyword,
CurrentPage=Result.get("currentPage"),
TotalPage=Result.get("totalPage"),
PageRange=PageRange,
PageNum=Result.get("currentPage"))
# TMDB缓存页面
@App.route('/tmdbcache', methods=['POST', 'GET'])
@login_required
def tmdbcache():
page_num = request.args.get("pagenum")
if not page_num:
page_num = 30
search_str = request.args.get("s")
if not search_str:
search_str = ""
current_page = request.args.get("page")
if not current_page:
current_page = 1
else:
current_page = int(current_page)
total_count, tmdb_caches = MetaHelper().dump_meta_data(search_str, current_page, page_num)
total_page = floor(total_count / page_num) + 1
page_range = WebUtils.get_page_range(current_page=current_page,
total_page=total_page)
return render_template("rename/tmdbcache.html",
TotalCount=total_count,
Count=len(tmdb_caches),
TmdbCaches=tmdb_caches,
Search=search_str,
CurrentPage=current_page,
TotalPage=total_page,
PageRange=page_range,
PageNum=page_num)
# 手工识别页面
@App.route('/unidentification', methods=['POST', 'GET'])
@login_required
def unidentification():
pagenum = request.args.get("pagenum")
keyword = request.args.get("s") or ""
current_page = request.args.get("page")
Result = WebAction().get_unknown_list_by_page({"keyword": keyword, "page": current_page, "pagenum": pagenum})
PageRange = WebUtils.get_page_range(current_page=Result.get("currentPage"),
total_page=Result.get("totalPage"))
return render_template("rename/unidentification.html",
TotalCount=Result.get("total"),
Count=len(Result.get("items")),
Items=Result.get("items"),
Search=keyword,
CurrentPage=Result.get("currentPage"),
TotalPage=Result.get("totalPage"),
PageRange=PageRange,
PageNum=Result.get("currentPage"))
# 文件管理页面
@App.route('/mediafile', methods=['POST', 'GET'])
@login_required
def mediafile():
download_dirs = Downloader().get_download_visit_dirs()
if download_dirs:
try:
DirD = os.path.commonpath(download_dirs).replace("\\", "/")
except Exception as err:
print(str(err))
DirD = "/"
else:
DirD = "/"
DirR = request.args.get("dir")
return render_template("rename/mediafile.html",
Dir=DirR or DirD)
# 基础设置页面
@App.route('/basic', methods=['POST', 'GET'])
@login_required
def basic():
proxy = Config().get_config('app').get("proxies", {}).get("http")
if proxy:
proxy = proxy.replace("http://", "")
RmtModeDict = WebAction().get_rmt_modes()
CustomScriptCfg = SystemConfig().get_system_config("CustomScript")
return render_template("setting/basic.html",
Config=Config().get_config(),
Proxy=proxy,
RmtModeDict=RmtModeDict,
CustomScriptCfg=CustomScriptCfg)
# 自定义识别词设置页面
@App.route('/customwords', methods=['POST', 'GET'])
@login_required
def customwords():
groups = WebAction().get_customwords().get("result")
return render_template("setting/customwords.html",
Groups=groups,
GroupsCount=len(groups))
# 目录同步页面
@App.route('/directorysync', methods=['POST', 'GET'])
@login_required
def directorysync():
RmtModeDict = WebAction().get_rmt_modes()
SyncPaths = WebAction().get_directorysync().get("result")
return render_template("setting/directorysync.html",
SyncPaths=SyncPaths,
SyncCount=len(SyncPaths),
RmtModeDict=RmtModeDict)
# 豆瓣页面
@App.route('/douban', methods=['POST', 'GET'])
@login_required
def douban():
DoubanHistory = WebAction().get_douban_history().get("result")
return render_template("setting/douban.html",
Config=Config().get_config(),
HistoryCount=len(DoubanHistory),
DoubanHistory=DoubanHistory)
# 下载器页面
@App.route('/downloader', methods=['POST', 'GET'])
@login_required
def downloader():
return render_template("setting/downloader.html",
Config=Config().get_config(),
SpeedLimitConf=SystemConfig().get_system_config("SpeedLimit") or {},
DownloaderConf=ModuleConf.DOWNLOADER_CONF)
# 下载设置页面
@App.route('/download_setting', methods=['POST', 'GET'])
@login_required
def download_setting():
DownloadSetting = Downloader().get_download_setting()
DefaultDownloadSetting = Downloader().get_default_download_setting()
Count = len(DownloadSetting)
return render_template("setting/download_setting.html",
DownloadSetting=DownloadSetting,
DefaultDownloadSetting=DefaultDownloadSetting,
DownloaderTypes=DownloaderType,
Count=Count)
# 索引器页面
@App.route('/indexer', methods=['POST', 'GET'])
@login_required
def indexer():
indexers = Indexer().get_builtin_indexers(check=False)
private_count = len([item.id for item in indexers if not item.public])
public_count = len([item.id for item in indexers if item.public])
return render_template("setting/indexer.html",
Config=Config().get_config(),
PrivateCount=private_count,
PublicCount=public_count,
Indexers=indexers,
IndexerConf=ModuleConf.INDEXER_CONF)
# 媒体库页面
@App.route('/library', methods=['POST', 'GET'])
@login_required
def library():
return render_template("setting/library.html", Config=Config().get_config())
# 媒体服务器页面
@App.route('/mediaserver', methods=['POST', 'GET'])
@login_required
def mediaserver():
return render_template("setting/mediaserver.html",
Config=Config().get_config(),
MediaServerConf=ModuleConf.MEDIASERVER_CONF)
# 通知消息页面
@App.route('/notification', methods=['POST', 'GET'])
@login_required
def notification():
MessageClients = Message().get_message_client_info()
Channels = ModuleConf.MESSAGE_CONF.get("client")
Switchs = ModuleConf.MESSAGE_CONF.get("switch")
return render_template("setting/notification.html",
Channels=Channels,
Switchs=Switchs,
ClientCount=len(MessageClients),
MessageClients=MessageClients)
# 用户管理页面
@App.route('/users', methods=['POST', 'GET'])
@login_required
def users():
Users = WebAction().get_users().get("result")
return render_template("setting/users.html", Users=Users, UserCount=len(Users))
# 过滤规则设置页面
@App.route('/filterrule', methods=['POST', 'GET'])
@login_required
def filterrule():
result = WebAction().get_filterrules()
return render_template("setting/filterrule.html",
Count=len(result.get("ruleGroups")),
RuleGroups=result.get("ruleGroups"),
Init_RuleGroups=result.get("initRules"))
# 自定义订阅页面
@App.route('/user_rss', methods=['POST', 'GET'])
@login_required
def user_rss():
Tasks = RssChecker().get_rsstask_info()
RssParsers = RssChecker().get_userrss_parser()
RuleGroups = {str(group["id"]): group["name"] for group in Filter().get_rule_groups()}
DownloadSettings = {did: attr["name"] for did, attr in Downloader().get_download_setting().items()}
RestypeDict = ModuleConf.TORRENT_SEARCH_PARAMS.get("restype")
PixDict = ModuleConf.TORRENT_SEARCH_PARAMS.get("pix")
return render_template("rss/user_rss.html",
Tasks=Tasks,
Count=len(Tasks),
RssParsers=RssParsers,
RuleGroups=RuleGroups,
RestypeDict=RestypeDict,
PixDict=PixDict,
DownloadSettings=DownloadSettings)
# RSS解析器页面
@App.route('/rss_parser', methods=['POST', 'GET'])
@login_required
def rss_parser():
RssParsers = RssChecker().get_userrss_parser()
return render_template("rss/rss_parser.html",
RssParsers=RssParsers,
Count=len(RssParsers))
# 插件页面
@App.route('/plugin', methods=['POST', 'GET'])
@login_required
def plugin():
Plugins = PluginManager().get_plugins_conf()
return render_template("setting/plugin.html",
Plugins=Plugins)
# 事件响应
@App.route('/do', methods=['POST'])
@action_login_check
def do():
try:
cmd = request.form.get("cmd")
data = request.form.get("data")
except Exception as e:
ExceptionUtils.exception_traceback(e)
return {"code": -1, "msg": str(e)}
if data:
data = json.loads(data)
return WebAction().action(cmd, data)
# 目录事件响应
@App.route('/dirlist', methods=['POST'])
@login_required
def dirlist():
r = ['<ul class="jqueryFileTree" style="display: none;">']
try:
r = ['<ul class="jqueryFileTree" style="display: none;">']
in_dir = request.form.get('dir')
ft = request.form.get("filter")
if not in_dir or in_dir == "/":
if SystemUtils.get_system() == OsType.WINDOWS:
partitions = SystemUtils.get_windows_drives()
if partitions:
dirs = partitions
else:
dirs = [os.path.join("C:/", f) for f in os.listdir("C:/")]
else:
dirs = [os.path.join("/", f) for f in os.listdir("/")]
else:
d = os.path.normpath(urllib.parse.unquote(in_dir))
if not os.path.isdir(d):
d = os.path.dirname(d)
dirs = [os.path.join(d, f) for f in os.listdir(d)]
for ff in dirs:
f = os.path.basename(ff)
if not f:
f = ff
if os.path.isdir(ff):
r.append('<li class="directory collapsed"><a rel="%s/">%s</a></li>' % (
ff.replace("\\", "/"), f.replace("\\", "/")))
else:
if ft != "HIDE_FILES_FILTER":
e = os.path.splitext(f)[1][1:]
r.append('<li class="file ext_%s"><a rel="%s">%s</a></li>' % (
e, ff.replace("\\", "/"), f.replace("\\", "/")))
r.append('</ul>')
except Exception as e:
ExceptionUtils.exception_traceback(e)
r.append('加载路径失败: %s' % str(e))
r.append('</ul>')
return make_response(''.join(r), 200)
# 禁止搜索引擎
@App.route('/robots.txt', methods=['GET', 'POST'])
def robots():
return send_from_directory("", "robots.txt")
# 响应企业微信消息
@App.route('/wechat', methods=['GET', 'POST'])
def wechat():
# 当前在用的交互渠道
interactive_client = Message().get_interactive_client(SearchType.WX)
if not interactive_client:
return make_response("NAStool没有启用微信交互", 200)
conf = interactive_client.get("config")
sToken = conf.get('token')
sEncodingAESKey = conf.get('encodingAESKey')
sCorpID = conf.get('corpid')
if not sToken or not sEncodingAESKey or not sCorpID:
return
wxcpt = WXBizMsgCrypt(sToken, sEncodingAESKey, sCorpID)
sVerifyMsgSig = request.args.get("msg_signature")
sVerifyTimeStamp = request.args.get("timestamp")
sVerifyNonce = request.args.get("nonce")
if request.method == 'GET':
if not sVerifyMsgSig and not sVerifyTimeStamp and not sVerifyNonce:
return "NAStool微信交互服务正常<br>微信回调配置步聚:<br>1、在微信企业应用接收消息设置页面生成Token和EncodingAESKey并填入设置->消息通知->微信对应项,打开微信交互开关。<br>2、保存并重启本工具保存并重启本工具保存并重启本工具。<br>3、在微信企业应用接收消息设置页面输入此地址http(s)://IP:PORT/wechatIP、PORT替换为本工具的外网访问地址及端口需要有公网IP并做好端口转发最好有域名"
sVerifyEchoStr = request.args.get("echostr")
log.debug("收到微信验证请求: echostr= %s" % sVerifyEchoStr)
ret, sEchoStr = wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp, sVerifyNonce, sVerifyEchoStr)
if ret != 0:
log.error("微信请求验证失败 VerifyURL ret: %s" % str(ret))
# 验证URL成功将sEchoStr返回给企业号
return sEchoStr
else:
try:
sReqData = request.data
log.debug("收到微信消息:%s" % str(sReqData))
ret, sMsg = wxcpt.DecryptMsg(sReqData, sVerifyMsgSig, sVerifyTimeStamp, sVerifyNonce)
if ret != 0:
log.error("解密微信消息失败 DecryptMsg ret = %s" % str(ret))
return make_response("ok", 200)
# 解析XML报文
"""
1、消息格式
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1348831860</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[this is a test]]></Content>
<MsgId>1234567890123456</MsgId>
<AgentID>1</AgentID>
</xml>
2、事件格式
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[UserID]]></FromUserName>
<CreateTime>1348831860</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
<AgentID>1</AgentID>
</xml>
"""
dom_tree = xml.dom.minidom.parseString(sMsg.decode('UTF-8'))
root_node = dom_tree.documentElement
# 消息类型
msg_type = DomUtils.tag_value(root_node, "MsgType")
# 用户ID
user_id = DomUtils.tag_value(root_node, "FromUserName")
# 没的消息类型和用户ID的消息不要
if not msg_type or not user_id:
log.info("收到微信心跳报文...")
return make_response("ok", 200)
# 解析消息内容
content = ""
if msg_type == "event":
# 校验用户有权限执行交互命令
if conf.get("adminUser") and not any(user_id == admin_user for admin_user in str(conf.get("adminUser")).split(";")):
Message().send_channel_msg(channel=SearchType.WX, title="用户无权限执行菜单命令", user_id=user_id)
return make_response(content, 200)
# 事件消息
event_key = DomUtils.tag_value(root_node, "EventKey")
if event_key:
log.info("点击菜单:%s" % event_key)
keys = event_key.split('#')
if len(keys) > 2:
content = ModuleConf.WECHAT_MENU.get(keys[2])
elif msg_type == "text":
# 文本消息
content = DomUtils.tag_value(root_node, "Content", default="")
if content:
# 处理消息内容
WebAction().handle_message_job(msg=content,
in_from=SearchType.WX,
user_id=user_id,
user_name=user_id)
return make_response(content, 200)
except Exception as err:
ExceptionUtils.exception_traceback(err)
log.error("微信消息处理发生错误:%s - %s" % (str(err), traceback.format_exc()))
return make_response("ok", 200)
# Plex Webhook
@App.route('/plex', methods=['POST'])
def plex_webhook():
if not SecurityHelper().check_mediaserver_ip(request.remote_addr):
log.warn(f"非法IP地址的媒体服务器消息通知{request.remote_addr}")
return '不允许的IP地址请求'
request_json = json.loads(request.form.get('payload', {}))
log.debug("收到Plex Webhook报文%s" % str(request_json))
# 发送消息
ThreadHelper().start_thread(MediaServer().webhook_message_handler,
(request_json, MediaServerType.PLEX))
# 触发事件
EventManager().send_event(EventType.PlexWebhook, request_json)
return 'Ok'
# Jellyfin Webhook
@App.route('/jellyfin', methods=['POST'])
def jellyfin_webhook():
if not SecurityHelper().check_mediaserver_ip(request.remote_addr):
log.warn(f"非法IP地址的媒体服务器消息通知{request.remote_addr}")
return '不允许的IP地址请求'
request_json = request.get_json()
log.debug("收到Jellyfin Webhook报文%s" % str(request_json))
# 发送消息
ThreadHelper().start_thread(MediaServer().webhook_message_handler,
(request_json, MediaServerType.JELLYFIN))
# 触发事件
EventManager().send_event(EventType.JellyfinWebhook, request_json)
return 'Ok'
@App.route('/emby', methods=['POST'])
# Emby Webhook
def emby_webhook():
if not SecurityHelper().check_mediaserver_ip(request.remote_addr):
log.warn(f"非法IP地址的媒体服务器消息通知{request.remote_addr}")
return '不允许的IP地址请求'
request_json = json.loads(request.form.get('data', {}))
log.debug("收到Emby Webhook报文%s" % str(request_json))
# 发送消息
ThreadHelper().start_thread(MediaServer().webhook_message_handler,
(request_json, MediaServerType.EMBY))
# 触发事件
EventManager().send_event(EventType.EmbyWebhook, request_json)
return 'Ok'
# Telegram消息响应
@App.route('/telegram', methods=['POST', 'GET'])
def telegram():
"""
{
'update_id': ,
'message': {
'message_id': ,
'from': {
'id': ,
'is_bot': False,
'first_name': '',
'username': '',
'language_code': 'zh-hans'
},
'chat': {
'id': ,
'first_name': '',
'username': '',
'type': 'private'
},
'date': ,
'text': ''
}
}
"""
# 当前在用的交互渠道
interactive_client = Message().get_interactive_client(SearchType.TG)
if not interactive_client:
return 'NAStool未启用Telegram交互'
msg_json = request.get_json()
if not SecurityHelper().check_telegram_ip(request.remote_addr):
log.error("收到来自 %s 的非法Telegram消息%s" % (request.remote_addr, msg_json))
return '不允许的IP地址请求'
if msg_json:
message = msg_json.get("message", {})
text = message.get("text")
user_id = message.get("from", {}).get("id")
log.info("收到Telegram消息from=%s, text=%s" % (user_id, text))
# 获取用户名
user_name = message.get("from", {}).get("username")
if text:
# 检查权限
if text.startswith("/"):
if str(user_id) not in interactive_client.get("client").get_admin():
Message().send_channel_msg(channel=SearchType.TG,
title="只有管理员才有权限执行此命令",
user_id=user_id)
return '只有管理员才有权限执行此命令'
else:
if not str(user_id) in interactive_client.get("client").get_users():
message.send_channel_msg(channel=SearchType.TG,
title="你不在用户白名单中,无法使用此机器人",
user_id=user_id)
return '你不在用户白名单中,无法使用此机器人'
WebAction().handle_message_job(msg=text,
in_from=SearchType.TG,
user_id=user_id,
user_name=user_name)
return 'Ok'
# Synology Chat消息响应
@App.route('/synology', methods=['POST', 'GET'])
def synology():
"""
token: bot token
user_id
username
post_id
timestamp
text
"""
# 当前在用的交互渠道
interactive_client = Message().get_interactive_client(SearchType.SYNOLOGY)
if not interactive_client:
return 'NAStool未启用Synology Chat交互'
msg_data = request.form
if not SecurityHelper().check_synology_ip(request.remote_addr):
log.error("收到来自 %s 的非法Synology Chat消息%s" % (request.remote_addr, msg_data))
return '不允许的IP地址请求'
if msg_data:
token = msg_data.get("token")
if not interactive_client.get("client").check_token(token):
log.error("收到来自 %s 的非法Synology Chat消息token校验不通过" % request.remote_addr)
return 'token校验不通过'
text = msg_data.get("text")
user_id = int(msg_data.get("user_id"))
log.info("收到Synology Chat消息from=%s, text=%s" % (user_id, text))
# 获取用户名
user_name = msg_data.get("username")
if text:
WebAction().handle_message_job(msg=text,
in_from=SearchType.SYNOLOGY,
user_id=user_id,
user_name=user_name)
return 'Ok'
# Slack消息响应
@App.route('/slack', methods=['POST'])
def slack():
"""
# 消息
{
'client_msg_id': '',
'type': 'message',
'text': 'hello',
'user': '',
'ts': '1670143568.444289',
'blocks': [{
'type': 'rich_text',
'block_id': 'i2j+',
'elements': [{
'type': 'rich_text_section',
'elements': [{
'type': 'text',
'text': 'hello'
}]
}]
}],
'team': '',
'client': '',
'event_ts': '1670143568.444289',
'channel_type': 'im'
}
# 快捷方式
{
"type": "shortcut",
"token": "XXXXXXXXXXXXX",
"action_ts": "1581106241.371594",
"team": {
"id": "TXXXXXXXX",
"domain": "shortcuts-test"
},
"user": {
"id": "UXXXXXXXXX",
"username": "aman",
"team_id": "TXXXXXXXX"
},
"callback_id": "shortcut_create_task",
"trigger_id": "944799105734.773906753841.38b5894552bdd4a780554ee59d1f3638"
}
# 按钮点击
{
"type": "block_actions",
"team": {
"id": "T9TK3CUKW",
"domain": "example"
},
"user": {
"id": "UA8RXUSPL",
"username": "jtorrance",
"team_id": "T9TK3CUKW"
},
"api_app_id": "AABA1ABCD",
"token": "9s8d9as89d8as9d8as989",
"container": {
"type": "message_attachment",
"message_ts": "1548261231.000200",
"attachment_id": 1,
"channel_id": "CBR2V3XEX",
"is_ephemeral": false,
"is_app_unfurl": false
},
"trigger_id": "12321423423.333649436676.d8c1bb837935619ccad0f624c448ffb3",
"client": {
"id": "CBR2V3XEX",
"name": "review-updates"
},
"message": {
"bot_id": "BAH5CA16Z",
"type": "message",
"text": "This content can't be displayed.",
"user": "UAJ2RU415",
"ts": "1548261231.000200",
...
},
"response_url": "https://hooks.slack.com/actions/AABA1ABCD/1232321423432/D09sSasdasdAS9091209",
"actions": [
{
"action_id": "WaXA",
"block_id": "=qXel",
"text": {
"type": "plain_text",
"text": "View",
"emoji": true
},
"value": "click_me_123",
"type": "button",
"action_ts": "1548426417.840180"
}
]
}
"""
# 只有本地转发请求能访问
if not SecurityHelper().check_slack_ip(request.remote_addr):
log.warn(f"非法IP地址的Slack消息通知{request.remote_addr}")
return '不允许的IP地址请求'
# 当前在用的交互渠道
interactive_client = Message().get_interactive_client(SearchType.SLACK)
if not interactive_client:
return 'NAStool未启用Slack交互'
msg_json = request.get_json()
if msg_json:
if msg_json.get("type") == "message":
channel = msg_json.get("client")
text = msg_json.get("text")
username = ""
elif msg_json.get("type") == "block_actions":
channel = msg_json.get("client", {}).get("id")
text = msg_json.get("actions")[0].get("value")
username = msg_json.get("user", {}).get("name")
elif msg_json.get("type") == "event_callback":
channel = msg_json.get("event", {}).get("client")
text = re.sub(r"<@[0-9A-Z]+>", "", msg_json.get("event", {}).get("text"), flags=re.IGNORECASE).strip()
username = ""
elif msg_json.get("type") == "shortcut":
channel = ""
text = msg_json.get("callback_id")
username = msg_json.get("user", {}).get("username")
else:
return "Error"
WebAction().handle_message_job(msg=text,
in_from=SearchType.SLACK,
user_id=channel,
user_name=username)
return "Ok"
# Jellyseerr Overseerr订阅接口
@App.route('/subscribe', methods=['POST', 'GET'])
@require_auth
def subscribe():
"""
{
"notification_type": "{{notification_type}}",
"event": "{{event}}",
"subject": "{{subject}}",
"message": "{{message}}",
"image": "{{image}}",
"{{media}}": {
"media_type": "{{media_type}}",
"tmdbId": "{{media_tmdbid}}",
"tvdbId": "{{media_tvdbid}}",
"status": "{{media_status}}",
"status4k": "{{media_status4k}}"
},
"{{request}}": {
"request_id": "{{request_id}}",
"requestedBy_email": "{{requestedBy_email}}",
"requestedBy_username": "{{requestedBy_username}}",
"requestedBy_avatar": "{{requestedBy_avatar}}"
},
"{{issue}}": {
"issue_id": "{{issue_id}}",
"issue_type": "{{issue_type}}",
"issue_status": "{{issue_status}}",
"reportedBy_email": "{{reportedBy_email}}",
"reportedBy_username": "{{reportedBy_username}}",
"reportedBy_avatar": "{{reportedBy_avatar}}"
},
"{{comment}}": {
"comment_message": "{{comment_message}}",
"commentedBy_email": "{{commentedBy_email}}",
"commentedBy_username": "{{commentedBy_username}}",
"commentedBy_avatar": "{{commentedBy_avatar}}"
},
"{{extra}}": []
}
"""
req_json = request.get_json()
if not req_json:
return make_response("非法请求!", 400)
notification_type = req_json.get("notification_type")
if notification_type not in ["MEDIA_APPROVED", "MEDIA_AUTO_APPROVED"]:
return make_response("ok", 200)
subject = req_json.get("subject")
media_type = MediaType.MOVIE if req_json.get("media", {}).get("media_type") == "movie" else MediaType.TV
tmdbId = req_json.get("media", {}).get("tmdbId")
if not media_type or not tmdbId or not subject:
return make_response("请求参数不正确!", 500)
# 添加订阅
code = 0
msg = "ok"
meta_info = MetaInfo(title=subject, mtype=media_type)
if media_type == MediaType.MOVIE:
code, msg, meta_info = Subscribe().add_rss_subscribe(mtype=media_type,
name=meta_info.get_name(),
year=meta_info.year,
mediaid=tmdbId)
meta_info.user_name = req_json.get("request", {}).get("requestedBy_username")
Message().send_rss_success_message(in_from=SearchType.API,
media_info=meta_info)
else:
seasons = []
for extra in req_json.get("extra", []):
if extra.get("name") == "Requested Seasons":
seasons = [int(str(sea).strip()) for sea in extra.get("value").split(", ") if str(sea).isdigit()]
break
for season in seasons:
code, msg, meta_info = Subscribe().add_rss_subscribe(mtype=media_type,
name=meta_info.get_name(),
year=meta_info.year,
mediaid=tmdbId,
season=season)
Message().send_rss_success_message(in_from=SearchType.API,
media_info=meta_info)
if code == 0:
return make_response("ok", 200)
else:
return make_response(msg, 500)
# 备份配置文件
@App.route('/backup', methods=['POST'])
@login_required
def backup():
"""
备份用户设置文件
:return: 备份文件.zip_file
"""
try:
# 创建备份文件夹
config_path = Path(Config().get_config_path())
backup_file = f"bk_{time.strftime('%Y%m%d%H%M%S')}"
backup_path = config_path / "backup_file" / backup_file
backup_path.mkdir(parents=True)
# 把现有的相关文件进行copy备份
shutil.copy(f'{config_path}/config.yaml', backup_path)
shutil.copy(f'{config_path}/default-category.yaml', backup_path)
shutil.copy(f'{config_path}/user.db', backup_path)
conn = sqlite3.connect(f'{backup_path}/user.db')
cursor = conn.cursor()
# 执行操作删除不需要备份的表
table_list = [
'SEARCH_RESULT_INFO',
'RSS_TORRENTS',
'DOUBAN_MEDIAS',
'TRANSFER_HISTORY',
'TRANSFER_UNKNOWN',
'TRANSFER_BLACKLIST',
'SYNC_HISTORY',
'DOWNLOAD_HISTORY',
'alembic_version'
]
for table in table_list:
cursor.execute(f"""DROP TABLE IF EXISTS {table};""")
conn.commit()
cursor.close()
conn.close()
zip_file = str(backup_path) + '.zip'
if os.path.exists(zip_file):
zip_file = str(backup_path) + '.zip'
shutil.make_archive(str(backup_path), 'zip', str(backup_path))
shutil.rmtree(str(backup_path))
except Exception as e:
ExceptionUtils.exception_traceback(e)
return make_response("创建备份失败", 400)
return send_file(zip_file)
# 上传文件到服务器
@App.route('/upload', methods=['POST'])
@login_required
def upload():
try:
files = request.files['file']
temp_path = Config().get_temp_path()
if not os.path.exists(temp_path):
os.makedirs(temp_path)
file_path = Path(temp_path) / files.filename
files.save(str(file_path))
return {"code": 0, "filepath": str(file_path)}
except Exception as e:
ExceptionUtils.exception_traceback(e)
return {"code": 1, "msg": str(e), "filepath": ""}
# base64模板过滤器
@App.template_filter('b64encode')
def b64encode(s):
return base64.b64encode(s.encode()).decode()
# split模板过滤器
@App.template_filter('split')
def split(string, char, pos):
return string.split(char)[pos]
# 刷流规则过滤器
@App.template_filter('brush_rule_string')
def brush_rule_string(rules):
return WebAction.parse_brush_rule_string(rules)
# 大小格式化过滤器
@App.template_filter('str_filesize')
def str_filesize(size):
return StringUtils.str_filesize(size, pre=1)
# MD5 HASH过滤器
@App.template_filter('hash')
def md5_hash(text):
return StringUtils.md5_hash(text)