diff --git a/.github/workflows/build-dev.yml b/.github/workflows/build-dev.yml
index eea8b9b9..3f83f86d 100644
--- a/.github/workflows/build-dev.yml
+++ b/.github/workflows/build-dev.yml
@@ -9,7 +9,7 @@ on:
- .github/workflows/build-dev.yml
- package_list.txt
- requirements.txt
- - docker/Dockerfile.dev
+ - docker/dev.Dockerfile
- docker/entrypoint.sh
jobs:
build:
@@ -46,10 +46,10 @@ jobs:
uses: docker/build-push-action@v2
with:
context: .
- file: docker/Dockerfile.dev
+ file: docker/dev.Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
- ${{ secrets.DOCKER_USERNAME }}/nas-tools:${{ env.app_version }}-beta
+ ${{ secrets.DOCKER_USERNAME }}/nas-tools:latest-beta
diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml
index d3edf884..89e5f8b7 100644
--- a/.github/workflows/build-package.yml
+++ b/.github/workflows/build-package.yml
@@ -50,7 +50,7 @@ jobs:
name: windows
path: D:/a/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.exe
- Linux-build:
+ Linux-build-amd64:
runs-on: ubuntu-latest
steps:
- name: init Python 3.10.10
@@ -84,14 +84,89 @@ jobs:
pwd
ls -all
pyinstaller nas-tools.spec
- mv /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux
+ mv /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux.amd64
ls -all /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist
shell: pwsh
- name: upload linux file
uses: actions/upload-artifact@v3
with:
- name: linux
- path: /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux
+ name: linux-amd64
+ path: /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux.amd64
+
+ Linux-build-arm64:
+ runs-on: ubuntu-latest
+ steps:
+ -
+ name: Checkout
+ uses: actions/checkout@master
+ -
+ name: Set Up QEMU
+ uses: docker/setup-qemu-action@v1
+ -
+ name: Set Up Buildx
+ uses: docker/setup-buildx-action@v1
+ - name: package through pyinstaller
+ run: |
+ mkdir rootfs
+ docker buildx build --platform linux/arm64 --file ./package/builder/Dockerfile --build-arg branch=master --output type=local,dest=./rootfs .
+ mkdir -p /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/
+ cp ./rootfs/nas-tools /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux.arm64
+ shell: pwsh
+ - name: upload linux file
+ uses: actions/upload-artifact@v3
+ with:
+ name: linux-arm64
+ path: /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux.arm64
+
+ Linux-build-amd64-musl:
+ runs-on: ubuntu-latest
+ steps:
+ -
+ name: Checkout
+ uses: actions/checkout@master
+ -
+ name: Set Up QEMU
+ uses: docker/setup-qemu-action@v1
+ -
+ name: Set Up Buildx
+ uses: docker/setup-buildx-action@v1
+ - name: package through pyinstaller
+ run: |
+ mkdir rootfs
+ docker buildx build --platform linux/amd64 --file ./package/builder/alpine.Dockerfile --build-arg branch=master --output type=local,dest=./rootfs .
+ mkdir -p /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/
+ cp ./rootfs/nas-tools /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux.musl.amd64
+ shell: pwsh
+ - name: upload linux file
+ uses: actions/upload-artifact@v3
+ with:
+ name: linux-musl-amd64
+ path: /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux.musl.amd64
+
+ Linux-build-arm64-musl:
+ runs-on: ubuntu-latest
+ steps:
+ -
+ name: Checkout
+ uses: actions/checkout@master
+ -
+ name: Set Up QEMU
+ uses: docker/setup-qemu-action@v1
+ -
+ name: Set Up Buildx
+ uses: docker/setup-buildx-action@v1
+ - name: package through pyinstaller
+ run: |
+ mkdir rootfs
+ docker buildx build --platform linux/arm64 --file ./package/builder/alpine.Dockerfile --build-arg branch=master --output type=local,dest=./rootfs .
+ mkdir -p /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/
+ cp ./rootfs/nas-tools /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux.musl.arm64
+ shell: pwsh
+ - name: upload linux file
+ uses: actions/upload-artifact@v3
+ with:
+ name: linux-musl-arm64
+ path: /home/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.linux.musl.arm64
Mac-build:
runs-on: macos-latest
@@ -126,7 +201,7 @@ jobs:
cp -r ./db_scripts/. $Python_ROOT_DIR/lib/python3.10/site-packages/db_scripts/
cd package
pyinstaller nas-tools.spec
- mv ./dist/nas-tools ./dist/nas-tools.mac
+ mv ./dist/nas-tools ./dist/nas-tools.macos
pwd
ls -all ./dist/
shell: bash
@@ -134,12 +209,12 @@ jobs:
uses: actions/upload-artifact@v3
with:
name: macos
- path: /Users/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.mac
+ path: /Users/runner/work/nas-tools/nas-tools/nas-tools/package/dist/nas-tools.macos
Create-release_Send-message:
permissions: write-all
runs-on: ubuntu-latest
- needs: [Windows-build, Linux-build, Mac-build]
+ needs: [Windows-build, Linux-build-amd64, Linux-build-arm64, Linux-build-amd64-musl, Linux-build-arm64-musl, Mac-build]
steps:
- uses: actions/checkout@v2
- name: Release version
@@ -156,8 +231,11 @@ jobs:
ls -all
mkdir releases
mv ./windows/nas-tools.exe /home/runner/work/nas-tools/nas-tools/releases/nastool_win_v${{ env.app_version }}.exe
- mv ./linux/nas-tools.linux /home/runner/work/nas-tools/nas-tools/releases/nastool_linux_v${{ env.app_version }}
- mv ./macos/nas-tools.mac /home/runner/work/nas-tools/nas-tools/releases/nastool_macos_v${{ env.app_version }}
+ mv ./linux-amd64/nas-tools.linux.amd64 /home/runner/work/nas-tools/nas-tools/releases/nastool_linux_amd64_v${{ env.app_version }}
+ mv ./linux-arm64/nas-tools.linux.arm64 /home/runner/work/nas-tools/nas-tools/releases/nastool_linux_arm64_v${{ env.app_version }}
+ mv ./linux-musl-amd64/nas-tools.linux.musl.amd64 /home/runner/work/nas-tools/nas-tools/releases/nastool_linux_musl_amd64_v${{ env.app_version }}
+ mv ./linux-musl-arm64/nas-tools.linux.musl.arm64 /home/runner/work/nas-tools/nas-tools/releases/nastool_linux_musl_arm64_v${{ env.app_version }}
+ mv ./macos/nas-tools.macos /home/runner/work/nas-tools/nas-tools/releases/nastool_macos_v${{ env.app_version }}
pwd
ls -all
- name: Create release
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 39d82899..a9015c16 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -53,4 +53,4 @@ jobs:
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/nas-tools:latest
- ${{ secrets.DOCKER_USERNAME }}/nas-tools:${{ env.app_version }}
\ No newline at end of file
+ ${{ secrets.DOCKER_USERNAME }}/nas-tools:${{ env.app_version }}
diff --git a/app/brushtask.py b/app/brushtask.py
index 6e3a41fb..991c6dd6 100644
--- a/app/brushtask.py
+++ b/app/brushtask.py
@@ -303,7 +303,7 @@ def __send_message(_task_name, _delete_type, _torrent_name):
set(torrent_ids).difference(
set([(torrent.get("hash")
if downloader_type == 'qbittorrent'
- else str(torrent.id)) for torrent in torrents])))
+ else str(torrent.hashString)) for torrent in torrents])))
# 完成的种子
for torrent in torrents:
torrent_info = self.__get_torrent_dict(downloader_type=downloader_type,
@@ -345,7 +345,7 @@ def __send_message(_task_name, _delete_type, _torrent_name):
set(remove_torrent_ids).difference(
set([(torrent.get("hash")
if downloader_type == 'qbittorrent'
- else str(torrent.id)) for torrent in torrents])))
+ else str(torrent.hashString)) for torrent in torrents])))
# 下载中的种子
for torrent in torrents:
torrent_info = self.__get_torrent_dict(downloader_type=downloader_type,
@@ -741,7 +741,7 @@ def __get_torrent_dict(downloader_type, torrent):
total_size = torrent.get("total_size")
else:
# ID
- torrent_id = torrent.id
+ torrent_id = torrent.hashString
# 做种时间
date_done = torrent.date_done or torrent.date_added
# 下载耗时
diff --git a/app/conf/systemconfig.py b/app/conf/systemconfig.py
index a1736048..2d9e55de 100644
--- a/app/conf/systemconfig.py
+++ b/app/conf/systemconfig.py
@@ -43,7 +43,7 @@ def set_system_config(self, key: [SystemConfigKey, str], value):
self.systemconfig[key] = value
# 写入数据库
if self.__is_obj(value):
- if value:
+ if value is not None:
value = json.dumps(value)
else:
value = ''
diff --git a/app/db/models.py b/app/db/models.py
index cba51446..7c5bb18c 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -257,6 +257,8 @@ class RSSMOVIES(Base):
FILTER_PIX = Column(Text)
FILTER_RULE = Column(Integer)
FILTER_TEAM = Column(Text)
+ FILTER_INCLUDE = Column(Text)
+ FILTER_EXCLUDE = Column(Text)
SAVE_PATH = Column(Text)
DOWNLOAD_SETTING = Column(Integer)
FUZZY_MATCH = Column(Integer)
@@ -302,6 +304,8 @@ class RSSTVS(Base):
FILTER_PIX = Column(Text)
FILTER_RULE = Column(Integer)
FILTER_TEAM = Column(Text)
+ FILTER_INCLUDE = Column(Text)
+ FILTER_EXCLUDE = Column(Text)
SAVE_PATH = Column(Text)
DOWNLOAD_SETTING = Column(Integer)
FUZZY_MATCH = Column(Integer)
diff --git a/app/doubansync.py b/app/doubansync.py
deleted file mode 100644
index 9a3d99d7..00000000
--- a/app/doubansync.py
+++ /dev/null
@@ -1,282 +0,0 @@
-import datetime
-import random
-from threading import Lock
-from time import sleep
-
-import log
-from app.downloader import Downloader
-from app.helper import DbHelper
-from app.media import Media, DouBan
-from app.media.meta import MetaInfo
-from app.searcher import Searcher
-from app.subscribe import Subscribe
-from app.utils import ExceptionUtils
-from app.utils.types import SearchType, MediaType, RssType
-from config import Config
-
-lock = Lock()
-
-
-class DoubanSync:
- douban = None
- searcher = None
- media = None
- downloader = None
- dbhelper = None
- subscribe = None
- _interval = None
- _auto_search = None
- _auto_rss = None
- _users = None
- _days = None
- _types = None
-
- def __init__(self):
- self.init_config()
-
- def init_config(self):
- self.douban = DouBan()
- self.searcher = Searcher()
- self.downloader = Downloader()
- self.media = Media()
- self.dbhelper = DbHelper()
- self.subscribe = Subscribe()
- douban = Config().get_config('douban')
- if douban:
- # 同步间隔
- self._interval = int(douban.get('interval')) if str(douban.get('interval')).isdigit() else None
- self._auto_search = douban.get('auto_search')
- self._auto_rss = douban.get('auto_rss')
- # 用户列表
- users = douban.get('users')
- if users:
- if not isinstance(users, list):
- users = [users]
- self._users = users
- # 时间范围
- self._days = int(douban.get('days')) if str(douban.get('days')).isdigit() else None
- # 类型
- types = douban.get('types')
- if types:
- self._types = types.split(',')
-
- def sync(self):
- """
- 同步豆瓣数据
- """
- if not self._interval:
- log.info("【Douban】豆瓣配置:同步间隔未配置或配置不正确")
- return
- with lock:
- log.info("【Douban】开始同步豆瓣数据...")
- # 拉取豆瓣数据
- medias = self.__get_all_douban_movies()
- # 开始检索
- for media in medias:
- if not media or not media.get_name():
- continue
- try:
- # 查询数据库状态,已经加入RSS的不处理
- search_state = self.dbhelper.get_douban_search_state(media.get_name(), media.year)
- if not search_state or search_state[0] == "NEW":
- if self._auto_search:
- # 需要检索
- if media.begin_season:
- subtitle = "第%s季" % media.begin_season
- else:
- subtitle = None
- media_info = self.media.get_media_info(title="%s %s" % (media.get_name(), media.year or ""),
- subtitle=subtitle,
- mtype=media.type)
- # 不需要自动加订阅,则直接搜索
- if not media_info or not media_info.tmdb_info:
- log.warn("【Douban】%s 未查询到媒体信息" % media.get_name())
- continue
- # 检查是否存在,电视剧返回不存在的集清单
- exist_flag, no_exists, _ = self.downloader.check_exists_medias(meta_info=media_info)
- # 已经存在
- if exist_flag:
- # 更新为已下载状态
- log.info("【Douban】%s 已存在" % media.get_name())
- self.dbhelper.insert_douban_media_state(media, "DOWNLOADED")
- continue
- if not self._auto_rss:
- # 合并季
- media_info.begin_season = media.begin_season
- # 开始检索
- search_result, no_exists, search_count, download_count = self.searcher.search_one_media(
- media_info=media_info,
- in_from=SearchType.DB,
- no_exists=no_exists,
- user_name=media_info.user_name)
- if search_result:
- # 下载全了更新为已下载,没下载全的下次同步再次搜索
- self.dbhelper.insert_douban_media_state(media, "DOWNLOADED")
- else:
- # 需要加订阅,则由订阅去检索
- log.info(
- "【Douban】%s %s 更新到%s订阅中..." % (media.get_name(), media.year, media.type.value))
- code, msg, _ = self.subscribe.add_rss_subscribe(mtype=media.type,
- name=media.get_name(),
- year=media.year,
- channel=RssType.Auto,
- season=media.begin_season,
- mediaid=f"DB:{media.douban_id}",
- in_from=SearchType.DB)
- if code != 0:
- log.error("【Douban】%s 添加订阅失败:%s" % (media.get_name(), msg))
- # 订阅已存在
- if code == 9:
- self.dbhelper.insert_douban_media_state(media, "RSS")
- else:
- # 插入为已RSS状态
- self.dbhelper.insert_douban_media_state(media, "RSS")
- else:
- # 不需要检索
- if self._auto_rss:
- # 加入订阅,使状态为R
- log.info("【Douban】%s %s 更新到%s订阅中..." % (
- media.get_name(), media.year, media.type.value))
- code, msg, _ = self.subscribe.add_rss_subscribe(mtype=media.type,
- name=media.get_name(),
- year=media.year,
- channel=RssType.Auto,
- season=media.begin_season,
- mediaid=f"DB:{media.douban_id}",
- state="R",
- in_from=SearchType.DB)
- if code != 0:
- log.error("【Douban】%s 添加订阅失败:%s" % (media.get_name(), msg))
- # 订阅已存在
- if code == 9:
- self.dbhelper.insert_douban_media_state(media, "RSS")
- else:
- # 插入为已RSS状态
- self.dbhelper.insert_douban_media_state(media, "RSS")
- elif not search_state:
- log.info("【Douban】%s %s 更新到%s列表中..." % (
- media.get_name(), media.year, media.type.value))
- self.dbhelper.insert_douban_media_state(media, "NEW")
-
- else:
- log.info("【Douban】%s %s 已处理过" % (media.get_name(), media.year))
- except Exception as err:
- log.error("【Douban】%s %s 处理失败:%s" % (media.get_name(), media.year, str(err)))
- continue
- log.info("【Douban】豆瓣数据同步完成")
-
- def __get_all_douban_movies(self):
- """
- 获取每一个用户的每一个类型的豆瓣标记
- :return: 检索到的媒体信息列表(不含TMDB信息)
- """
- if not self._interval \
- or not self._days \
- or not self._users \
- or not self._types:
- log.warn("【Douban】豆瓣未配置或配置不正确")
- return []
- # 返回媒体列表
- media_list = []
- # 豆瓣ID列表
- douban_ids = {}
- # 每页条数
- perpage_number = 15
- # 每一个用户
- for user in self._users:
- if not user:
- continue
- # 查询用户名称
- user_name = ""
- userinfo = self.douban.get_user_info(userid=user)
- if userinfo:
- user_name = userinfo.get("name")
- # 每一个类型成功数量
- user_succnum = 0
- for mtype in self._types:
- if not mtype:
- continue
- log.info(f"【Douban】开始获取 {user_name or user} 的 {mtype} 数据...")
- # 开始序号
- start_number = 0
- # 类型成功数量
- user_type_succnum = 0
- # 每一页
- while True:
- # 页数
- page_number = int(start_number / perpage_number + 1)
- # 当前页成功数量
- sucess_urlnum = 0
- # 是否继续下一页
- continue_next_page = True
- log.debug(f"【Douban】开始解析第 {page_number} 页数据...")
- try:
- items = self.douban.get_douban_wish(dtype=mtype, userid=user, start=start_number, wait=True)
- if not items:
- log.warn(f"【Douban】第 {page_number} 页未获取到数据")
- break
- # 解析豆瓣ID
- for item in items:
- # 时间范围
- date = item.get("date")
- if not date:
- continue_next_page = False
- break
- else:
- mark_date = datetime.datetime.strptime(date, '%Y-%m-%d')
- if not (datetime.datetime.now() - mark_date).days < int(self._days):
- continue_next_page = False
- break
- doubanid = item.get("id")
- if str(doubanid).isdigit():
- log.info("【Douban】解析到媒体:%s" % doubanid)
- if doubanid not in douban_ids:
- douban_ids[doubanid] = {
- "user_name": user_name
- }
- sucess_urlnum += 1
- user_type_succnum += 1
- user_succnum += 1
- log.debug(
- f"【Douban】{user_name or user} 第 {page_number} 页解析完成,共获取到 {sucess_urlnum} 个媒体")
- except Exception as err:
- ExceptionUtils.exception_traceback(err)
- log.error(f"【Douban】{user_name or user} 第 {page_number} 页解析出错:%s" % str(err))
- break
- # 继续下一页
- if continue_next_page:
- start_number += perpage_number
- else:
- break
- # 当前类型解析结束
- log.debug(f"【Douban】用户 {user_name or user} 的 {mtype} 解析完成,共获取到 {user_type_succnum} 个媒体")
- log.info(f"【Douban】用户 {user_name or user} 解析完成,共获取到 {user_succnum} 个媒体")
- log.info(f"【Douban】所有用户解析完成,共获取到 {len(douban_ids)} 个媒体")
- # 查询豆瓣详情
- for doubanid, info in douban_ids.items():
- douban_info = self.douban.get_douban_detail(doubanid=doubanid, wait=True)
- # 组装媒体信息
- if not douban_info:
- log.warn("【Douban】%s 未正确获取豆瓣详细信息,尝试使用网页获取" % doubanid)
- douban_info = self.douban.get_media_detail_from_web(doubanid)
- if not douban_info:
- log.warn("【Douban】%s 无权限访问,需要配置豆瓣Cookie" % doubanid)
- # 随机休眠
- sleep(round(random.uniform(1, 5), 1))
- continue
- media_type = MediaType.TV if douban_info.get("episodes_count") else MediaType.MOVIE
- log.info("【Douban】%s:%s %s".strip() % (media_type.value, douban_info.get("title"), douban_info.get("year")))
- meta_info = MetaInfo(title="%s %s" % (douban_info.get("title"), douban_info.get("year") or ""))
- meta_info.douban_id = doubanid
- meta_info.type = media_type
- meta_info.overview = douban_info.get("intro")
- meta_info.poster_path = douban_info.get("cover_url")
- rating = douban_info.get("rating", {}) or {}
- meta_info.vote_average = rating.get("value") or ""
- meta_info.imdb_id = douban_info.get("imdbid")
- meta_info.user_name = info.get("user_name")
- if meta_info not in media_list:
- media_list.append(meta_info)
- # 随机休眠
- sleep(round(random.uniform(1, 5), 1))
- return media_list
diff --git a/app/downloader/client/transmission.py b/app/downloader/client/transmission.py
index c0da6e79..9e79ad18 100644
--- a/app/downloader/client/transmission.py
+++ b/app/downloader/client/transmission.py
@@ -80,6 +80,17 @@ def __login_transmission(self):
def get_status(self):
return True if self.trc else False
+ @staticmethod
+ def __parse_ids(ids):
+ """
+ 统一处理种子ID
+ """
+ if isinstance(ids, list) and any([str(x).isdigit() for x in ids]):
+ ids = [int(x) for x in ids if str(x).isdigit()]
+ elif not isinstance(ids, list) and str(ids).isdigit():
+ ids = int(ids)
+ return ids
+
def get_torrents(self, ids=None, status=None, tag=None):
"""
获取种子列表
@@ -87,10 +98,7 @@ def get_torrents(self, ids=None, status=None, tag=None):
"""
if not self.trc:
return [], True
- if isinstance(ids, list):
- ids = [int(x) for x in ids if str(x).isdigit()]
- elif str(ids).isdigit():
- ids = int(ids)
+ ids = self.__parse_ids(ids)
try:
torrents = self.trc.get_torrents(ids=ids, arguments=self._trarg)
except Exception as err:
@@ -151,10 +159,7 @@ def set_torrents_status(self, ids, tags=None):
"""
if not self.trc:
return
- if isinstance(ids, list):
- ids = [int(x) for x in ids if str(x).isdigit()]
- elif str(ids).isdigit():
- ids = int(ids)
+ ids = self.__parse_ids(ids)
# 合成标签
if tags:
if not isinstance(tags, list):
@@ -176,8 +181,9 @@ def set_torrent_tag(self, tid, tag):
"""
if not tid or not tag:
return
+ ids = self.__parse_ids(tid)
try:
- self.trc.change_torrent(labels=tag, ids=int(tid))
+ self.trc.change_torrent(labels=tag, ids=ids)
except Exception as err:
ExceptionUtils.exception_traceback(err)
@@ -201,7 +207,7 @@ def change_torrent(self,
if not tid:
return
else:
- ids = int(tid)
+ ids = self.__parse_ids(tid)
if tag:
if isinstance(tag, list):
labels = tag
@@ -270,7 +276,7 @@ def get_transfer_task(self, tag, match_path=None):
true_path = self.get_replace_path(path, self.download_dir)
trans_tasks.append({
'path': os.path.join(true_path, torrent.name).replace("\\", "/"),
- 'id': torrent.id,
+ 'id': torrent.hashString,
'tags': torrent.labels
})
return trans_tasks
@@ -329,21 +335,21 @@ def get_remove_torrents(self, config=None):
if tr_error_key and not re.findall(tr_error_key, torrent.error_string, re.I):
continue
remove_torrents.append({
- "id": torrent.id,
+ "id": torrent.hashString,
"name": torrent.name,
"site": torrent.trackers[0].get("sitename"),
"size": torrent.total_size
})
- remove_torrents_ids.append(torrent.id)
+ remove_torrents_ids.append(torrent.hashString)
if config.get("samedata") and remove_torrents:
remove_torrents_plus = []
for remove_torrent in remove_torrents:
name = remove_torrent.get("name")
size = remove_torrent.get("size")
for torrent in torrents:
- if torrent.name == name and torrent.total_size == size and torrent.id not in remove_torrents_ids:
+ if torrent.name == name and torrent.total_size == size and torrent.hashString not in remove_torrents_ids:
remove_torrents_plus.append({
- "id": torrent.id,
+ "id": torrent.hashString,
"name": torrent.name,
"site": torrent.trackers[0].get("sitename") if torrent.trackers else "",
"size": torrent.total_size
@@ -364,11 +370,11 @@ def add_torrent(self, content,
download_dir=download_dir,
paused=is_paused,
cookies=cookie)
- if ret and ret.id:
+ if ret and ret.hashString:
if upload_limit:
- self.set_uploadspeed_limit(ret.id, int(upload_limit))
+ self.set_uploadspeed_limit(ret.hashString, int(upload_limit))
if download_limit:
- self.set_downloadspeed_limit(ret.id, int(download_limit))
+ self.set_downloadspeed_limit(ret.hashString, int(download_limit))
return ret
except Exception as err:
ExceptionUtils.exception_traceback(err)
@@ -377,10 +383,7 @@ def add_torrent(self, content,
def start_torrents(self, ids):
if not self.trc:
return False
- if isinstance(ids, list):
- ids = [int(x) for x in ids if str(x).isdigit()]
- elif str(ids).isdigit():
- ids = int(ids)
+ ids = self.__parse_ids(ids)
try:
return self.trc.start_torrent(ids=ids)
except Exception as err:
@@ -390,10 +393,7 @@ def start_torrents(self, ids):
def stop_torrents(self, ids):
if not self.trc:
return False
- if isinstance(ids, list):
- ids = [int(x) for x in ids if str(x).isdigit()]
- elif str(ids).isdigit():
- ids = int(ids)
+ ids = self.__parse_ids(ids)
try:
return self.trc.stop_torrent(ids=ids)
except Exception as err:
@@ -405,10 +405,7 @@ def delete_torrents(self, delete_file, ids):
return False
if not ids:
return False
- if isinstance(ids, list):
- ids = [int(x) for x in ids if str(x).isdigit()]
- elif str(ids).isdigit():
- ids = int(ids)
+ ids = self.__parse_ids(ids)
try:
return self.trc.remove_torrent(delete_data=delete_file, ids=ids)
except Exception as err:
@@ -471,10 +468,7 @@ def set_uploadspeed_limit(self, ids, limit):
return
if not ids or not limit:
return
- if not isinstance(ids, list):
- ids = int(ids)
- else:
- ids = [int(x) for x in ids if str(x).isdigit()]
+ ids = self.__parse_ids(ids)
self.trc.change_torrent(ids, uploadLimit=int(limit))
def set_downloadspeed_limit(self, ids, limit):
@@ -485,10 +479,7 @@ def set_downloadspeed_limit(self, ids, limit):
return
if not ids or not limit:
return
- if not isinstance(ids, list):
- ids = int(ids)
- else:
- ids = [int(x) for x in ids if str(x).isdigit()]
+ ids = self.__parse_ids(ids)
self.trc.change_torrent(ids, downloadLimit=int(limit))
def get_downloading_progress(self, tag=None, ids=None):
@@ -515,7 +506,7 @@ def get_downloading_progress(self, tag=None, ids=None):
# 进度
progress = round(torrent.progress)
DispTorrents.append({
- 'id': torrent.id,
+ 'id': torrent.hashString,
'name': torrent.name,
'speed': speed,
'state': state,
diff --git a/app/downloader/downloader.py b/app/downloader/downloader.py
index 6a4dae56..30a14096 100644
--- a/app/downloader/downloader.py
+++ b/app/downloader/downloader.py
@@ -430,7 +430,7 @@ def __download_fail(msg):
download_dir=download_dir,
cookie=site_info.get("cookie"))
if ret:
- download_id = ret.id
+ download_id = ret.hashString
downloader.change_torrent(tid=download_id,
tag=tags,
upload_limit=upload_limit,
diff --git a/app/helper/__init__.py b/app/helper/__init__.py
index eac7702f..221ea4e1 100644
--- a/app/helper/__init__.py
+++ b/app/helper/__init__.py
@@ -14,3 +14,4 @@
from .cookiecloud_helper import CookieCloudHelper
from .ffmpeg_helper import FfmpegHelper
from .redis_helper import RedisHelper
+from .iyuu_helper import IyuuHelper
diff --git a/app/helper/chrome_helper.py b/app/helper/chrome_helper.py
index 7f884fb0..ef8a9b6c 100644
--- a/app/helper/chrome_helper.py
+++ b/app/helper/chrome_helper.py
@@ -51,8 +51,6 @@ def browser(self):
return self._chrome
def get_status(self):
- if not self._executable_path:
- return False
if self._executable_path \
and not os.path.exists(self._executable_path):
return False
@@ -76,6 +74,8 @@ def __get_browser(self):
options.add_argument('--no-service-autorun')
options.add_argument('--no-default-browser-check')
options.add_argument('--password-store=basic')
+ if SystemUtils.is_windows() or SystemUtils.is_macos():
+ options.add_argument("--window-position=-32000,-32000")
if self._proxy:
proxy = Config().get_proxies().get("https")
if proxy:
diff --git a/app/helper/db_helper.py b/app/helper/db_helper.py
index f1eeafee..eed91baf 100644
--- a/app/helper/db_helper.py
+++ b/app/helper/db_helper.py
@@ -781,6 +781,8 @@ def insert_rss_movie(self, media_info,
filter_pix=None,
filter_team=None,
filter_rule=None,
+ filter_include=None,
+ filter_exclude=None,
save_path=None,
download_setting=-1,
fuzzy_match=0,
@@ -812,6 +814,8 @@ def insert_rss_movie(self, media_info,
FILTER_PIX=filter_pix,
FILTER_RULE=filter_rule,
FILTER_TEAM=filter_team,
+ FILTER_INCLUDE=filter_include,
+ FILTER_EXCLUDE=filter_exclude,
SAVE_PATH=save_path,
DOWNLOAD_SETTING=download_setting,
FUZZY_MATCH=fuzzy_match,
@@ -977,6 +981,8 @@ def insert_rss_tv(self,
filter_pix=None,
filter_team=None,
filter_rule=None,
+ filter_include=None,
+ filter_exclude=None,
save_path=None,
download_setting=-1,
total_ep=None,
@@ -1015,6 +1021,8 @@ def insert_rss_tv(self,
FILTER_PIX=filter_pix,
FILTER_RULE=filter_rule,
FILTER_TEAM=filter_team,
+ FILTER_INCLUDE=filter_include,
+ FILTER_EXCLUDE=filter_exclude,
SAVE_PATH=save_path,
DOWNLOAD_SETTING=download_setting,
FUZZY_MATCH=fuzzy_match,
diff --git a/app/helper/dict_helper.py b/app/helper/dict_helper.py
index 5ff4f9f7..5d8e8ae6 100644
--- a/app/helper/dict_helper.py
+++ b/app/helper/dict_helper.py
@@ -16,7 +16,7 @@ def set(self, dtype, key, value, note=""):
:param note: 备注
:return: True False
"""
- if not dtype or not key or not value:
+ if not dtype or not key:
return False
if self.exists(dtype, key):
return self._db.query(SYSTEMDICT).filter(SYSTEMDICT.TYPE == dtype,
diff --git a/app/helper/iyuu_helper.py b/app/helper/iyuu_helper.py
new file mode 100644
index 00000000..18af54f5
--- /dev/null
+++ b/app/helper/iyuu_helper.py
@@ -0,0 +1,128 @@
+import hashlib
+import json
+import time
+
+from app.utils import RequestUtils
+from app.utils.commons import singleton
+
+
+@singleton
+class IyuuHelper(object):
+ _version = "2.0.0"
+ _api_base = "https://api.iyuu.cn/%s"
+ _sites = []
+ _token = None
+
+ def __init__(self, token):
+ self._token = token
+ self.init_config()
+
+ def init_config(self):
+ self._sites = self.__get_sites()
+
+ def __request_iyuu(self, url, method="get", params=None):
+ """
+ 向IYUUApi发送请求
+ """
+ if params:
+ if not params.get("sign"):
+ params.update({"sign": self._token})
+ if not params.get("version"):
+ params.update({"version": self._version})
+ else:
+ params = {"sign": self._token, "version": self._version}
+ # 开始请求
+ if method == "get":
+ ret = RequestUtils(
+ accept_type="application/json"
+ ).get_res(f"{url}", params=params)
+ else:
+ ret = RequestUtils(
+ accept_type="application/json"
+ ).post_res(f"{url}", data=json.dumps(params))
+ if ret:
+ result = ret.json()
+ if result.get('ret') == 200:
+ return result.get('data'), ""
+ else:
+ return None, f"请求IYUU失败,状态码:{result.get('ret')},返回信息:{result.get('msg')}"
+ elif ret is not None:
+ return None, f"请求IYUU失败,状态码:{ret.status_code},错误原因:{ret.reason}"
+ else:
+ return None, f"请求IYUU失败,未获取到返回信息"
+
+ def get_torrent_url(self, sid):
+ if not self._sites:
+ return None, None
+ if not sid:
+ return None, None
+ for site in self._sites:
+ if site.get('id') == sid:
+ return site.get('base_url'), site.get('download_page')
+ return None, None
+
+ def __get_sites(self):
+ """
+ 返回支持辅种的全部站点
+ :return: 站点列表、错误信息
+ {
+ "ret": 200,
+ "data": {
+ "sites": [
+ {
+ "id": 1,
+ "site": "keepfrds",
+ "nickname": "朋友",
+ "base_url": "pt.keepfrds.com",
+ "download_page": "download.php?id={}&passkey={passkey}",
+ "reseed_check": "passkey",
+ "is_https": 2
+ },
+ ]
+ }
+ }
+ """
+ result, msg = self.__request_iyuu(url=self._api_base % 'api/sites')
+ if result:
+ return result.get('sites')
+ else:
+ print(msg)
+ return []
+
+ def get_seed_info(self, info_hashs: list):
+ """
+ 返回info_hash对应的站点id、种子id
+ {
+ "ret": 200,
+ "data": [
+ {
+ "sid": 3,
+ "torrent_id": 377467,
+ "info_hash": "a444850638e7a6f6220e2efdde94099c53358159"
+ },
+ {
+ "sid": 7,
+ "torrent_id": 35538,
+ "info_hash": "cf7d88fd656d10fe5130d13567aec27068b96676"
+ }
+ ],
+ "msg": "",
+ "version": "1.0.0"
+ }
+ """
+ # FIXME 非法请求:做种列表sha1校验失败
+ info_hashs.sort()
+ json_str = json.dumps(info_hashs, ensure_ascii=False)
+ sha1 = self.get_sha1(json_str)
+ result, msg = self.__request_iyuu(url=self._api_base % 'api/infohash',
+ method="post",
+ params={
+ "timestamp": time.time(),
+ "hash": json_str,
+ "sha1": sha1
+ })
+ return result, msg
+
+ @staticmethod
+ def get_sha1(json_str) -> str:
+ return hashlib.sha1(json_str.encode('utf-8')).hexdigest()
diff --git a/app/media/bangumi.py b/app/media/bangumi.py
index 9d46a99e..ccd1227f 100644
--- a/app/media/bangumi.py
+++ b/app/media/bangumi.py
@@ -52,7 +52,7 @@ def __dict_item(item, weekday):
"""
bid = item.get("id")
detail = item.get("url")
- title = item.get("name_cn", item.get("name"))
+ title = item.get("name_cn") or item.get("name")
air_date = item.get("air_date")
rating = item.get("rating")
if rating:
diff --git a/app/media/douban.py b/app/media/douban.py
index cb64f226..320965ea 100644
--- a/app/media/douban.py
+++ b/app/media/douban.py
@@ -4,14 +4,12 @@
import zhconv
-from app.utils.commons import singleton
-from app.utils import ExceptionUtils, StringUtils
-
import log
-from config import Config
from app.media.doubanapi import DoubanApi, DoubanWeb
from app.media.meta import MetaInfo
+from app.utils import ExceptionUtils, StringUtils
from app.utils import RequestUtils
+from app.utils.commons import singleton
from app.utils.types import MediaType
lock = Lock()
@@ -32,18 +30,13 @@ def __init__(self):
def init_config(self):
self.doubanapi = DoubanApi()
self.doubanweb = DoubanWeb()
- douban = Config().get_config('douban')
- if douban:
- # Cookie
- self.cookie = douban.get('cookie')
- if not self.cookie:
- try:
- res = RequestUtils(timeout=5).get_res("https://www.douban.com/")
- if res:
- self.cookie = StringUtils.str_from_cookiejar(res.cookies)
- except Exception as err:
- ExceptionUtils.exception_traceback(err)
- log.warn(f"【Douban】获取cookie失败:{format(err)}")
+ try:
+ res = RequestUtils(timeout=5).get_res("https://www.douban.com/")
+ if res:
+ self.cookie = StringUtils.str_from_cookiejar(res.cookies)
+ except Exception as err:
+ ExceptionUtils.exception_traceback(err)
+ log.warn(f"【Douban】获取cookie失败:{format(err)}")
def get_douban_detail(self, doubanid, mtype=None, wait=False):
"""
diff --git a/app/media/media.py b/app/media/media.py
index 2a84956c..b75226f0 100644
--- a/app/media/media.py
+++ b/app/media/media.py
@@ -35,6 +35,7 @@ class Media:
openai = None
_rmt_match_mode = None
_search_keyword = None
+ _search_tmdbweb = None
_chatgpt_enable = None
_default_language = None
@@ -47,6 +48,8 @@ def init_config(self):
laboratory = Config().get_config('laboratory')
# 辅助查询
self._search_keyword = laboratory.get("search_keyword")
+ # WEB辅助
+ self._search_tmdbweb = laboratory.get("search_tmdbweb")
# ChatGPT辅助
self._chatgpt_enable = laboratory.get("chatgpt_enable")
# 默认语言
@@ -218,12 +221,10 @@ def __search_tmdb(self, file_media_name,
info.get('name'),
info.get('first_air_date')))
# 返回
- if info:
- return info
- else:
+ if not info:
log.info("【Meta】%s 以年份 %s 在TMDB中未找到%s信息!" % (
file_media_name, StringUtils.xstr(first_media_year), search_type.value if search_type else ""))
- return info
+ return info
def __search_movie_by_name(self, file_media_name, first_media_year):
"""
@@ -436,10 +437,9 @@ def __search_multi_tmdb(self, file_media_name):
# 返回
if info:
info['media_type'] = MediaType.MOVIE if info.get('media_type') == 'movie' else MediaType.TV
- return info
else:
log.info("【Meta】%s 在TMDB中未找到媒体信息!" % file_media_name)
- return info
+ return info
@lru_cache(maxsize=512)
def __search_chatgpt(self, file_name, mtype: MediaType):
@@ -451,15 +451,18 @@ def __search_chatgpt(self, file_name, mtype: MediaType):
"""
def __failed():
+ return mtype, None, None, {}
+
+ def __failed_none():
return mtype, None, None, None
if not file_name:
- return __failed()
+ return __failed_none()
log.info("【Meta】正在通过ChatGPT识别文件名:%s" % file_name)
file_info = self.openai.get_media_name(file_name)
if file_info is None:
log.info("【Meta】ChatGPT识别出错,请检查是否设置OpenAI ApiKey!")
- return __failed()
+ return __failed_none()
if not file_info:
log.info("【Meta】ChatGPT识别失败!")
return __failed()
@@ -485,6 +488,61 @@ def __failed():
tmdb_info = self.__search_multi_tmdb(file_media_name=file_title)
return mtype, file_info.get("season"), file_info.get("episode"), tmdb_info
+ @lru_cache(maxsize=512)
+ def __search_tmdb_web(self, file_media_name, mtype: MediaType):
+ """
+ 检索TMDB网站,直接抓取结果,结果只有一条时才返回
+ :param file_media_name: 名称
+ """
+ if not file_media_name:
+ return None
+ if StringUtils.is_chinese(file_media_name):
+ return {}
+ log.info("【Meta】正在从TheDbMovie网站查询:%s ..." % file_media_name)
+ tmdb_url = "https://www.themoviedb.org/search?query=%s" % file_media_name
+ res = RequestUtils(timeout=5).get_res(url=tmdb_url)
+ if res and res.status_code == 200:
+ html_text = res.text
+ if not html_text:
+ return None
+ try:
+ tmdb_links = []
+ html = etree.HTML(html_text)
+ links = html.xpath("//a[@data-id]/@href")
+ for link in links:
+ if not link or (not link.startswith("/tv") and not link.startswith("/movie")):
+ continue
+ if link not in tmdb_links:
+ tmdb_links.append(link)
+ if len(tmdb_links) == 1:
+ tmdbinfo = self.get_tmdb_info(
+ mtype=MediaType.TV if tmdb_links[0].startswith("/tv") else MediaType.MOVIE,
+ tmdbid=tmdb_links[0].split("/")[-1])
+ if tmdbinfo:
+ if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV:
+ return {}
+ if tmdbinfo.get('media_type') == MediaType.MOVIE:
+ log.info("【Meta】%s 从WEB识别到 电影:TMDBID=%s, 名称=%s, 上映日期=%s" % (
+ file_media_name,
+ tmdbinfo.get('id'),
+ tmdbinfo.get('title'),
+ tmdbinfo.get('release_date')))
+ else:
+ log.info("【Meta】%s 从WEB识别到 电视剧:TMDBID=%s, 名称=%s, 首播日期=%s" % (
+ file_media_name,
+ tmdbinfo.get('id'),
+ tmdbinfo.get('name'),
+ tmdbinfo.get('first_air_date')))
+ return tmdbinfo
+ elif len(tmdb_links) > 1:
+ log.info("【Meta】%s TMDB网站返回数据过多:%s" % (file_media_name, len(tmdb_links)))
+ else:
+ log.info("【Meta】%s TMDB网站未查询到媒体信息!" % file_media_name)
+ except Exception as err:
+ print(str(err))
+ return None
+ return None
+
def search_tmdb_person(self, name):
"""
搜索TMDB演员信息
@@ -710,7 +768,12 @@ def get_media_info(self, title,
if not file_media_info and self._rmt_match_mode == MatchMode.NORMAL and not strict:
# 非严格模式下去掉年份和类型再查一次
file_media_info = self.__search_multi_tmdb(file_media_name=meta_info.get_name())
+ if not file_media_info and self._search_tmdbweb:
+ # 从网站查询
+ file_media_info = self.__search_tmdb_web(file_media_name=meta_info.get_name(),
+ mtype=meta_info.type)
if not file_media_info and self._chatgpt_enable:
+ # 通过ChatGPT查询
mtype, seaons, episodes, file_media_info = self.__search_chatgpt(file_name=title,
mtype=meta_info.type)
# 修正类型和集数
@@ -720,6 +783,7 @@ def get_media_info(self, title,
if not meta_info.get_episode_string():
meta_info.set_episode(episodes)
if not file_media_info and self._search_keyword:
+ # 关键字猜测
cache_name = cacheman["tmdb_supply"].get(meta_info.get_name())
is_movie = False
if not cache_name:
@@ -1853,7 +1917,8 @@ def get_tmdb_en_title(self, media_info):
"""
en_info = self.get_tmdb_info(mtype=media_info.type,
tmdbid=media_info.tmdb_id,
- language="en")
+ language="en",
+ chinese=False)
if en_info:
return en_info.get("title") if media_info.type == MediaType.MOVIE else en_info.get("name")
return None
diff --git a/app/media/scraper.py b/app/media/scraper.py
index 72b972f3..4619afc9 100644
--- a/app/media/scraper.py
+++ b/app/media/scraper.py
@@ -7,9 +7,10 @@
import log
from app.helper import FfmpegHelper
from app.media.douban import DouBan
+from app.media.meta import MetaInfo
from app.utils.commons import retry
-from config import Config
-from app.utils import DomUtils, RequestUtils, ExceptionUtils
+from config import Config, RMT_MEDIAEXT
+from app.utils import DomUtils, RequestUtils, ExceptionUtils, NfoReader
from app.utils.types import MediaType
from app.media import Media
@@ -27,6 +28,110 @@ def __init__(self):
self._scraper_nfo = Config().get_config('scraper_nfo')
self._scraper_pic = Config().get_config('scraper_pic')
+ def folder_scraper(self, path, exclude_path=None, mode=None):
+ """
+ 刮削指定文件夹或文件
+ :param path: 文件夹或文件路径
+ :param exclude_path: 排除路径
+ :param mode: 刮削模式,可选值:force_nfo, force_all
+ :return:
+ """
+ # 模式
+ force_nfo = True if mode in ["force_nfo", "force_all"] else False
+ force_pic = True if mode in ["force_all"] else False
+ # 每个媒体库下的所有文件
+ for file in self.__get_library_files(path, exclude_path):
+ if not file:
+ continue
+ log.info(f"【Scraper】开始刮削媒体库文件:{file} ...")
+ # 识别媒体文件
+ meta_info = MetaInfo(os.path.basename(file))
+ # 优先读取本地文件
+ tmdbid = None
+ if meta_info.type == MediaType.MOVIE:
+ # 电影
+ movie_nfo = os.path.join(os.path.dirname(file), "movie.nfo")
+ if os.path.exists(movie_nfo):
+ tmdbid = self.__get_tmdbid_from_nfo(movie_nfo)
+ file_nfo = os.path.join(os.path.splitext(file)[0] + ".nfo")
+ if not tmdbid and os.path.exists(file_nfo):
+ tmdbid = self.__get_tmdbid_from_nfo(file_nfo)
+ else:
+ # 电视剧
+ tv_nfo = os.path.join(os.path.dirname(os.path.dirname(file)), "tvshow.nfo")
+ if os.path.exists(tv_nfo):
+ tmdbid = self.__get_tmdbid_from_nfo(tv_nfo)
+ if tmdbid and not force_nfo:
+ log.info(f"【Scraper】读取到本地nfo文件的tmdbid:{tmdbid}")
+ meta_info.set_tmdb_info(self.media.get_tmdb_info(mtype=meta_info.type,
+ tmdbid=tmdbid,
+ append_to_response='all'))
+ media_info = meta_info
+ else:
+ medias = self.media.get_media_info_on_files(file_list=[file],
+ append_to_response="all")
+ if not medias:
+ continue
+ media_info = None
+ for _, media in medias.items():
+ media_info = media
+ break
+ if not media_info or not media_info.tmdb_info:
+ continue
+ self.gen_scraper_files(media=media_info,
+ dir_path=os.path.dirname(file),
+ file_name=os.path.splitext(os.path.basename(file))[0],
+ file_ext=os.path.splitext(file)[-1],
+ force=True,
+ force_nfo=force_nfo,
+ force_pic=force_pic)
+ log.info(f"【Scraper】{file} 刮削完成")
+
+ @staticmethod
+ def __get_library_files(in_path, exclude_path=None):
+ """
+ 获取媒体库文件列表
+ """
+ if not os.path.isdir(in_path):
+ yield in_path
+ return
+
+ for root, dirs, files in os.walk(in_path):
+ if exclude_path and any(os.path.abspath(root).startswith(os.path.abspath(path))
+ for path in exclude_path.split(",")):
+ continue
+
+ for file in files:
+ cur_path = os.path.join(root, file)
+ # 检查后缀
+ if os.path.splitext(file)[-1].lower() in RMT_MEDIAEXT:
+ yield cur_path
+
+ @staticmethod
+ def __get_tmdbid_from_nfo(file_path):
+ """
+ 从nfo文件中获取信息
+ :param file_path:
+ :return: tmdbid
+ """
+ if not file_path:
+ return None
+ xpaths = [
+ "uniqueid[@type='Tmdb']",
+ "uniqueid[@type='tmdb']",
+ "uniqueid[@type='TMDB']",
+ "tmdbid"
+ ]
+ reader = NfoReader(file_path)
+ for xpath in xpaths:
+ try:
+ tmdbid = reader.get_element_value(xpath)
+ if tmdbid:
+ return tmdbid
+ except Exception as err:
+ print(str(err))
+ return None
+
def __gen_common_nfo(self,
tmdbinfo: dict,
doubaninfo: dict,
diff --git a/app/message/client/bark.py b/app/message/client/bark.py
index 252c1a69..57e7678a 100644
--- a/app/message/client/bark.py
+++ b/app/message/client/bark.py
@@ -45,7 +45,7 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
if self._params:
sc_url = "%s?%s" % (sc_url, self._params)
res = RequestUtils().post_res(sc_url)
- if res:
+ if res and res.status_code == 200:
ret_json = res.json()
code = ret_json['code']
message = ret_json['message']
@@ -53,6 +53,8 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
return True, message
else:
return False, message
+ elif res is not None:
+ return False, f"错误码:{res.status_code},错误原因:{res.reason}"
else:
return False, "未获取到返回信息"
except Exception as msg_e:
diff --git a/app/message/client/chanify.py b/app/message/client/chanify.py
index 0fc8cdab..d6e6106d 100644
--- a/app/message/client/chanify.py
+++ b/app/message/client/chanify.py
@@ -47,11 +47,10 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
data.update({'title': title, 'text': text})
# 发送文本
res = RequestUtils().post_res(sc_url, data=parse.urlencode(data).encode())
- if res:
- if res.status_code == 200:
- return True, "发送成功"
- else:
- return False, "错误码:%s" % res.status_code
+ if res and res.status_code == 200:
+ return True, "发送成功"
+ elif res is not None:
+ return False, f"错误码:{res.status_code},错误原因:{res.reason}"
else:
return False, "未获取到返回信息"
except Exception as msg_e:
diff --git a/app/message/client/gotify.py b/app/message/client/gotify.py
index 11b0cabf..73365870 100644
--- a/app/message/client/gotify.py
+++ b/app/message/client/gotify.py
@@ -59,8 +59,8 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
res = RequestUtils(content_type="application/json").post_res(sc_url, json=sc_data)
if res and res.status_code == 200:
return True, "发送成功"
- elif res:
- return False, f"错误码:{res.status_code}"
+ elif res is not None:
+ return False, f"错误码:{res.status_code},错误原因:{res.reason}"
else:
return False, "未获取到返回信息"
except Exception as msg_e:
diff --git a/app/message/client/iyuu.py b/app/message/client/iyuu.py
index 4787d623..aac93765 100644
--- a/app/message/client/iyuu.py
+++ b/app/message/client/iyuu.py
@@ -38,7 +38,7 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
try:
sc_url = "http://iyuu.cn/%s.send?%s" % (self._token, urlencode({"text": title, "desp": text}))
res = RequestUtils().get_res(sc_url)
- if res:
+ if res and res.status_code == 200:
ret_json = res.json()
errno = ret_json.get('errcode')
error = ret_json.get('errmsg')
@@ -46,6 +46,8 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
return True, error
else:
return False, error
+ elif res is not None:
+ return False, f"错误码:{res.status_code},错误原因:{res.reason}"
else:
return False, "未获取到返回信息"
except Exception as msg_e:
diff --git a/app/message/client/pushplus.py b/app/message/client/pushplus.py
index d2dbdff1..3f71bc41 100644
--- a/app/message/client/pushplus.py
+++ b/app/message/client/pushplus.py
@@ -56,7 +56,7 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
}
sc_url = "http://www.pushplus.plus/send?%s" % urlencode(values)
res = RequestUtils().get_res(sc_url)
- if res:
+ if res and res.status_code == 200:
ret_json = res.json()
code = ret_json.get("code")
msg = ret_json.get("msg")
@@ -64,6 +64,8 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
return True, msg
else:
return False, msg
+ elif res is not None:
+ return False, f"错误码:{res.status_code},错误原因:{res.reason}"
else:
return False, "未获取到返回信息"
except Exception as msg_e:
diff --git a/app/message/client/serverchan.py b/app/message/client/serverchan.py
index 9a770a61..1cba20d6 100644
--- a/app/message/client/serverchan.py
+++ b/app/message/client/serverchan.py
@@ -38,7 +38,7 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
try:
sc_url = "https://sctapi.ftqq.com/%s.send?%s" % (self._sckey, urlencode({"title": title, "desp": text}))
res = RequestUtils().get_res(sc_url)
- if res:
+ if res and res.status_code == 200:
ret_json = res.json()
errno = ret_json.get('code')
error = ret_json.get('message')
@@ -46,6 +46,8 @@ def send_msg(self, title, text="", image="", url="", user_id=""):
return True, error
else:
return False, error
+ elif res is not None:
+ return False, f"错误码:{res.status_code},错误原因:{res.reason}"
else:
return False, "未获取到返回信息"
except Exception as msg_e:
diff --git a/app/message/client/synologychat.py b/app/message/client/synologychat.py
index 8d70d153..56d8bfe3 100644
--- a/app/message/client/synologychat.py
+++ b/app/message/client/synologychat.py
@@ -163,7 +163,7 @@ def __send_request(self, payload_data):
return False, f"{errno}-{errmsg}"
else:
return False, f"{ret.text}"
- elif ret:
- return False, f"错误码:{ret.status_code}"
+ elif ret is not None:
+ return False, f"错误码:{ret.status_code},错误原因:{ret.reason}"
else:
return False, "未获取到返回信息"
diff --git a/app/message/client/telegram.py b/app/message/client/telegram.py
index 4e1a0519..8da445e4 100644
--- a/app/message/client/telegram.py
+++ b/app/message/client/telegram.py
@@ -168,13 +168,15 @@ def __send_request(self, chat_id="", image="", caption=""):
向Telegram发送报文
"""
def _res_parse(result):
- if result:
+ if result and result.status_code == 200:
ret_json = result.json()
status = ret_json.get("ok")
if status:
return True, ""
else:
return False, ret_json.get("description")
+ elif result is not None:
+ return False, f"错误码:{result.status_code},错误原因:{result.reason}"
else:
return False, "未获取到返回信息"
diff --git a/app/message/client/wechat.py b/app/message/client/wechat.py
index b99423a2..4dcc60f8 100644
--- a/app/message/client/wechat.py
+++ b/app/message/client/wechat.py
@@ -208,7 +208,7 @@ def __post_request(self, message_url, req_json):
try:
res = RequestUtils(headers=headers).post(message_url,
data=json.dumps(req_json, ensure_ascii=False).encode('utf-8'))
- if res:
+ if res and res.status_code == 200:
ret_json = res.json()
if ret_json.get('errcode') == 0:
return True, ret_json.get('errmsg')
@@ -216,8 +216,10 @@ def __post_request(self, message_url, req_json):
if ret_json.get('errcode') == 42001:
self.__get_access_token(force=True)
return False, ret_json.get('errmsg')
+ elif res is not None:
+ return False, f"错误码:{res.status_code},错误原因:{res.reason}"
else:
- return False, None
+ return False, "未获取到返回信息"
except Exception as err:
ExceptionUtils.exception_traceback(err)
return False, str(err)
diff --git a/app/plugins/event_manager.py b/app/plugins/event_manager.py
index b75fe1bb..970b1165 100644
--- a/app/plugins/event_manager.py
+++ b/app/plugins/event_manager.py
@@ -47,7 +47,7 @@ def add_event_listener(self, etype: EventType, handler):
self._handlers[etype.value] = handlerList
if handler not in handlerList:
handlerList.append(handler)
- log.info(f"已注册事件:{etype.value}{handler}")
+ log.debug(f"已注册事件:{etype.value}{handler}")
def remove_event_listener(self, etype: EventType, handler):
"""
diff --git a/app/plugins/modules/_base.py b/app/plugins/modules/_base.py
index 5223283e..f49dd583 100644
--- a/app/plugins/modules/_base.py
+++ b/app/plugins/modules/_base.py
@@ -20,6 +20,8 @@ class _IPluginModule(metaclass=ABCMeta):
module_version = "1.0"
# 插件作者
module_author = ""
+ # 作者主页
+ author_url = ""
# 插件配置项ID前缀:为了避免各插件配置表单相冲突,配置表单元素ID自动在前面加上此前缀
module_config_prefix = "plugin_"
# 显示顺序
@@ -43,7 +45,7 @@ def get_state(self):
pass
@abstractmethod
- def init_config(self, config: dict):
+ def init_config(self, config: dict = None):
"""
生效配置信息
:param config: 配置信息字典
diff --git a/app/plugins/modules/autosub.py b/app/plugins/modules/autosub.py
index 45e45187..6e3ae5dd 100644
--- a/app/plugins/modules/autosub.py
+++ b/app/plugins/modules/autosub.py
@@ -5,8 +5,10 @@
import tempfile
import time
import traceback
+from datetime import timedelta
import iso639
+import psutil
import srt
from app.helper import FfmpegHelper
@@ -25,15 +27,17 @@ class AutoSub(_IPluginModule):
# 插件图标
module_icon = "autosubtitles.jpeg"
# 主题色
- module_color = "bg-cyan"
+ module_color = "#2C4F7E"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "olly"
+ # 作者主页
+ author_url = "https://github.com/lightolly"
# 插件配置项ID前缀
module_config_prefix = "autosub"
# 加载顺序
- module_order = 21
+ module_order = 14
# 可使用的用户级别
auth_level = 2
@@ -55,6 +59,9 @@ def __init__(self):
self.fail_count = 0
self.success_count = 0
self.send_notify = False
+ self.asr_engine = 'whisper.cpp'
+ self.faster_whisper_model = 'base'
+ self.faster_whisper_model_path = None
@staticmethod
def get_fields():
@@ -63,11 +70,61 @@ def get_fields():
{
'type': 'div',
'content': [
- # 同一行
[
{
- 'title': 'whisper.cpp路径',
+ 'title': '媒体路径',
+ 'required': '',
+ 'tooltip': '要进行字幕生成的路径,每行一个路径,请确保路径正确',
+ 'type': 'textarea',
+ 'content':
+ {
+ 'id': 'path_list',
+ 'placeholder': '文件路径',
+ 'rows': 5
+ }
+ }
+ ],
+ # asr 引擎
+ [
+ {
+ 'title': '文件大小(MB)',
+ 'required': "required",
+ 'tooltip': '单位 MB, 大于该大小的文件才会进行字幕生成',
+ 'type': 'text',
+ 'content':
+ [{
+ 'id': 'file_size',
+ 'placeholder': '文件大小, 单位MB'
+ }]
+ },
+ {
+ 'title': 'ASR引擎',
'required': "required",
+ 'tooltip': '自动语音识别引擎选择',
+ 'type': 'select',
+ 'content': [
+ {
+ 'id': 'asr_engine',
+ 'options': {
+ 'whisper.cpp': 'whisper.cpp',
+ 'faster-whisper': 'faster-whisper'
+ },
+ 'default': 'whisper.cpp'
+ }
+ ]
+ }
+ ]
+ ]
+ },
+ {
+ 'type': 'details',
+ 'summary': 'whisper.cpp 配置',
+ 'tooltip': '使用 whisper.cpp 引擎时的配置',
+ 'content': [
+ [
+ {
+ 'title': 'whisper.cpp路径',
+ 'required': "",
'tooltip': '填写whisper.cpp主程序路径,如/config/plugin/autosub/main \n'
'推荐教程 https://ddsrem.com/autosub',
'type': 'text',
@@ -79,11 +136,10 @@ def get_fields():
]
}
],
- # 模型路径
[
{
'title': 'whisper.cpp模型路径',
- 'required': "required",
+ 'required': "",
'tooltip': '填写whisper.cpp模型路径,如/config/plugin/autosub/models/ggml-base.en.bin\n'
'可从https://github.com/ggerganov/whisper.cpp/tree/master/models处下载',
'type': 'text',
@@ -94,34 +150,75 @@ def get_fields():
}]
}
],
- # 文件大小
[
{
- 'title': '文件大小(MB)',
- 'required': "required",
- 'tooltip': '单位 MB, 大于该大小的文件才会进行字幕生成',
+ 'title': '高级参数',
+ 'tooltip': 'whisper.cpp的高级参数,请勿随意修改',
+ 'required': "",
'type': 'text',
- 'content':
- [{
- 'id': 'file_size',
- 'placeholder': '文件大小, 单位MB'
- }]
+ 'content': [
+ {
+ 'id': 'additional_args',
+ 'placeholder': '-t 4 -p 1'
+ }
+ ]
}
- ],
+ ]
+ ]
+ },
+ {
+ 'type': 'details',
+ 'summary': 'faster-whisper 配置',
+ 'tooltip': '使用 faster-whisper 引擎时的配置,安装参考 https://github.com/guillaumekln/faster-whisper',
+ 'content': [
[
{
- 'title': '媒体路径',
- 'required': '',
- 'tooltip': '要进行字幕生成的路径,每行一个路径,请确保路径正确',
- 'type': 'textarea',
- 'content':
+ 'title': '模型',
+ 'required': "",
+ 'tooltip': '选择模型后第一次运行会从Hugging Face Hub下载模型,可能需要一段时间',
+ 'type': 'select',
+ 'content': [
{
- 'id': 'path_list',
- 'placeholder': '文件路径',
- 'rows': 5
+ 'id': 'faster_whisper_model',
+ 'options': {
+ # tiny, tiny.en, base, base.en,
+ # small, small.en, medium, medium.en,
+ # large-v1, or large-v2
+ 'tiny': 'tiny',
+ 'tiny.en': 'tiny.en',
+ 'base': 'base',
+ 'base.en': 'base.en',
+ 'small': 'small',
+ 'small.en': 'small.en',
+ 'medium': 'medium',
+ 'medium.en': 'medium.en',
+ 'large-v1': 'large-v1',
+ 'large-v2': 'large-v2',
+ },
+ 'default': 'base'
}
+ ]
}
],
+ [
+ {
+ 'title': '模型保存路径',
+ 'required': "",
+ 'tooltip': '配置模型保存路径,如/config/plugin/autosub/faster-whisper/models',
+ 'type': 'text',
+ 'content': [
+ {
+ 'id': 'faster_whisper_model_path',
+ 'placeholder': 'faster-whisper配置模型保存路径'
+ }
+ ]
+ }
+ ]
+ ]
+ },
+ {
+ 'type': 'div',
+ 'content': [
[
{
'title': '立即运行一次',
@@ -155,25 +252,6 @@ def get_fields():
}
]
]
- },
- {
- 'type': 'details',
- 'summary': '高级参数',
- 'tooltip': 'whisper.cpp的高级参数,请勿随意修改',
- 'content': [
- [
- {
- 'required': "",
- 'type': 'text',
- 'content': [
- {
- 'id': 'additional_args',
- 'placeholder': '-t 4 -p 1'
- }
- ]
- }
- ]
- ]
}
]
@@ -192,6 +270,9 @@ def init_config(self, config=None):
self.translate_only = config.get('translate_only', False)
self.additional_args = config.get('additional_args', '-t 4 -p 1')
self.send_notify = config.get('send_notify', False)
+ self.asr_engine = config.get('asr_engine', 'whisper.cpp')
+ self.faster_whisper_model = config.get('faster_whisper_model', 'base')
+ self.faster_whisper_model_path = config.get('faster_whisper_model_path')
run_now = config.get('run_now')
if not run_now:
@@ -201,28 +282,19 @@ def init_config(self, config=None):
self.update_config(config)
# 如果没有配置信息, 则不处理
- if not path_list or not self.file_size or not self.whisper_main or not self.whisper_model:
+ if not path_list or not self.file_size:
self.warn(f"配置信息不完整,不进行处理")
return
- if not os.path.exists(self.whisper_main):
- self.warn(f"whisper.cpp主程序不存在,不进行处理")
- return
-
- if not os.path.exists(self.whisper_model):
- self.warn(f"whisper.cpp模型文件不存在,不进行处理")
- return
-
- # 校验扩展参数是否包含异常字符
- if self.additional_args and re.search(r'[;|&]', self.additional_args):
- self.warn(f"扩展参数包含异常字符,不进行处理")
- return
-
# 校验文件大小是否为数字
if not self.file_size.isdigit():
self.warn(f"文件大小不是数字,不进行处理")
return
+ # asr 配置检查
+ if not self.translate_only and not self.__check_asr():
+ return
+
if self._running:
self.warn(f"上一次任务还未完成,不进行处理")
return
@@ -257,6 +329,39 @@ def init_config(self, config=None):
f"成功{self.success_count} / 跳过{self.skip_count} / 失败{self.fail_count} / 共{self.process_count}")
self._running = False
+ def __check_asr(self):
+ if self.asr_engine == 'whisper.cpp':
+ if not self.whisper_main or not self.whisper_model:
+ self.warn(f"配置信息不完整,不进行处理")
+ return
+ if not os.path.exists(self.whisper_main):
+ self.warn(f"whisper.cpp主程序不存在,不进行处理")
+ return False
+ if not os.path.exists(self.whisper_model):
+ self.warn(f"whisper.cpp模型文件不存在,不进行处理")
+ return False
+ # 校验扩展参数是否包含异常字符
+ if self.additional_args and re.search(r'[;|&]', self.additional_args):
+ self.warn(f"扩展参数包含异常字符,不进行处理")
+ return False
+ elif self.asr_engine == 'faster-whisper':
+ if not self.faster_whisper_model_path or not self.faster_whisper_model:
+ self.warn(f"配置信息不完整,不进行处理")
+ return
+ if not os.path.exists(self.faster_whisper_model_path):
+ self.warn(f"faster-whisper模型文件夹不存在,不进行处理")
+ return False
+ try:
+ from faster_whisper import WhisperModel, download_model
+ except ImportError:
+ self.warn(f"faster-whisper 未安装,不进行处理")
+ return False
+ return True
+ else:
+ self.warn(f"未配置asr引擎,不进行处理")
+ return False
+ return True
+
def __process_folder_subtitle(self, path):
"""
处理目录字幕
@@ -329,6 +434,76 @@ def __process_folder_subtitle(self, path):
traceback.print_exc()
self.fail_count += 1
+ def __do_speech_recognition(self, audio_lang, audio_file):
+ """
+ 语音识别, 生成字幕
+ :param audio_lang:
+ :param audio_file:
+ :return:
+ """
+ lang = audio_lang
+ if self.asr_engine == 'whisper.cpp':
+ command = [self.whisper_main] + self.additional_args.split()
+ command += ['-l', lang, '-m', self.whisper_model, '-osrt', '-of', audio_file, audio_file]
+ ret = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ if ret.returncode == 0:
+ if lang == 'auto':
+ # 从output中获取语言 "whisper_full_with_state: auto-detected language: en (p = 0.973642)"
+ output = ret.stdout.decode('utf-8') if ret.stdout else ""
+ lang = re.search(r"auto-detected language: (\w+)", output)
+ if lang and lang.group(1):
+ lang = lang.group(1)
+ else:
+ lang = "en"
+ return True, lang
+ elif self.asr_engine == 'faster-whisper':
+ try:
+ from faster_whisper import WhisperModel, download_model
+ # 设置缓存目录, 防止缓存同目录出现 cross-device 错误
+ cache_dir = os.path.join(self.faster_whisper_model_path, "cache")
+ if not os.path.exists(cache_dir):
+ os.mkdir(cache_dir)
+ os.environ["HUGGINGFACE_HUB_CACHE"] = cache_dir
+ model = WhisperModel(download_model(self.faster_whisper_model),
+ device="cpu", compute_type="int8", cpu_threads=psutil.cpu_count(logical=False))
+ segments, info = model.transcribe(audio_file,
+ language=lang if lang != 'auto' else None,
+ word_timestamps=True,
+ temperature=0,
+ beam_size=5)
+ if lang == 'auto':
+ lang = info.language
+
+ subs = []
+ if lang in ['en', 'eng']:
+ # 英文先生成单词级别字幕,再合并
+ idx = 0
+ for segment in segments:
+ for word in segment.words:
+ idx += 1
+ subs.append(srt.Subtitle(index=idx,
+ start=timedelta(seconds=word.start),
+ end=timedelta(seconds=word.end),
+ content=word.word))
+ subs = self.__merge_srt(subs)
+ else:
+ for i, segment in enumerate(segments):
+ subs.append(srt.Subtitle(index=i,
+ start=timedelta(seconds=segment.start),
+ end=timedelta(seconds=segment.end),
+ content=segment.text))
+
+ self.__save_srt(f"{audio_file}.srt", subs)
+ return True, lang
+ except ImportError:
+ self.warn(f"faster-whisper 未安装,不进行处理")
+ return False, None
+ except Exception as e:
+ traceback.print_exc()
+ self.error(f"faster-whisper 处理异常:{e}")
+ return False, None
+ return False, None
+
def __generate_subtitle(self, video_file, subtitle_file, only_extract=False):
"""
生成字幕
@@ -397,27 +572,16 @@ def __generate_subtitle(self, video_file, subtitle_file, only_extract=False):
self.info(f"提取音频完成:{audio_file.name}")
# 生成字幕
- command = [self.whisper_main] + self.additional_args.split()
- command += ['-l', audio_lang, '-m', self.whisper_model, '-osrt', '-of', audio_file.name, audio_file.name]
self.info(f"开始生成字幕, 语言 {audio_lang} ...")
- ret = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
- if ret.returncode == 0:
- lang = audio_lang
- if lang == 'auto':
- # 从output中获取语言 "whisper_full_with_state: auto-detected language: en (p = 0.973642)"
- output = ret.stdout.decode('utf-8') if ret.stdout else ""
- lang = re.search(r"auto-detected language: (\w+)", output)
- if lang and lang.group(1):
- lang = lang.group(1)
- else:
- lang = "en"
+ ret, lang = self.__do_speech_recognition(audio_lang, audio_file.name)
+ if ret:
self.info(f"生成字幕成功,原始语言:{lang}")
# 复制字幕文件
SystemUtils.copy(f"{audio_file.name}.srt", f"{subtitle_file}.{lang}.srt")
self.info(f"复制字幕文件:{subtitle_file}.{lang}.srt")
# 删除临时文件
os.remove(f"{audio_file.name}.srt")
- return True, lang
+ return ret, lang
else:
self.error(f"生成字幕失败")
return False, None
@@ -467,7 +631,7 @@ def __save_srt(file_path, srt_data):
def __get_video_prefer_audio(self, video_meta, prefer_lang=None):
"""
获取视频的首选音轨,如果有多音轨, 优先指定语言音轨,否则获取默认音轨
- :param video_file:
+ :param video_meta
:return:
"""
if type(prefer_lang) == str and prefer_lang:
@@ -562,7 +726,6 @@ def __merge_srt(self, subtitle_data):
merged_subtitle = []
sentence_end = True
- self.info(f"开始合并字幕语句 ...")
for index, item in enumerate(subtitle_data):
# 当前字幕先将多行合并为一行,再去除首尾空格
content = item.content.replace('\n', ' ').strip()
@@ -591,7 +754,6 @@ def __merge_srt(self, subtitle_data):
else:
sentence_end = False
- self.info(f"合并字幕语句完成,合并前字幕数量:{len(subtitle_data)}, 合并后字幕数量:{len(merged_subtitle)}")
return merged_subtitle
def __do_translate_with_retry(self, text, retry=3):
@@ -624,7 +786,11 @@ def __translate_zh_subtitle(self, source_lang, source_subtitle, dest_subtitle):
srt_data = self.__load_srt(source_subtitle)
# 合并字幕语句,目前带标点带英文效果较好,非英文或者无标点的需要NLP处理
if source_lang in ['en', 'eng']:
- srt_data = self.__merge_srt(srt_data)
+ self.info(f"开始合并字幕语句 ...")
+ merged_data = self.__merge_srt(srt_data)
+ self.info(f"合并字幕语句完成,合并前字幕数量:{len(merged_data)}, 合并后字幕数量:{len(srt_data)}")
+ srt_data = merged_data
+
batch = []
max_batch_tokens = 1000
for srt_item in srt_data:
diff --git a/app/plugins/modules/chinesesubfinder.py b/app/plugins/modules/chinesesubfinder.py
index 6ec8ac1b..720fd7a6 100644
--- a/app/plugins/modules/chinesesubfinder.py
+++ b/app/plugins/modules/chinesesubfinder.py
@@ -16,11 +16,13 @@ class ChineseSubFinder(_IPluginModule):
# 插件图标
module_icon = "chinesesubfinder.png"
# 主题色
- module_color = "bg-lime"
+ module_color = "#83BE39"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
module_config_prefix = "chinesesubfinder_"
# 加载顺序
diff --git a/app/plugins/modules/cloudflarespeedtest.py b/app/plugins/modules/cloudflarespeedtest.py
index 5fc4707b..4b1c8d91 100644
--- a/app/plugins/modules/cloudflarespeedtest.py
+++ b/app/plugins/modules/cloudflarespeedtest.py
@@ -22,11 +22,13 @@ class CloudflareSpeedTest(_IPluginModule):
# 插件图标
module_icon = "cloudflare.jpg"
# 主题色
- module_color = "bg-orange"
+ module_color = "#F6821F"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
module_config_prefix = "cloudflarespeedtest_"
# 加载顺序
@@ -215,8 +217,9 @@ def init_config(self, config=None):
self.info(f"Cloudflare CDN优选服务启动,周期:{self._cron}")
# 关闭一次性开关
- self._onlyonce = False
- self.__update_config()
+ if self._onlyonce:
+ self._onlyonce = False
+ self.__update_config()
def __cloudflareSpeedTest(self):
"""
@@ -277,9 +280,11 @@ def __cloudflareSpeedTest(self):
self.info(f"CLoudflare CDN优选ip [{best_ip}] 已替换自定义Hosts插件")
# 解发自定义hosts插件重载
- self.eventmanager.send_event(EventType.CustomHostsReload,
- self.get_config("CustomHosts"))
- self.info("通知CustomHosts插件重载")
+ self.info("通知CustomHosts插件重载 ...")
+ self.eventmanager.send_event(EventType.PluginReload,
+ {
+ "plugin_id": "CustomHosts"
+ })
else:
self.error("获取到最优ip格式错误,请重试")
self._onlyonce = False
diff --git a/app/plugins/modules/customhosts.py b/app/plugins/modules/customhosts.py
index aa772d14..df7ffe76 100644
--- a/app/plugins/modules/customhosts.py
+++ b/app/plugins/modules/customhosts.py
@@ -14,11 +14,13 @@ class CustomHosts(_IPluginModule):
# 插件图标
module_icon = "hosts.png"
# 主题色
- module_color = "bg-cyan"
+ module_color = "#02C4E0"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
module_config_prefix = "customhosts_"
# 加载顺序
@@ -106,12 +108,17 @@ def init_config(self, config=None):
"enable": self._enable
})
- @EventHandler.register(EventType.CustomHostsReload)
+ @EventHandler.register(EventType.PluginReload)
def reload(self, event):
"""
- CloudflareSpeedTest优选ip后重载本插件
+ 响应插件重载事件
"""
- self.init_config(event.event_data)
+ plugin_id = event.event_data.get("plugin_id")
+ if not plugin_id:
+ return
+ if plugin_id != self.__class__.__name__:
+ return
+ return self.init_config(self.get_config())
@staticmethod
def __read_system_hosts():
diff --git a/app/plugins/modules/customreleasegroups.py b/app/plugins/modules/customreleasegroups.py
index ff57ccb7..49d495b5 100644
--- a/app/plugins/modules/customreleasegroups.py
+++ b/app/plugins/modules/customreleasegroups.py
@@ -10,11 +10,13 @@ class CustomReleaseGroups(_IPluginModule):
# 插件图标
module_icon = "teamwork.png"
# 主题色
- module_color = "bg-cyan"
+ module_color = "#00ADEF"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "Shurelol"
+ # 作者主页
+ author_url = "https://github.com/Shurelol"
# 插件配置项ID前缀
module_config_prefix = "customreleasegroups_"
# 加载顺序
diff --git a/app/plugins/modules/diskspacesaver.py b/app/plugins/modules/diskspacesaver.py
index 75747a1d..3412c47e 100644
--- a/app/plugins/modules/diskspacesaver.py
+++ b/app/plugins/modules/diskspacesaver.py
@@ -14,15 +14,17 @@ class DiskSpaceSaver(_IPluginModule):
# 插件图标
module_icon = "diskusage.jpg"
# 主题色
- module_color = "bg-yellow"
+ module_color = "#FE9003"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "link2fun"
+ # 作者主页
+ author_url = "https://github.com/link2fun"
# 插件配置项ID前缀
module_config_prefix = "diskspace_saver_"
# 加载顺序
- module_order = 20
+ module_order = 13
# 可使用的用户级别
auth_level = 1
diff --git a/app/plugins/modules/doubanrank.py b/app/plugins/modules/doubanrank.py
new file mode 100644
index 00000000..45bb6fe9
--- /dev/null
+++ b/app/plugins/modules/doubanrank.py
@@ -0,0 +1,338 @@
+import re
+import xml.dom.minidom
+from datetime import datetime
+from threading import Event
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.helper import DbHelper
+from app.mediaserver import MediaServer
+from app.plugins.modules._base import _IPluginModule
+from app.subscribe import Subscribe
+from app.utils import RequestUtils, DomUtils
+from app.utils.types import MediaType, SearchType, RssType
+from config import Config
+from web.backend.web_utils import WebUtils
+
+
+class DoubanRank(_IPluginModule):
+ # 插件名称
+ module_name = "豆瓣榜单订阅"
+ # 插件描述
+ module_desc = "监控豆瓣热门榜单,自动添加订阅。"
+ # 插件图标
+ module_icon = "movie.jpg"
+ # 主题色
+ module_color = "#01B3E3"
+ # 插件版本
+ module_version = "1.0"
+ # 插件作者
+ module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
+ # 插件配置项ID前缀
+ module_config_prefix = "doubanrank_"
+ # 加载顺序
+ module_order = 16
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 退出事件
+ _event = Event()
+ # 私有属性
+ mediaserver = None
+ dbhelper = None
+ subscribe = 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/subject_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',
+ }
+ _enable = False
+ _onlyonce = False
+ _cron = ""
+ _rss_addrs = []
+ _ranks = []
+ _scheduler = None
+
+ def init_config(self, config: dict = None):
+ self.mediaserver = MediaServer()
+ self.dbhelper = DbHelper()
+ self.subscribe = Subscribe()
+ if config:
+ self._enable = config.get("enable")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ 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.stop_service()
+
+ # 启动服务
+ if self.get_state() or self._onlyonce:
+ self._scheduler = BackgroundScheduler(timezone=Config().get_timezone())
+ if self._cron:
+ self._scheduler.add_job(self.__refresh_rss, CronTrigger.from_crontab(self._cron))
+ if self._onlyonce:
+ self._scheduler.add_job(self.__refresh_rss, 'date',
+ run_date=datetime.now(tz=pytz.timezone(Config().get_timezone())))
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+ if self._onlyonce:
+ self.info(f"订阅服务启动,立即运行一次")
+ # 关闭一次性开关
+ self._onlyonce = False
+ self.update_config({
+ "onlyonce": False,
+ "enable": self._enable,
+ "cron": self._cron,
+ "ranks": self._ranks,
+ "rss_addrs": "\n".join(self._rss_addrs)
+ })
+ if self._cron:
+ self.info(f"订阅服务启动,周期:{self._cron}")
+
+ def get_state(self):
+ return self._enable and self._cron and (self._ranks or self._rss_addrs)
+
+ @staticmethod
+ def get_fields():
+ return [
+ # 同一板块
+ {
+ 'type': 'div',
+ 'content': [
+ # 同一行
+ [
+ {
+ 'title': '开启豆瓣榜单订阅',
+ 'required': "",
+ 'tooltip': '开启后,自动监控豆瓣榜单变化,有新内容时如媒体服务器不存在且未订阅过,则会添加订阅,仅支持rsshub的豆瓣RSS',
+ 'type': 'switch',
+ 'id': 'enable',
+ }
+ ],
+ [
+ {
+ 'title': '立即运行一次',
+ 'required': "",
+ 'tooltip': '打开后立即运行一次(点击此对话框的确定按钮后即会运行,周期未设置也会运行),关闭后将仅按照刮削周期运行(同时上次触发运行的任务如果在运行中也会停止)',
+ 'type': 'switch',
+ 'id': 'onlyonce',
+ }
+ ],
+ [
+ {
+ 'title': '刷新周期',
+ 'required': "required",
+ 'tooltip': '榜单数据刷新的时间周期,支持5位cron表达式;应根据榜单更新的周期合理设置刷新时间,避免刷新过于频繁',
+ 'type': 'text',
+ 'content': [
+ {
+ 'id': 'cron',
+ 'placeholder': '0 0 0 ? *',
+ }
+ ]
+ }
+ ],
+ [
+ {
+ 'title': 'RssHub订阅地址',
+ 'required': '',
+ 'tooltip': '每一行一个RSS地址,访问 https://docs.rsshub.app/social-media.html#dou-ban 查询可用地址',
+ 'type': 'textarea',
+ 'content':
+ {
+ 'id': 'rss_addrs',
+ 'placeholder': 'https://rsshub.app/douban/movie/classification/:sort?/:score?/:tags?',
+ 'rows': 5
+ }
+ }
+ ]
+ ]
+ },
+ {
+ 'type': 'details',
+ 'summary': '热门榜单',
+ 'tooltip': '内建支持的豆瓣榜单,使用https://rsshub.app数据源,可直接选择订阅',
+ 'content': [
+ # 同一行
+ [
+ {
+ 'id': 'ranks',
+ 'type': 'form-selectgroup',
+ 'content': {
+ 'movie-ustop': {
+ 'id': 'movie-ustop',
+ 'name': '北美电影票房榜',
+ },
+ 'movie-weekly': {
+ 'id': 'movie-weekly',
+ 'name': '一周电影口碑榜',
+ },
+ 'movie-real-time': {
+ 'id': 'movie-real-time',
+ 'name': '实时热门榜',
+ },
+ 'movie-hot-gaia': {
+ 'id': 'movie-hot-gaia',
+ 'name': '热门电影',
+ },
+ 'movie-top250': {
+ 'id': 'movie-top250',
+ 'name': '电影TOP10',
+ },
+ 'tv-hot': {
+ 'id': 'tv-hot',
+ 'name': '热门剧集',
+ },
+ 'show-domestic': {
+ 'id': 'show-domestic',
+ 'name': '热门综艺',
+ }
+ }
+ },
+ ]
+ ]
+ }
+ ]
+
+ 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 __refresh_rss(self):
+ """
+ 刷新RSS
+ """
+ self.info(f"开始刷新RSS ...")
+ addr_list = self._rss_addrs + [self._douban_address.get(rank) for rank in self._ranks]
+ if not addr_list:
+ self.info(f"未设置RSS地址")
+ return
+ else:
+ self.info(f"共 {len(addr_list)} 个RSS地址需要刷新")
+ for addr in addr_list:
+ if not addr:
+ continue
+ try:
+ self.info(f"获取RSS:{addr} ...")
+ rss_infos = self.__get_rss_info(addr)
+ if not rss_infos:
+ self.error(f"RSS地址:{addr} ,未查询到数据")
+ continue
+ else:
+ self.info(f"RSS地址:{addr} ,共 {len(rss_infos)} 条数据")
+ for rss_info in rss_infos:
+ if self._event.is_set():
+ self.info(f"订阅服务停止")
+ return
+ # 识别媒体信息
+ media_info = WebUtils.get_mediainfo_from_id(mtype=rss_info.get("type"),
+ mediaid=f"DB:{rss_info.get('doubanid')}")
+ if not media_info:
+ self.warn(f"未查询到TMDB媒体信息:{rss_info.get('doubanid')} - {rss_info.get('title')}")
+ continue
+ # 检查媒体服务器是否存在
+ item_id = self.mediaserver.check_item_exists(mtype=media_info.type,
+ title=media_info.title,
+ year=media_info.year,
+ tmdbid=media_info.tmdb_id,
+ season=media_info.get_season_seq())
+ if item_id:
+ self.info(f"媒体服务器已存在:{media_info.get_title_string()}")
+ continue
+ # 检查是否已订阅过
+ if self.dbhelper.check_rss_history(
+ type_str="MOV" if media_info.type == MediaType.MOVIE else "TV",
+ name=media_info.title,
+ year=media_info.year,
+ season=media_info.get_season_string()):
+ self.info(
+ f"{media_info.get_title_string()}{media_info.get_season_string()} 已订阅过")
+ continue
+ # 添加订阅
+ code, msg, rss_media = self.subscribe.add_rss_subscribe(
+ mtype=media_info.type,
+ name=media_info.title,
+ year=media_info.year,
+ season=media_info.begin_season,
+ channel=RssType.Auto,
+ in_from=SearchType.PLUGIN
+ )
+ if not rss_media or code != 0:
+ self.warn("%s 添加订阅失败:%s" % (media_info.get_title_string(), msg))
+ else:
+ self.info("%s 添加订阅成功" % media_info.get_title_string())
+ except Exception as e:
+ self.error(str(e))
+ self.info(f"所有RSS刷新完成")
+
+ def __get_rss_info(self, addr):
+ """
+ 获取RSS
+ """
+ try:
+ ret = RequestUtils().get_res(addr)
+ if not ret:
+ return []
+ ret.encoding = ret.apparent_encoding
+ 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:
+ # 标题
+ title = DomUtils.tag_value(item, "title", default="")
+ # 链接
+ link = DomUtils.tag_value(item, "link", default="")
+ if not title or not link:
+ self.warn(f"标题或链接为空:{title} - {link}")
+ continue
+ doubanid = re.findall(r"/(\d+)/", link)
+ if doubanid:
+ doubanid = doubanid[0]
+ if not doubanid or not str(doubanid).isdigit():
+ self.warn("豆瓣ID解析失败:" + link)
+ continue
+ # 返回对象
+ ret_array.append({
+ 'title': title,
+ 'link': link,
+ 'doubanid': doubanid
+ })
+ except Exception as e1:
+ self.error("解析RSS条目失败:" + str(e1))
+ continue
+ return ret_array
+ except Exception as e:
+ self.error("获取RSS失败:" + str(e))
+ return []
diff --git a/app/plugins/modules/doubansync.py b/app/plugins/modules/doubansync.py
new file mode 100644
index 00000000..4d11c752
--- /dev/null
+++ b/app/plugins/modules/doubansync.py
@@ -0,0 +1,577 @@
+import random
+from datetime import datetime
+from threading import Event, Lock
+from time import sleep
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from jinja2 import Template
+
+from app.downloader import Downloader
+from app.helper import DbHelper
+from app.media import DouBan, Media
+from app.media.meta import MetaInfo
+from app.plugins import EventHandler
+from app.plugins.modules._base import _IPluginModule
+from app.searcher import Searcher
+from app.subscribe import Subscribe
+from app.utils import ExceptionUtils
+from app.utils.types import SearchType, RssType, EventType, MediaType
+from config import Config
+
+lock = Lock()
+
+
+class DoubanSync(_IPluginModule):
+ # 插件名称
+ module_name = "豆瓣同步"
+ # 插件描述
+ module_desc = "同步豆瓣在看、想看、看过记录,自动添加订阅或搜索下载。"
+ # 插件图标
+ module_icon = "douban.png"
+ # 主题色
+ module_color = "#05B711"
+ # 插件版本
+ module_version = "1.0"
+ # 插件作者
+ module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
+ # 插件配置项ID前缀
+ module_config_prefix = "doubansync_"
+ # 加载顺序
+ module_order = 17
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 退出事件
+ _event = Event()
+ # 私有属性
+ douban = None
+ searcher = None
+ downloader = None
+ media = None
+ dbhelper = None
+ subscribe = None
+ _enable = False
+ _onlyonce = False
+ _interval = False
+ _auto_search = False
+ _auto_rss = False
+ _users = []
+ _days = 0
+ _types = []
+ _cookie = None
+ _scheduler = None
+
+ def init_config(self, config: dict = None):
+ self.douban = DouBan()
+ self.searcher = Searcher()
+ self.downloader = Downloader()
+ self.media = Media()
+ self.dbhelper = DbHelper()
+ self.subscribe = Subscribe()
+ if config:
+ self._enable = config.get("enable")
+ self._onlyonce = config.get("onlyonce")
+ self._interval = config.get("interval")
+ if self._interval and str(self._interval).isdigit():
+ self._interval = int(self._interval)
+ else:
+ self._interval = 0
+ self._auto_search = config.get("auto_search")
+ self._auto_rss = config.get("auto_rss")
+ self._cookie = config.get("cookie")
+ self._users = config.get("users") or []
+ if self._users:
+ if isinstance(self._users, str):
+ self._users = self._users.split(',')
+ self._days = config.get("days")
+ if self._days and str(self._days).isdigit():
+ self._days = int(self._days)
+ else:
+ self._days = 0
+ self._types = config.get("types") or []
+ if self._types:
+ if isinstance(self._types, str):
+ self._types = self._types.split(',')
+
+ # 停止现有任务
+ self.stop_service()
+
+ # 启动服务
+ if self.get_state() or self._onlyonce:
+ self._scheduler = BackgroundScheduler(timezone=Config().get_timezone())
+ if self._interval:
+ self._scheduler.add_job(self.sync, 'interval', hours=self._interval)
+ if self._onlyonce:
+ self._scheduler.add_job(self.sync, 'date',
+ run_date=datetime.now(tz=pytz.timezone(Config().get_timezone())))
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+ if self._onlyonce:
+ self.info(f"同步服务启动,立即运行一次")
+ # 关闭一次性开关
+ self._onlyonce = False
+ self.update_config({
+ "onlyonce": False,
+ "enable": self._enable,
+ "interval": self._interval,
+ "auto_search": self._auto_search,
+ "auto_rss": self._auto_rss,
+ "cookie": self._cookie,
+ "users": self._users,
+ "days": self._days,
+ "types": self._types
+ })
+ if self._interval:
+ self.info(f"订阅服务启动,周期:{self._interval} 小时,类型:{self._types},用户:{self._users}")
+
+ def get_state(self):
+ return self._enable \
+ and self._interval \
+ and self._users \
+ and self._types
+
+ @staticmethod
+ def get_fields():
+ return [
+ # 同一板块
+ {
+ 'type': 'div',
+ 'content': [
+ # 同一行
+ [
+ {
+ 'title': '开启豆瓣同步',
+ 'required': "",
+ 'tooltip': '开启后,定时同步豆瓣在看、想看、看过记录,有新内容时自动添加订阅或者搜索下载',
+ 'type': 'switch',
+ 'id': 'enable',
+ }
+ ],
+ [
+ {
+ 'title': '立即运行一次',
+ 'required': "",
+ 'tooltip': '打开后立即运行一次(点击此对话框的确定按钮后即会运行,周期未设置也会运行),关闭后将仅按照刮削周期运行(同时上次触发运行的任务如果在运行中也会停止)',
+ 'type': 'switch',
+ 'id': 'onlyonce',
+ }
+ ],
+ [
+ {
+ 'title': '豆瓣用户ID',
+ 'required': "required",
+ 'tooltip': '需要同步数据的豆瓣用户ID,在豆瓣个人主页地址栏/people/后面的数字;如有多个豆瓣用户ID,使用英文逗号,分隔',
+ 'type': 'text',
+ 'content': [
+ {
+ 'id': 'users',
+ 'placeholder': '用户1,用户2,用户3',
+ }
+ ]
+ },
+ {
+ 'title': '同步数据类型',
+ 'required': "required",
+ 'tooltip': '同步哪些类型的收藏数据:do 在看,wish 想看,collect 看过,用英文逗号,分隔配置',
+ 'type': 'text',
+ 'content': [
+ {
+ 'id': 'types',
+ 'placeholder': 'do,wish,collect',
+ }
+ ]
+ }
+ ],
+ [
+ {
+ 'title': '同步范围(天)',
+ 'required': "required",
+ 'tooltip': '同步多少天内的记录,0表示同步全部',
+ 'type': 'text',
+ 'content': [
+ {
+ 'id': 'days',
+ 'placeholder': '30',
+ }
+ ]
+ },
+ {
+ 'title': '同步间隔(小时)',
+ 'required': "required",
+ 'tooltip': '间隔多久同步一次豆瓣数据,为了避免被豆瓣封禁IP,应尽可能拉长间隔时间',
+ 'type': 'text',
+ 'content': [
+ {
+ 'id': 'interval',
+ 'placeholder': '6',
+ }
+ ]
+ }
+ ],
+ [
+ {
+ 'title': '豆瓣Cookie',
+ 'required': '',
+ 'tooltip': '受豆瓣限制,部分电影需要配置Cookie才能同步到数据;通过浏览器抓取',
+ 'type': 'textarea',
+ 'content':
+ {
+ 'id': 'cookie',
+ 'placeholder': '',
+ 'rows': 5
+ }
+ }
+ ],
+ [
+ {
+ 'title': '自动搜索下载',
+ 'required': "",
+ 'tooltip': '开启后豆瓣同步的数据会自动进行站点聚合搜索下载',
+ 'type': 'switch',
+ 'id': 'auto_search',
+ },
+ {
+ 'title': '自动添加订阅',
+ 'required': "",
+ 'tooltip': '开启后未进行搜索下载的或搜索下载不完整的将加入订阅',
+ 'type': 'switch',
+ 'id': 'auto_rss',
+ }
+ ],
+ ]
+ }
+ ]
+
+ def get_page(self):
+ """
+ 插件的额外页面,返回页面标题和页面内容
+ """
+ results = self.dbhelper.get_douban_history()
+ template = """
+
+
+
+
+
+ 标题
+ 类型
+ 状态
+ 添加时间
+
+
+
+
+ {% if HistoryCount > 0 %}
+ {% for Item in DoubanHistory %}
+
+
+
+
+
+ {{ Item.NAME }} ({{ Item.YEAR }})
+ {% if Item.RATING %}
+
+ 评份:{{ Item.RATING }}
+
+ {% endif %}
+
+
+ {{ Item.TYPE }}
+
+
+ {% if Item.STATE == 'DOWNLOADED' %}
+ 已下载
+ {% elif Item.STATE == 'RSS' %}
+ 已订阅
+ {% elif Item.STATE == 'NEW' %}
+ 新增
+ {% else %}
+ 处理中
+ {% endif %}
+
+
+ {{ Item.ADD_TIME or '' }}
+
+
+
+
+
+ {% endfor %}
+ {% else %}
+
+ 没有数据
+
+ {% endif %}
+
+
+
+
+ """
+ return "同步历史", Template(template).render(HistoryCount=len(results), DoubanHistory=results)
+
+ 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))
+
+ @EventHandler.register(EventType.DoubanSync)
+ def sync(self, event=None):
+ """
+ 同步豆瓣数据
+ """
+ if not self._interval:
+ self.info("豆瓣配置:同步间隔未配置或配置不正确")
+ return
+ with lock:
+ self.info("开始同步豆瓣数据...")
+ # 拉取豆瓣数据
+ medias = self.__get_all_douban_movies()
+ # 开始检索
+ for media in medias:
+ if not media or not media.get_name():
+ continue
+ try:
+ # 查询数据库状态,已经加入RSS的不处理
+ search_state = self.dbhelper.get_douban_search_state(media.get_name(), media.year)
+ if not search_state or search_state[0] == "NEW":
+ if self._auto_search:
+ # 需要检索
+ if media.begin_season:
+ subtitle = "第%s季" % media.begin_season
+ else:
+ subtitle = None
+ media_info = self.media.get_media_info(title="%s %s" % (media.get_name(), media.year or ""),
+ subtitle=subtitle,
+ mtype=media.type)
+ # 不需要自动加订阅,则直接搜索
+ if not media_info or not media_info.tmdb_info:
+ self.warn("%s 未查询到媒体信息" % media.get_name())
+ continue
+ # 检查是否存在,电视剧返回不存在的集清单
+ exist_flag, no_exists, _ = self.downloader.check_exists_medias(meta_info=media_info)
+ # 已经存在
+ if exist_flag:
+ # 更新为已下载状态
+ self.info("%s 已存在" % media.get_name())
+ self.dbhelper.insert_douban_media_state(media, "DOWNLOADED")
+ continue
+ if not self._auto_rss:
+ # 合并季
+ media_info.begin_season = media.begin_season
+ # 开始检索
+ search_result, no_exists, search_count, download_count = self.searcher.search_one_media(
+ media_info=media_info,
+ in_from=SearchType.DB,
+ no_exists=no_exists,
+ user_name=media_info.user_name)
+ if search_result:
+ # 下载全了更新为已下载,没下载全的下次同步再次搜索
+ self.dbhelper.insert_douban_media_state(media, "DOWNLOADED")
+ else:
+ # 需要加订阅,则由订阅去检索
+ self.info(
+ "%s %s 更新到%s订阅中..." % (media.get_name(), media.year, media.type.value))
+ code, msg, _ = self.subscribe.add_rss_subscribe(mtype=media.type,
+ name=media.get_name(),
+ year=media.year,
+ channel=RssType.Auto,
+ season=media.begin_season,
+ mediaid=f"DB:{media.douban_id}",
+ in_from=SearchType.DB)
+ if code != 0:
+ self.error("%s 添加订阅失败:%s" % (media.get_name(), msg))
+ # 订阅已存在
+ if code == 9:
+ self.dbhelper.insert_douban_media_state(media, "RSS")
+ else:
+ # 插入为已RSS状态
+ self.dbhelper.insert_douban_media_state(media, "RSS")
+ else:
+ # 不需要检索
+ if self._auto_rss:
+ # 加入订阅,使状态为R
+ self.info("%s %s 更新到%s订阅中..." % (
+ media.get_name(), media.year, media.type.value))
+ code, msg, _ = self.subscribe.add_rss_subscribe(mtype=media.type,
+ name=media.get_name(),
+ year=media.year,
+ channel=RssType.Auto,
+ season=media.begin_season,
+ mediaid=f"DB:{media.douban_id}",
+ state="R",
+ in_from=SearchType.DB)
+ if code != 0:
+ self.error("%s 添加订阅失败:%s" % (media.get_name(), msg))
+ # 订阅已存在
+ if code == 9:
+ self.dbhelper.insert_douban_media_state(media, "RSS")
+ else:
+ # 插入为已RSS状态
+ self.dbhelper.insert_douban_media_state(media, "RSS")
+ elif not search_state:
+ self.info("%s %s 更新到%s列表中..." % (
+ media.get_name(), media.year, media.type.value))
+ self.dbhelper.insert_douban_media_state(media, "NEW")
+
+ else:
+ self.info("%s %s 已处理过" % (media.get_name(), media.year))
+ except Exception as err:
+ self.error("%s %s 处理失败:%s" % (media.get_name(), media.year, str(err)))
+ continue
+ self.info("豆瓣数据同步完成")
+
+ def __get_all_douban_movies(self):
+ """
+ 获取每一个用户的每一个类型的豆瓣标记
+ :return: 检索到的媒体信息列表(不含TMDB信息)
+ """
+ if not self._interval \
+ or not self._users \
+ or not self._types:
+ self.error("豆瓣插件未配置或配置不正确")
+ return []
+ # 返回媒体列表
+ media_list = []
+ # 豆瓣ID列表
+ douban_ids = {}
+ # 每页条数
+ perpage_number = 15
+ # 每一个用户
+ for user in self._users:
+ if not user:
+ continue
+ # 查询用户名称
+ user_name = ""
+ userinfo = self.douban.get_user_info(userid=user)
+ if userinfo:
+ user_name = userinfo.get("name")
+ # 每一个类型成功数量
+ user_succnum = 0
+ for mtype in self._types:
+ if not mtype:
+ continue
+ self.info(f"开始获取 {user_name or user} 的 {mtype} 数据...")
+ # 开始序号
+ start_number = 0
+ # 类型成功数量
+ user_type_succnum = 0
+ # 每一页
+ while True:
+ # 页数
+ page_number = int(start_number / perpage_number + 1)
+ # 当前页成功数量
+ sucess_urlnum = 0
+ # 是否继续下一页
+ continue_next_page = True
+ self.debug(f"开始解析第 {page_number} 页数据...")
+ try:
+ items = self.douban.get_douban_wish(dtype=mtype, userid=user, start=start_number, wait=True)
+ if not items:
+ self.warn(f"第 {page_number} 页未获取到数据")
+ break
+ # 解析豆瓣ID
+ for item in items:
+ # 时间范围
+ date = item.get("date")
+ if not date:
+ continue_next_page = False
+ break
+ else:
+ mark_date = datetime.strptime(date, '%Y-%m-%d')
+ if self._days and not (datetime.now() - mark_date).days < int(self._days):
+ continue_next_page = False
+ break
+ doubanid = item.get("id")
+ if str(doubanid).isdigit():
+ self.info("解析到媒体:%s" % doubanid)
+ if doubanid not in douban_ids:
+ douban_ids[doubanid] = {
+ "user_name": user_name
+ }
+ sucess_urlnum += 1
+ user_type_succnum += 1
+ user_succnum += 1
+ self.debug(
+ f"{user_name or user} 第 {page_number} 页解析完成,共获取到 {sucess_urlnum} 个媒体")
+ except Exception as err:
+ ExceptionUtils.exception_traceback(err)
+ self.error(f"{user_name or user} 第 {page_number} 页解析出错:%s" % str(err))
+ break
+ # 继续下一页
+ if continue_next_page:
+ start_number += perpage_number
+ else:
+ break
+ # 当前类型解析结束
+ self.debug(f"用户 {user_name or user} 的 {mtype} 解析完成,共获取到 {user_type_succnum} 个媒体")
+ self.info(f"用户 {user_name or user} 解析完成,共获取到 {user_succnum} 个媒体")
+ self.info(f"所有用户解析完成,共获取到 {len(douban_ids)} 个媒体")
+ # 查询豆瓣详情
+ for doubanid, info in douban_ids.items():
+ douban_info = self.douban.get_douban_detail(doubanid=doubanid, wait=True)
+ # 组装媒体信息
+ if not douban_info:
+ self.warn("%s 未正确获取豆瓣详细信息,尝试使用网页获取" % doubanid)
+ douban_info = self.douban.get_media_detail_from_web(doubanid)
+ if not douban_info:
+ self.warn("%s 无权限访问,需要配置豆瓣Cookie" % doubanid)
+ # 随机休眠
+ sleep(round(random.uniform(1, 5), 1))
+ continue
+ media_type = MediaType.TV if douban_info.get("episodes_count") else MediaType.MOVIE
+ self.info("%s:%s %s".strip() % (media_type.value, douban_info.get("title"), douban_info.get("year")))
+ meta_info = MetaInfo(title="%s %s" % (douban_info.get("title"), douban_info.get("year") or ""))
+ meta_info.douban_id = doubanid
+ meta_info.type = media_type
+ meta_info.overview = douban_info.get("intro")
+ meta_info.poster_path = douban_info.get("cover_url")
+ rating = douban_info.get("rating", {}) or {}
+ meta_info.vote_average = rating.get("value") or ""
+ meta_info.imdb_id = douban_info.get("imdbid")
+ meta_info.user_name = info.get("user_name")
+ if meta_info not in media_list:
+ media_list.append(meta_info)
+ # 随机休眠
+ sleep(round(random.uniform(1, 5), 1))
+ return media_list
diff --git a/app/plugins/modules/iyuuautoseed.py b/app/plugins/modules/iyuuautoseed.py
new file mode 100644
index 00000000..e2b228b5
--- /dev/null
+++ b/app/plugins/modules/iyuuautoseed.py
@@ -0,0 +1,397 @@
+from datetime import datetime
+from threading import Event
+
+import pytz
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.downloader import Downloader
+from app.helper import IyuuHelper
+from app.media.meta import MetaInfo
+from app.message import Message
+from app.plugins.modules._base import _IPluginModule
+from app.sites import Sites
+from app.utils.types import DownloaderType
+from config import Config
+
+
+class IYUUAutoSeed(_IPluginModule):
+ # 插件名称
+ module_name = "IYUU自动辅种"
+ # 插件描述
+ module_desc = "基于IYUU官方Api实现自动辅种。"
+ # 插件图标
+ module_icon = "iyuu.png"
+ # 主题色
+ module_color = "#F3B70B"
+ # 插件版本
+ module_version = "1.0"
+ # 插件作者
+ module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
+ # 插件配置项ID前缀
+ module_config_prefix = "iyuuautoseed_"
+ # 加载顺序
+ module_order = 10
+ # 可使用的用户级别
+ user_level = 1
+
+ # 私有属性
+ _scheduler = None
+ downloader = None
+ iyuuhelper = None
+ sites = None
+ message = None
+ # 限速开关
+ _enable = False
+ _cron = None
+ _onlyonce = False
+ _token = None
+ _downloaders = []
+ _sites = []
+ _notify = False
+ # 退出事件
+ _event = Event()
+
+ @staticmethod
+ def get_fields():
+ downloaders = {k: v for k, v in Downloader().get_downloader_conf_simple().items()
+ if v.get("type") in ["qbittorrent", "transmission"] and v.get("enabled")}
+ sites = {site.get("id"): site for site in Sites().get_site_dict()}
+ return [
+ # 同一板块
+ {
+ 'type': 'div',
+ 'content': [
+ # 同一行
+ [
+ {
+ 'title': '开启自动辅种',
+ 'required': "",
+ 'tooltip': '开启后,自动监控下载器,对下载完成的任务根据执行周期自动辅种。',
+ 'type': 'switch',
+ 'id': 'enable',
+ }
+ ],
+ [
+ {
+ 'title': '立即运行一次',
+ 'required': "",
+ 'tooltip': '打开后立即运行一次(点击此对话框的确定按钮后即会运行,周期未设置也会运行),关闭后将仅按照刮削周期运行(同时上次触发运行的任务如果在运行中也会停止)',
+ 'type': 'switch',
+ 'id': 'onlyonce',
+ }
+ ],
+ [
+ {
+ 'title': 'IYUU Token',
+ 'required': "required",
+ 'tooltip': '登录IYUU使用的Token,用于调用IYUU官方Api',
+ 'type': 'text',
+ 'content': [
+ {
+ 'id': 'token',
+ 'placeholder': 'IYUUxxx',
+ }
+ ]
+ },
+ {
+ 'title': '执行周期',
+ 'required': "required",
+ 'tooltip': '辅种任务执行的时间周期,支持5位cron表达式;应避免任务执行过于频繁',
+ 'type': 'text',
+ 'content': [
+ {
+ 'id': 'cron',
+ 'placeholder': '0 0 0 ? *',
+ }
+ ]
+ }
+ ]
+ ]
+ },
+ {
+ 'type': 'details',
+ 'summary': '辅种下载器',
+ 'tooltip': '只有选中的下载器才会执行辅种任务',
+ 'content': [
+ # 同一行
+ [
+ {
+ 'id': 'downloaders',
+ 'type': 'form-selectgroup',
+ 'content': downloaders
+ },
+ ]
+ ]
+ },
+ {
+ 'type': 'details',
+ 'summary': '辅种站点',
+ 'tooltip': '只有选中的站点才会执行辅种任务,不选则默认为全选',
+ 'content': [
+ # 同一行
+ [
+ {
+ 'id': 'sites',
+ 'type': 'form-selectgroup',
+ 'content': sites
+ },
+ ]
+ ]
+ },
+ {
+ 'type': 'div',
+ 'content': [
+ # 同一行
+ [
+ {
+ 'title': '运行时通知',
+ 'required': "",
+ 'tooltip': '运行辅助任务后会发送通知(需要打开自定义消息通知)',
+ 'type': 'switch',
+ 'id': 'notify',
+ }
+ ]
+ ]
+ }
+ ]
+
+ def init_config(self, config=None):
+ self.downloader = Downloader()
+ self.sites = Sites()
+ self.message = Message()
+ # 读取配置
+ if config:
+ self._enable = config.get("enable")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._token = config.get("token")
+ self._downloaders = config.get("downloaders")
+ self._sites = config.get("sites")
+ self._notify = config.get("notify")
+ # 停止现有任务
+ self.stop_service()
+
+ # 启动定时任务 & 立即运行一次
+ if self.get_state() or self._onlyonce:
+ self.iyuuhelper = IyuuHelper(token=self._token)
+ self._scheduler = BackgroundScheduler(timezone=Config().get_timezone())
+ if self._cron:
+ self._scheduler.add_job(self.auto_seed, CronTrigger.from_crontab(self._cron))
+ if self._onlyonce:
+ self._scheduler.add_job(self.auto_seed, 'date',
+ run_date=datetime.now(tz=pytz.timezone(Config().get_timezone())))
+ if self._cron or self._onlyonce:
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ if self._onlyonce:
+ self.info(f"辅种服务启动,立即运行一次")
+ if self._cron:
+ self.info(f"辅种服务启动,周期:{self._cron}")
+
+ # 关闭一次性开关
+ if self._onlyonce:
+ self._onlyonce = False
+ self.update_config({
+ "enable": self._enable,
+ "onlyonce": False,
+ "cron": self._cron,
+ "token": self._token,
+ "downloaders": self._downloaders,
+ "sites": self._sites,
+ "notify": self._notify
+ })
+
+ def get_state(self):
+ return True if self._enable and self._cron and self._token and self._downloaders else False
+
+ def auto_seed(self):
+ """
+ 开始辅种
+ """
+ if not self.get_state():
+ return
+ if not self.iyuuhelper:
+ return
+ self.info("开始辅种任务 ...")
+ # 扫描下载器辅种
+ for downloader in self._downloaders:
+ self.info(f"开始扫描下载器:{downloader} ...")
+ # 下载器类型
+ downloader_type = self.downloader.get_downloader_type(downloader_id=downloader)
+ # 获取下载器中已完成的种子
+ torrents = self.downloader.get_completed_torrents(downloader_id=downloader)
+ if torrents:
+ self.info(f"下载器:{downloader},已完成种子数:{len(torrents)}")
+ else:
+ self.info(f"下载器:{downloader},没有已完成种子")
+ continue
+ hash_strs = []
+ for torrent in torrents:
+ if self._event.is_set():
+ self.info(f"辅种服务停止")
+ return
+ # 获取种子hash
+ hash_str = self.__get_hash(torrent, downloader_type)
+ save_path = self.__get_save_path(torrent, downloader_type)
+ hash_strs.append({
+ "hash": hash_str,
+ "save_path": save_path
+ })
+ if hash_strs:
+ # 200个为一组
+ chunk_size = 200
+ for i in range(0, len(hash_strs), chunk_size):
+ chunk = hash_strs[i:i + chunk_size] # 切片操作
+ # 处理分组
+ self.__seed_torrents(hash_strs=chunk,
+ downloader=downloader)
+ self.info("辅种任务执行完成")
+
+ def __seed_torrents(self, hash_strs: list, downloader):
+ """
+ 执行一个种子的辅种
+ """
+ if not hash_strs:
+ return
+ self.info(f"下载器 {downloader} 开始查询辅种,数量:{len(hash_strs)} ...")
+ # 下载器中的Hashs
+ hashs = [item.get("hash") for item in hash_strs]
+ # 每个Hash的保存目录
+ save_paths = {}
+ for item in hash_strs:
+ save_paths[item.get("hash")] = item.get("save_path")
+ # 查询可辅种数据
+ seed_list, msg = self.iyuuhelper.get_seed_info(hashs)
+ if not isinstance(seed_list, dict):
+ self.warn(f"当前种子列表没有可辅种的站点:{msg}")
+ return
+ else:
+ self.info(f"IYUU返回可辅种数:{len(seed_list)}")
+ # 遍历
+ for current_hash, seed_info in seed_list.items():
+ if not seed_info:
+ continue
+ seed_torrents = seed_info.get("torrent")
+ if not isinstance(seed_torrents, list):
+ seed_torrents = [seed_torrents]
+ for seed in seed_torrents:
+ if not seed:
+ continue
+ if not isinstance(seed, dict):
+ continue
+ if not seed.get("sid") or not seed.get("info_hash"):
+ continue
+ if seed.get("info_hash") in hashs:
+ self.debug(f"{seed.get('info_hash')} 已在下载器中,跳过 ...")
+ continue
+ # 添加任务
+ self.__download_torrent(seed=seed,
+ downloader=downloader,
+ save_path=save_paths.get(current_hash))
+ # 针对每一个站点的种子,拼装下载链接下载种子,发送至下载器
+ self.info(f"下载器 {downloader} 辅种完成")
+
+ def __download_torrent(self, seed, downloader, save_path):
+ """
+ 下载种子
+ torrent: {
+ "sid": 3,
+ "torrent_id": 377467,
+ "info_hash": "a444850638e7a6f6220e2efdde94099c53358159"
+ }
+ """
+ site_url, download_page = self.iyuuhelper.get_torrent_url(seed.get("sid"))
+ if not site_url or not download_page:
+ return
+ # 查询站点
+ site_info = self.sites.get_sites(siteurl=site_url)
+ if not site_info:
+ self.warn(f"没有维护种子对应的站点:{site_url}")
+ return
+ if self._sites and str(site_info.get("id")) not in self._sites:
+ self.info("当前站点不在选择的辅助站点范围,跳过 ...")
+ return
+ # 查询hash值是否已经在下载器中
+ torrent_info = self.downloader.get_torrents(downloader_id=downloader,
+ ids=[seed.get("info_hash")])
+ if torrent_info:
+ self.info(f"{seed.get('info_hash')} 已在下载器中,跳过 ...")
+ return
+ # 下载种子
+ torrent_url = self.__get_download_url(seed=seed,
+ site=site_info,
+ base_url=download_page)
+ if not torrent_url:
+ return
+ meta_info = MetaInfo(title="IYUU自动辅种")
+ meta_info.set_torrent_info(site=site_info.get("name"),
+ enclosure=torrent_url)
+ _, download_id, retmsg = self.downloader.download(
+ media_info=meta_info,
+ tag=["已整理", "辅种"],
+ downloader_id=downloader,
+ download_dir=save_path,
+ download_setting="-2"
+ )
+ if not download_id:
+ # 下载失败
+ self.warn(f"添加下载任务出错,"
+ f"错误原因:{retmsg or '下载器添加任务失败'},"
+ f"种子链接:{torrent_url}")
+ return
+ else:
+ # 下载成功
+ self.info(f"成功添加辅种下载:站点:{site_info.get('name')},种子链接:{torrent_url}")
+ if self._notify:
+ msg_title = "【IYUU自动辅种新增任务】"
+ msg_text = f"站点:{site_info.get('name')}\n种子链接:{torrent_url}"
+ self.message.send_custom_message(title=msg_title, text=msg_text)
+
+ @staticmethod
+ def __get_hash(torrent, dl_type):
+ """
+ 获取种子hash
+ """
+ return torrent.get("hash") if dl_type == DownloaderType.QB else torrent.hashString
+
+ @staticmethod
+ def __get_save_path(torrent, dl_type):
+ """
+ 获取种子保存路径
+ """
+ return torrent.get("save_path") if dl_type == DownloaderType.QB else torrent.download_dir
+
+ def __get_download_url(self, seed, site, base_url):
+ """
+ 拼装种子下载链接
+ """
+ download_url = base_url.replace("id={}",
+ "id={id}"
+ ).format(
+ **{"id": seed.get("torrent_id"),
+ "passkey": site.get("passkey"),
+ "uid": site.get("uid")
+ })
+ if download_url.find("{") != -1:
+ self.warn(f"当前不支持该站点的辅助任务,Url转换失败:{download_url}")
+ return None
+ return f"{site.get('strict_url')}/{download_url}"
+
+ 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))
diff --git a/app/plugins/modules/libraryrefresh.py b/app/plugins/modules/libraryrefresh.py
index 459b5ecb..4e5f63ee 100644
--- a/app/plugins/modules/libraryrefresh.py
+++ b/app/plugins/modules/libraryrefresh.py
@@ -12,11 +12,13 @@ class LibraryRefresh(_IPluginModule):
# 插件图标
module_icon = "refresh.png"
# 主题色
- module_color = "bg-teal"
+ module_color = "#32BEA6"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
module_config_prefix = "libraryrefresh_"
# 加载顺序
diff --git a/app/plugins/modules/libraryscraper.py b/app/plugins/modules/libraryscraper.py
index 36c8cf26..f7c40ecc 100644
--- a/app/plugins/modules/libraryscraper.py
+++ b/app/plugins/modules/libraryscraper.py
@@ -1,4 +1,3 @@
-import os
from datetime import datetime
from threading import Event
@@ -6,13 +5,11 @@
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
-from app.media import Media, Scraper
-from app.media.meta import MetaInfo
+from app.media import Scraper
from app.plugins import EventHandler
from app.plugins.modules._base import _IPluginModule
-from app.utils import NfoReader
-from app.utils.types import MediaType, EventType
-from config import Config, RMT_MEDIAEXT
+from app.utils.types import EventType
+from config import Config
class LibraryScraper(_IPluginModule):
@@ -23,11 +20,13 @@ class LibraryScraper(_IPluginModule):
# 插件图标
module_icon = "scraper.png"
# 主题色
- module_color = "bg-orange"
+ module_color = "#FF7D00"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
module_config_prefix = "libraryscraper_"
# 加载顺序
@@ -37,7 +36,6 @@ class LibraryScraper(_IPluginModule):
# 私有属性
_scheduler = None
- _media = None
_scraper = None
# 限速开关
_cron = None
@@ -138,7 +136,6 @@ def get_fields():
]
def init_config(self, config=None):
- self._media = Media()
self._scraper = Scraper()
# 读取配置
@@ -164,19 +161,20 @@ def init_config(self, config=None):
self._scheduler.start()
if self._onlyonce:
- self.info(f"媒体库刮削服务启动,立即运行一次")
+ self.info(f"刮削服务启动,立即运行一次")
if self._cron:
- self.info(f"媒体库刮削服务启动,周期:{self._cron}")
+ self.info(f"刮削服务启动,周期:{self._cron}")
# 关闭一次性开关
- self._onlyonce = False
- self.update_config({
- "onlyonce": False,
- "cron": self._cron,
- "mode": self._mode,
- "scraper_path": self._scraper_path,
- "exclude_path": self._exclude_path
- })
+ if self._onlyonce:
+ self._onlyonce = False
+ self.update_config({
+ "onlyonce": False,
+ "cron": self._cron,
+ "mode": self._mode,
+ "scraper_path": self._scraper_path,
+ "exclude_path": self._exclude_path
+ })
def get_state(self):
return True if self._cron else False
@@ -193,71 +191,11 @@ def start_scrap(self, event):
return
path = event_info.get("path")
force = event_info.get("force")
- self.__folder_scraper(path, force=force)
-
- def __folder_scraper(self, path, exclude_path=None, force=None):
- """
- 刮削指定文件夹或文件
- :param path: 文件夹或文件路径
- :param force: 是否强制刮削
- :return:
- """
- # 模式
- if force is not None:
- force_nfo = force_pic = force
+ if force:
+ mode = 'force_all'
else:
- force_nfo = True if self._mode in ["force_nfo", "force_all"] else False
- force_pic = True if self._mode in ["force_all"] else False
- # 每个媒体库下的所有文件
- for file in self.__get_library_files(path, exclude_path):
- if self._event.is_set():
- self.info(f"媒体库刮削服务停止")
- return
- if not file:
- continue
- self.info(f"开始刮削媒体库文件:{file} ...")
- # 识别媒体文件
- meta_info = MetaInfo(os.path.basename(file))
- # 优先读取本地文件
- tmdbid = None
- if meta_info.type == MediaType.MOVIE:
- # 电影
- movie_nfo = os.path.join(os.path.dirname(file), "movie.nfo")
- if os.path.exists(movie_nfo):
- tmdbid = self.__get_tmdbid_from_nfo(movie_nfo)
- file_nfo = os.path.join(os.path.splitext(file)[0] + ".nfo")
- if not tmdbid and os.path.exists(file_nfo):
- tmdbid = self.__get_tmdbid_from_nfo(file_nfo)
- else:
- # 电视剧
- tv_nfo = os.path.join(os.path.dirname(os.path.dirname(file)), "tvshow.nfo")
- if os.path.exists(tv_nfo):
- tmdbid = self.__get_tmdbid_from_nfo(tv_nfo)
- if tmdbid and not force_nfo:
- self.info(f"读取到本地nfo文件的tmdbid:{tmdbid}")
- meta_info.set_tmdb_info(self._media.get_tmdb_info(mtype=meta_info.type,
- tmdbid=tmdbid,
- append_to_response='all'))
- media_info = meta_info
- else:
- medias = self._media.get_media_info_on_files(file_list=[file],
- append_to_response="all")
- if not medias:
- continue
- media_info = None
- for _, media in medias.items():
- media_info = media
- break
- if not media_info or not media_info.tmdb_info:
- continue
- self._scraper.gen_scraper_files(media=media_info,
- dir_path=os.path.dirname(file),
- file_name=os.path.splitext(os.path.basename(file))[0],
- file_ext=os.path.splitext(file)[-1],
- force=True,
- force_nfo=force_nfo,
- force_pic=force_pic)
- self.info(f"{file} 刮削完成")
+ mode = 'no_force'
+ self._scraper.folder_scraper(path, mode=mode)
def __libraryscraper(self):
"""
@@ -268,55 +206,15 @@ def __libraryscraper(self):
for path in self._scraper_path:
if not path:
continue
+ if self._event.is_set():
+ self.info(f"媒体库刮削服务停止")
+ return
# 刮削目录
- self.__folder_scraper(path, self._exclude_path)
+ self._scraper.folder_scraper(path=path,
+ exclude_path=self._exclude_path,
+ mode=self._mode)
self.info(f"媒体库刮削完成")
- @staticmethod
- def __get_library_files(in_path, exclude_path=None):
- """
- 获取媒体库文件列表
- """
- if not os.path.isdir(in_path):
- yield in_path
- return
-
- for root, dirs, files in os.walk(in_path):
- if exclude_path and any(os.path.abspath(root).startswith(os.path.abspath(path))
- for path in exclude_path.split(",")):
- continue
-
- for file in files:
- cur_path = os.path.join(root, file)
- # 检查后缀
- if os.path.splitext(file)[-1].lower() in RMT_MEDIAEXT:
- yield cur_path
-
- @staticmethod
- def __get_tmdbid_from_nfo(file_path):
- """
- 从nfo文件中获取信息
- :param file_path:
- :return: tmdbid
- """
- if not file_path:
- return None
- xpaths = [
- "uniqueid[@type='Tmdb']",
- "uniqueid[@type='tmdb']",
- "uniqueid[@type='TMDB']",
- "tmdbid"
- ]
- reader = NfoReader(file_path)
- for xpath in xpaths:
- try:
- tmdbid = reader.get_element_value(xpath)
- if tmdbid:
- return tmdbid
- except Exception as err:
- print(str(err))
- return None
-
def stop_service(self):
"""
退出插件
diff --git a/app/plugins/modules/mediasyncdel.py b/app/plugins/modules/mediasyncdel.py
index 394da2c8..96e8417b 100644
--- a/app/plugins/modules/mediasyncdel.py
+++ b/app/plugins/modules/mediasyncdel.py
@@ -17,15 +17,17 @@ class MediaSyncDel(_IPluginModule):
# 插件图标
module_icon = "emby.png"
# 主题色
- module_color = "bg-red"
+ module_color = "#C90425"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
module_config_prefix = "mediasyncdel_"
# 加载顺序
- module_order = 22
+ module_order = 15
# 可使用的用户级别
auth_level = 1
@@ -55,7 +57,7 @@ def get_fields():
{
'title': '删除源文件',
'required': "",
- 'tooltip': '开启后,删除历史记录的同时会同步删除源文件。',
+ 'tooltip': '开启后,删除历史记录的同时会同步删除源文件。同时开启下载任务清理插件,可联动删除下载任务。',
'type': 'switch',
'id': 'del_source',
},
@@ -121,17 +123,17 @@ def sync_del(self, event):
tmdb_id = event_data.get("tmdb_id")
# 季数
season_num = event_data.get("season_num")
- if season_num and int(season_num) < 10:
+ if season_num and str(season_num).isdigit() and int(season_num) < 10:
season_num = f'0{season_num}'
# 集数
episode_num = event_data.get("episode_num")
- if episode_num and int(episode_num) < 10:
+ if episode_num and str(episode_num).isdigit() and int(episode_num) < 10:
episode_num = f'0{episode_num}'
if not media_type:
self.error(f"{media_name} 同步删除失败,未获取到媒体类型")
return
- if not tmdb_id:
+ if not tmdb_id or not str(tmdb_id).isdigit():
self.error(f"{media_name} 同步删除失败,未获取到TMDB ID")
return
@@ -158,7 +160,7 @@ def sync_del(self, event):
transfer_history = self.dbhelper.get_transfer_info_by(tmdbid=tmdb_id)
# 删除季 S02
elif media_type == "Season":
- if not season_num:
+ if not season_num or not str(season_num).isdigit():
self.error(f"{media_name} 季同步删除失败,未获取到具体季")
return
event_info['item_name'] = f'{media_name} S{season_num}'
@@ -167,7 +169,7 @@ def sync_del(self, event):
transfer_history = self.dbhelper.get_transfer_info_by(tmdbid=tmdb_id, season=f'S{season_num}')
# 删除剧集S02E02
elif media_type == "Episode":
- if not season_num or not episode_num:
+ if not season_num or not str(season_num).isdigit() or not episode_num or not str(episode_num).isdigit():
self.error(f"{media_name} 集同步删除失败,未获取到具体集")
return
event_info['item_name'] = f'{media_name} S{season_num}E{episode_num}'
@@ -182,7 +184,16 @@ def sync_del(self, event):
return
# 开始删除
- logids = [history.ID for history in transfer_history]
+ if media_type == "Episode" or media_type == "Movie":
+ # 如果有剧集或者电影有多个版本的话,需要根据名称筛选下要删除的版本
+ logids = [history.ID for history in transfer_history if history.DEST_FILENAME == str(media_name)]
+ else:
+ logids = [history.ID for history in transfer_history]
+
+ if len(logids) == 0:
+ self.warn(f"{media_type} {media_name} 未获取到可删除数据")
+ return
+
self.info(f"获取到删除媒体数量 {len(logids)}")
WebAction().delete_history({
"logids": logids,
diff --git a/app/plugins/modules/movielike.py b/app/plugins/modules/movielike.py
index 56e1c5a6..3a448a53 100644
--- a/app/plugins/modules/movielike.py
+++ b/app/plugins/modules/movielike.py
@@ -18,11 +18,13 @@ class MovieLike(_IPluginModule):
# 插件图标
module_icon = "like.jpg"
# 主题色
- module_color = "bg-pink"
+ module_color = "#E4003F"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
module_config_prefix = "movielike_"
# 加载顺序
diff --git a/app/plugins/modules/opensubtitles.py b/app/plugins/modules/opensubtitles.py
index 79267d67..8df2846f 100644
--- a/app/plugins/modules/opensubtitles.py
+++ b/app/plugins/modules/opensubtitles.py
@@ -27,6 +27,8 @@ class OpenSubtitles(_IPluginModule):
module_version = "1.0"
# 插件作者
module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
module_config_prefix = "opensubtitles_"
# 加载顺序
@@ -46,7 +48,7 @@ class OpenSubtitles(_IPluginModule):
def __init__(self):
self._ua = Config().get_ua()
- def init_config(self, config: dict):
+ def init_config(self, config: dict = None):
self.sitehelper = SiteHelper()
self._save_tmp_path = Config().get_temp_path()
if not os.path.exists(self._save_tmp_path):
diff --git a/app/plugins/modules/speedlimiter.py b/app/plugins/modules/speedlimiter.py
index 16239438..8e4991a8 100644
--- a/app/plugins/modules/speedlimiter.py
+++ b/app/plugins/modules/speedlimiter.py
@@ -20,11 +20,13 @@ class SpeedLimiter(_IPluginModule):
# 插件图标
module_icon = "SpeedLimiter.jpg"
# 主题色
- module_color = "bg-blue"
+ module_color = "#183883"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "Shurelol"
+ # 作者主页
+ author_url = "https://github.com/Shurelol"
# 插件配置项ID前缀
module_config_prefix = "speedlimit_"
# 加载顺序
diff --git a/app/plugins/modules/synctimer.py b/app/plugins/modules/synctimer.py
index cedbcd68..4af55be3 100644
--- a/app/plugins/modules/synctimer.py
+++ b/app/plugins/modules/synctimer.py
@@ -14,11 +14,13 @@ class SyncTimer(_IPluginModule):
# 插件图标
module_icon = "synctimer.png"
# 主题色
- module_color = "bg-green"
+ module_color = "#53BA48"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
module_config_prefix = "synctimer_"
# 加载顺序
diff --git a/app/plugins/modules/torrentremover.py b/app/plugins/modules/torrentremover.py
index e8f42fc1..70acf124 100644
--- a/app/plugins/modules/torrentremover.py
+++ b/app/plugins/modules/torrentremover.py
@@ -16,11 +16,13 @@ class TorrentRemover(_IPluginModule):
# 插件图标
module_icon = "torrentremover.png"
# 主题色
- module_color = "bg-danger"
+ module_color = "#F44336"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
module_config_prefix = "torrentremover_"
# 加载顺序
@@ -35,7 +37,7 @@ class TorrentRemover(_IPluginModule):
def __init__(self):
self._ua = Config().get_ua()
- def init_config(self, config: dict):
+ def init_config(self, config: dict = None):
self.dbhelper = DbHelper()
if config:
self._enable = config.get("enable")
diff --git a/app/plugins/modules/webhook.py b/app/plugins/modules/webhook.py
index 5e18e5ba..3af3611c 100644
--- a/app/plugins/modules/webhook.py
+++ b/app/plugins/modules/webhook.py
@@ -12,11 +12,13 @@ class Webhook(_IPluginModule):
# 插件图标
module_icon = "webhook.png"
# 主题色
- module_color = "bg-purple"
+ module_color = "#C73A63"
# 插件版本
module_version = "1.0"
# 插件作者
module_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
module_config_prefix = "webhook_"
# 加载顺序
diff --git a/app/plugins/plugin_manager.py b/app/plugins/plugin_manager.py
index 689f3f7b..1118aea7 100644
--- a/app/plugins/plugin_manager.py
+++ b/app/plugins/plugin_manager.py
@@ -8,6 +8,7 @@
from app.plugins.event_manager import EventManager
from app.utils import SystemUtils, PathUtils
from app.utils.commons import singleton
+from app.utils.types import SystemConfigKey
from config import Config
@@ -41,7 +42,9 @@ def __init__(self):
def init_config(self):
self.systemconfig = SystemConfig()
self.eventmanager = EventManager()
- # 启动事件处理进程
+ # 停止已有插件
+ self.stop_service()
+ # 启动插件
self.start_service()
def __run(self):
@@ -78,7 +81,8 @@ def stop_service(self):
# 将事件管理器设为停止
self._active = False
# 等待事件处理线程退出
- self._thread.join()
+ if self._thread:
+ self._thread.join()
# 停止所有插件
self.__stop_plugins()
@@ -86,15 +90,25 @@ def __load_plugins(self):
"""
加载所有插件
"""
+ # 扫描插件目录
plugins = SubmoduleHelper.import_submodules(
"app.plugins.modules",
filter_func=lambda _, obj: hasattr(obj, 'module_name')
)
+ # 排序
plugins.sort(key=lambda x: x.module_order if hasattr(x, "module_order") else 0)
+ # 用户已安装插件列表
+ user_plugins = self.systemconfig.get_system_config(SystemConfigKey.UserInstalledPlugins) or []
+ self._running_plugins = {}
+ self._plugins = {}
for plugin in plugins:
module_id = plugin.__name__
self._plugins[module_id] = plugin
+ # 未安装的跳过加载
+ if module_id not in user_plugins:
+ continue
self._running_plugins[module_id] = plugin()
+ # 初始化配置
self.reload_plugin(module_id)
log.info(f"加载插件:{plugin}")
@@ -115,11 +129,14 @@ def reload_plugin(self, pid):
"""
生效插件配置
"""
+ if not pid:
+ return
if not self._running_plugins.get(pid):
return
if hasattr(self._running_plugins[pid], "init_config"):
try:
self._running_plugins[pid].init_config(self.get_plugin_config(pid))
+ log.debug(f"生效插件配置:{pid}")
except Exception as err:
print(str(err))
@@ -139,6 +156,27 @@ def get_plugin_config(self, pid):
return {}
return self.systemconfig.get_system_config(self._config_key % pid) or {}
+ def get_plugin_page(self, pid):
+ """
+ 获取插件数据
+ """
+ if not self._running_plugins.get(pid):
+ return None
+ if not hasattr(self._running_plugins[pid], "get_page"):
+ return None
+ title, html = self._running_plugins[pid].get_page()
+ return title, html
+
+ def get_plugin_state(self, pid):
+ """
+ 获取插件状态
+ """
+ if not self._running_plugins.get(pid):
+ return None
+ if not hasattr(self._running_plugins[pid], "get_state"):
+ return None
+ return self._running_plugins[pid].get_state()
+
def save_plugin_config(self, pid, conf):
"""
保存插件配置
@@ -169,10 +207,11 @@ def get_plugins_conf(self, auth_level):
conf.update({"icon": plugin.module_icon})
if hasattr(plugin, "module_color"):
conf.update({"color": plugin.module_color})
- if hasattr(plugin, "module_author"):
- conf.update({"author": plugin.module_author})
if hasattr(plugin, "module_config_prefix"):
conf.update({"prefix": plugin.module_config_prefix})
+ if hasattr(plugin, "get_page"):
+ title, _ = plugin.get_page()
+ conf.update({"page": title})
# 配置项
conf.update({"fields": plugin.get_fields() or {}})
# 配置值
@@ -182,3 +221,39 @@ def get_plugins_conf(self, auth_level):
# 汇总
all_confs[pid] = conf
return all_confs
+
+ def get_plugin_apps(self, auth_level):
+ """
+ 获取所有插件
+ """
+ all_confs = {}
+ installed_apps = self.systemconfig.get_system_config(SystemConfigKey.UserInstalledPlugins) or []
+ for pid, plugin in self._plugins.items():
+ # 基本属性
+ conf = {}
+ # 权限
+ if hasattr(plugin, "auth_level") \
+ and plugin.auth_level > auth_level:
+ continue
+ conf.update({"id": pid})
+ if pid in installed_apps:
+ conf.update({"installed": True})
+ else:
+ conf.update({"installed": False})
+ if hasattr(plugin, "module_name"):
+ conf.update({"name": plugin.module_name})
+ if hasattr(plugin, "module_desc"):
+ conf.update({"desc": plugin.module_desc})
+ if hasattr(plugin, "module_version"):
+ conf.update({"version": plugin.module_version})
+ if hasattr(plugin, "module_icon"):
+ conf.update({"icon": plugin.module_icon})
+ if hasattr(plugin, "module_color"):
+ conf.update({"color": plugin.module_color})
+ if hasattr(plugin, "module_author"):
+ conf.update({"author": plugin.module_author})
+ if hasattr(plugin, "author_url"):
+ conf.update({"author_url": plugin.author_url})
+ # 汇总
+ all_confs[pid] = conf
+ return all_confs
diff --git a/app/rss.py b/app/rss.py
index 882cde23..571adc75 100644
--- a/app/rss.py
+++ b/app/rss.py
@@ -306,6 +306,7 @@ def parse_rssxml(url, proxy=False):
"""
解析RSS订阅URL,获取RSS中的种子信息
:param url: RSS地址
+ :param proxy: 是否使用代理
:return: 种子信息列表
"""
_special_title_sites = {
@@ -529,7 +530,9 @@ def check_torrent_rss(self,
"restype": match_rss_info.get('filter_restype'),
"pix": match_rss_info.get('filter_pix'),
"team": match_rss_info.get('filter_team'),
- "rule": filter_rule
+ "rule": filter_rule,
+ "include": match_rss_info.get('filter_include'),
+ "exclude": match_rss_info.get('filter_exclude'),
}
match_filter_flag, res_order, match_filter_msg = self.filter.check_torrent_filter(meta_info=media_info,
filter_args=filter_dict)
diff --git a/app/rsschecker.py b/app/rsschecker.py
index 400dc9c0..9b62a398 100644
--- a/app/rsschecker.py
+++ b/app/rsschecker.py
@@ -319,7 +319,7 @@ def check_task_rss(self, taskid):
year=media_info.year,
season=media_info.get_season_string()):
log.info(
- f"【RssChecker】{media_info.title} ({media_info.year}) {media_info.get_season_string()} 已订阅过")
+ f"【RssChecker】{media_info.get_title_string()}{media_info.get_season_string()} 已订阅过")
continue
# 添加处理历史
self.dbhelper.insert_rss_torrents(media_info)
diff --git a/app/scheduler.py b/app/scheduler.py
index 286005fd..4b809770 100644
--- a/app/scheduler.py
+++ b/app/scheduler.py
@@ -9,7 +9,6 @@
from apscheduler.util import undefined
import log
-from app.doubansync import DoubanSync
from app.helper import MetaHelper
from app.mediaserver import MediaServer
from app.rss import Rss
@@ -96,23 +95,6 @@ def run_service(self):
self.SCHEDULER.add_job(Subscribe().subscribe_search_all, 'interval', hours=search_rss_interval)
log.info("订阅定时搜索服务启动")
- # 豆瓣电影同步
- if self._douban:
- douban_interval = self._douban.get('interval')
- if douban_interval:
- if isinstance(douban_interval, str):
- if douban_interval.isdigit():
- douban_interval = int(douban_interval)
- else:
- try:
- douban_interval = float(douban_interval)
- except Exception as e:
- log.info("豆瓣同步服务启动失败:%s" % str(e))
- douban_interval = 0
- if douban_interval:
- self.SCHEDULER.add_job(DoubanSync().sync, 'interval', hours=douban_interval)
- log.info("豆瓣同步服务启动")
-
# 媒体库同步
if self._media:
mediasync_interval = self._media.get("mediasync_interval")
diff --git a/app/subscribe.py b/app/subscribe.py
index d818acf2..b401344c 100644
--- a/app/subscribe.py
+++ b/app/subscribe.py
@@ -66,6 +66,8 @@ def add_rss_subscribe(self, mtype, name, year,
filter_pix=None,
filter_team=None,
filter_rule=None,
+ filter_include=None,
+ filter_exclude=None,
save_path=None,
download_setting=None,
total_ep=None,
@@ -91,6 +93,8 @@ def add_rss_subscribe(self, mtype, name, year,
:param filter_pix: 分辨率过滤
:param filter_team: 制作组/字幕组过滤
:param filter_rule: 关键字过滤
+ :param filter_include: 包含关键字
+ :param filter_exclude: 排除关键字
:param save_path: 保存路径
:param download_setting: 下载设置
:param state: 添加订阅时的状态
@@ -119,6 +123,8 @@ def add_rss_subscribe(self, mtype, name, year,
default_pix = default_rss_setting.get('pix')
default_team = default_rss_setting.get('team')
default_rule = default_rss_setting.get('rule')
+ default_include = default_rss_setting.get('include')
+ default_exclude = default_rss_setting.get('exclude')
default_download_setting = default_rss_setting.get('download_setting')
default_over_edition = default_rss_setting.get('over_edition')
default_rss_sites = default_rss_setting.get('rss_sites')
@@ -131,6 +137,10 @@ def add_rss_subscribe(self, mtype, name, year,
filter_team = default_team
if not filter_rule and default_rule:
filter_rule = int(default_rule) if str(default_rule).isdigit() else None
+ if not filter_include and default_include:
+ filter_include = default_include
+ if not filter_exclude and default_exclude:
+ filter_exclude = default_exclude
if not over_edition and default_over_edition:
over_edition = 1 if default_over_edition == "1" else 0
if not download_setting and default_download_setting:
@@ -202,6 +212,8 @@ def add_rss_subscribe(self, mtype, name, year,
filter_pix=filter_pix,
filter_team=filter_team,
filter_rule=filter_rule,
+ filter_include=filter_include,
+ filter_exclude=filter_exclude,
save_path=save_path,
download_setting=download_setting,
total_ep=total_ep,
@@ -223,6 +235,8 @@ def add_rss_subscribe(self, mtype, name, year,
filter_pix=filter_pix,
filter_team=filter_team,
filter_rule=filter_rule,
+ filter_include=filter_include,
+ filter_exclude=filter_exclude,
save_path=save_path,
download_setting=download_setting,
fuzzy_match=0,
@@ -248,6 +262,8 @@ def add_rss_subscribe(self, mtype, name, year,
filter_pix=filter_pix,
filter_team=filter_team,
filter_rule=filter_rule,
+ filter_include=filter_include,
+ filter_exclude=filter_exclude,
save_path=save_path,
download_setting=download_setting,
fuzzy_match=1,
@@ -266,6 +282,8 @@ def add_rss_subscribe(self, mtype, name, year,
filter_pix=filter_pix,
filter_team=filter_team,
filter_rule=filter_rule,
+ filter_include=filter_include,
+ filter_exclude=filter_exclude,
save_path=save_path,
download_setting=download_setting,
fuzzy_match=1,
@@ -382,6 +400,8 @@ def get_subscribe_movies(self, rid=None, state=None):
filter_pix = rss_movie.FILTER_PIX
filter_team = rss_movie.FILTER_TEAM
filter_rule = rss_movie.FILTER_RULE
+ filter_include = rss_movie.FILTER_INCLUDE
+ filter_exclude = rss_movie.FILTER_EXCLUDE
download_setting = rss_movie.DOWNLOAD_SETTING
save_path = rss_movie.SAVE_PATH
fuzzy_match = True if rss_movie.FUZZY_MATCH == 1 else False
@@ -419,6 +439,8 @@ def get_subscribe_movies(self, rid=None, state=None):
"filter_pix": filter_pix,
"filter_team": filter_team,
"filter_rule": filter_rule,
+ "filter_include": filter_include,
+ "filter_exclude": filter_exclude,
"save_path": save_path,
"download_setting": download_setting,
"fuzzy_match": fuzzy_match,
@@ -447,6 +469,8 @@ def get_subscribe_tvs(self, rid=None, state=None):
filter_pix = rss_tv.FILTER_PIX
filter_team = rss_tv.FILTER_TEAM
filter_rule = rss_tv.FILTER_RULE
+ filter_include = rss_tv.FILTER_INCLUDE
+ filter_exclude = rss_tv.FILTER_EXCLUDE
download_setting = rss_tv.DOWNLOAD_SETTING
save_path = rss_tv.SAVE_PATH
total_ep = rss_tv.TOTAL_EP
@@ -463,6 +487,8 @@ def get_subscribe_tvs(self, rid=None, state=None):
filter_pix = desc.get("pix")
filter_team = desc.get("team")
filter_rule = desc.get("rule")
+ filter_include = desc.get("include")
+ filter_exclude = desc.get("exclude")
save_path = ""
download_setting = ""
total_ep = desc.get("total")
@@ -489,6 +515,8 @@ def get_subscribe_tvs(self, rid=None, state=None):
"filter_pix": filter_pix,
"filter_team": filter_team,
"filter_rule": filter_rule,
+ "filter_include": filter_include,
+ "filter_exclude": filter_exclude,
"save_path": save_path,
"download_setting": download_setting,
"total": rss_tv.TOTAL,
@@ -704,6 +732,8 @@ def subscribe_search_movie(self, rssid=None, state='D'):
"pix": rss_info.get('filter_pix'),
"team": rss_info.get('filter_team'),
"rule": rss_info.get('filter_rule'),
+ "include": rss_info.get('filter_include'),
+ "exclude": rss_info.get('filter_exclude'),
"site": rss_info.get("search_sites")
}
search_result, _, _, _ = self.searcher.search_one_media(
@@ -829,6 +859,8 @@ def subscribe_search_tv(self, rssid=None, state="D"):
"pix": rss_info.get('filter_pix'),
"team": rss_info.get('filter_team'),
"rule": rss_info.get('filter_rule'),
+ "include": rss_info.get('filter_include'),
+ "exclude": rss_info.get('filter_exclude'),
"site": rss_info.get("search_sites")
}
search_result, no_exists, _, _ = self.searcher.search_one_media(
diff --git a/app/utils/types.py b/app/utils/types.py
index ebe5fab5..a42390d6 100644
--- a/app/utils/types.py
+++ b/app/utils/types.py
@@ -33,6 +33,7 @@ class SearchType(Enum):
API = "第三方API请求"
SLACK = "Slack"
SYNOLOGY = "Synology Chat"
+ PLUGIN = "插件"
class RmtMode(Enum):
@@ -128,8 +129,12 @@ class EventType(Enum):
LibraryFileDeleted = "libraryfile.deleted"
# 刮削媒体信息
MediaScrapStart = "media.scrap.start"
- # customHosts插件重载
- CustomHostsReload = "customhosts.reload"
+ # 插件重载
+ PluginReload = "plugin.reload"
+ # 豆瓣想看同步
+ DoubanSync = "douban.sync"
+ # 辅种任务开始
+ AutoSeedStart = "autoseed.start"
# 系统配置Key字典
@@ -150,6 +155,8 @@ class SystemConfigKey(Enum):
DefaultRssSettingMOV = "DefaultRssSettingMOV"
# 默认电视剧订阅设置
DefaultRssSettingTV = "DefaultRssSettingTV"
+ # 用户已安装的插件
+ UserInstalledPlugins = "UserInstalledPlugins"
# 处理进度Key字典
diff --git a/config/config.yaml b/config/config.yaml
index 9edb58fa..f1fc85ba 100644
--- a/config/config.yaml
+++ b/config/config.yaml
@@ -210,25 +210,6 @@ pt:
# 【搜索结果数量限制】:每个站点返回搜索结果的最大数量
site_search_result_num: 100
-# 【配置豆瓣账号信息】:配置后会自动同步豆瓣收藏,豆瓣标记想看内容后,后台自动下载
-douban:
- # 【用户ID列表】:豆瓣电影点个我主页people后面的那一串数字,或者使用豆瓣App个人信息中查看。可以配置多个,注意要加引号
- # 这里可以是自己的,也可以是别人的,比如填写几个大V的账号ID,实现热门影视自动下载
- users:
- - ""
- # 【豆瓣Cookie】:选配,嫌麻烦的可以不用配置,可能影响个别电影的同步
- cookie:
- # 【同步天数】:同步多少天内加入的数据
- days: 30
- # 【同步间隔】:多久同步一次数据,单位小时,建议不要太频繁,避免被检测到后封号
- interval:
- # 【同步数据类型】:同步哪些类型的收藏数据:do 在看,wish 想看,collect 看过,用逗号分隔配置
- types: "wish"
- # 【自动开载开关】:同步到豆瓣的数据后是否自动检索站点并下载
- auto_search: true
- # 【自动添加RSS开关】:站点检索找不到的记录是否自动添加RSS订阅(可实现未搜索到的自动追更)
- auto_rss: true
-
# 【openai】
openai:
# 【openai api key】:openai的api key,可在openai官网申请
@@ -259,11 +240,13 @@ security:
laboratory:
# 【识别增强】关键字猜想
search_keyword: false
- # 【识别增强】通过ChatGPT识别文件名
+ # 【WEB识别增强】通过TMDB WEB检索
+ search_tmdbweb: false
+ # 【ChatGPT识别增强】通过ChatGPT识别文件名
chatgpt_enable: false
# 【TMDB缓存过期策略】:是否开启TMDB缓存过期策略,默认7天过期,过期缓存将被删除, 7天内访问过期时间可以被刷新
tmdb_cache_expire: true
- # 【使用豆瓣名称联想】:开启将使用豆瓣进行电影电视剧的名称联想,否则使用TMDB的数据
+ # 【默认搜索豆瓣资源】:开启将使用豆瓣进行电影电视剧的名称搜索,否则使用TMDB的数据
use_douban_titles: false
# 【精确搜索使用英文名称】:开启后对于精确搜索场景(远程搜索、订阅搜索等)将会使用英文名检索站点资源以提升匹配度,但对有些站点资源标题全是中文的则需要关闭,否则匹配不到
search_en_title: true
diff --git a/config/sites.dat b/config/sites.dat
index e0fcc9ed..0ac5e55d 100644
Binary files a/config/sites.dat and b/config/sites.dat differ
diff --git a/db_scripts/versions/13a58bd5311f_1_2_2.py b/db_scripts/versions/13a58bd5311f_1_2_2.py
new file mode 100644
index 00000000..c9fccd82
--- /dev/null
+++ b/db_scripts/versions/13a58bd5311f_1_2_2.py
@@ -0,0 +1,38 @@
+"""1.2.2
+
+Revision ID: 13a58bd5311f
+Revises: 69508d1aed24
+Create Date: 2023-04-04 08:49:43.453901
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '13a58bd5311f'
+down_revision = '69508d1aed24'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ # 1.2.2
+ try:
+ with op.batch_alter_table("RSS_TVS") as batch_op:
+ batch_op.add_column(sa.Column('FILTER_INCLUDE', sa.Text, nullable=True))
+ batch_op.add_column(sa.Column('FILTER_EXCLUDE', sa.Text, nullable=True))
+ except Exception as e:
+ pass
+ try:
+ with op.batch_alter_table("RSS_MOVIES") as batch_op:
+ batch_op.add_column(sa.Column('FILTER_INCLUDE', sa.Text, nullable=True))
+ batch_op.add_column(sa.Column('FILTER_EXCLUDE', sa.Text, nullable=True))
+ except Exception as e:
+ pass
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ pass
diff --git a/docker/Dockerfile.dev b/docker/dev.Dockerfile
similarity index 100%
rename from docker/Dockerfile.dev
rename to docker/dev.Dockerfile
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index e44197ec..8681b7df 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -13,7 +13,7 @@ if [ "${NASTOOL_AUTO_UPDATE}" = "true" ]; then
if [ ! -s /tmp/package_list.txt.sha256sum ]; then
sha256sum package_list.txt > /tmp/package_list.txt.sha256sum
fi
- echo "更新程序..."
+ echo "更新主程序..."
git remote set-url origin "${REPO_URL}" &> /dev/null
echo "windows/" > .gitignore
# 更新分支
@@ -28,7 +28,27 @@ if [ "${NASTOOL_AUTO_UPDATE}" = "true" ]; then
git reset --hard origin/${branch}
if [ $? -eq 0 ]; then
- echo "更新成功..."
+ echo "主程序更新成功"
+ # 系统软件包更新
+ hash_old=$(cat /tmp/package_list.txt.sha256sum)
+ hash_new=$(sha256sum package_list.txt)
+ if [ "${hash_old}" != "${hash_new}" ]; then
+ echo "检测到package_list.txt有变化,更新软件包..."
+ if [ "${NASTOOL_CN_UPDATE}" = "true" ]; then
+ sed -i "s/dl-cdn.alpinelinux.org/${ALPINE_MIRROR}/g" /etc/apk/repositories
+ apk update -f
+ if [ $? -ne 0 ]; then
+ echo "无法更新软件包,请更新镜像!"
+ fi
+ fi
+ apk add --no-cache $(echo $(cat package_list.txt))
+ if [ $? -ne 0 ]; then
+ echo "无法更新软件包,请更新镜像!"
+ else
+ echo "软件包安装成功"
+ sha256sum package_list.txt > /tmp/package_list.txt.sha256sum
+ fi
+ fi
# Python依赖包更新
hash_old=$(cat /tmp/requirements.txt.sha256sum)
hash_new=$(sha256sum requirements.txt)
@@ -42,40 +62,25 @@ if [ "${NASTOOL_AUTO_UPDATE}" = "true" ]; then
pip install -r requirements.txt
fi
if [ $? -ne 0 ]; then
- echo "无法安装依赖,请更新镜像..."
+ echo "无法安装依赖,请更新镜像!"
+ exit 1
else
- echo "依赖安装成功..."
+ echo "依赖安装成功"
sha256sum requirements.txt > /tmp/requirements.txt.sha256sum
- hash_old=$(cat /tmp/third_party.txt.sha256sum)
- hash_new=$(sha256sum third_party.txt)
- if [ "${hash_old}" != "${hash_new}" ]; then
- echo "检测到third_party.txt有变化,更新第三方组件..."
- git submodule update --init --recursive
- if [ $? -ne 0 ]; then
- echo "无法更新第三方组件,请更新镜像..."
- else
- echo "第三方组件安装成功..."
- sha256sum third_party.txt > /tmp/third_party.txt.sha256sum
- fi
- fi
fi
fi
- # 系统软件包更新
- hash_old=$(cat /tmp/package_list.txt.sha256sum)
- hash_new=$(sha256sum package_list.txt)
+ # third_party 更新
+ hash_old=$(cat /tmp/third_party.txt.sha256sum)
+ hash_new=$(sha256sum third_party.txt)
if [ "${hash_old}" != "${hash_new}" ]; then
- echo "检测到package_list.txt有变化,更新软件包..."
- if [ "${NASTOOL_CN_UPDATE}" = "true" ]; then
- sed -i "s/dl-cdn.alpinelinux.org/${ALPINE_MIRROR}/g" /etc/apk/repositories
- apk update -f
- fi
- apk add --no-cache libffi-dev
- apk add --no-cache $(echo $(cat package_list.txt))
+ echo "检测到third_party.txt有变化,更新第三方组件..."
+ git submodule update --init --recursive
if [ $? -ne 0 ]; then
- echo "无法更新软件包,请更新镜像..."
+ echo "无法更新第三方组件,请更新镜像!"
+ exit 1
else
- echo "软件包安装成功..."
- sha256sum package_list.txt > /tmp/package_list.txt.sha256sum
+ echo "第三方组件安装成功"
+ sha256sum third_party.txt > /tmp/third_party.txt.sha256sum
fi
fi
else
diff --git a/initializer.py b/initializer.py
index b0ca78ee..678bc77e 100644
--- a/initializer.py
+++ b/initializer.py
@@ -214,6 +214,28 @@ def update_config():
except Exception as e:
ExceptionUtils.exception_traceback(e)
+ # 豆瓣配置转为插件
+ try:
+ douban = Config().get_config('douban')
+ if douban:
+ _enable = True if douban.get("users") and douban.get("interval") and douban.get("types") else False
+ PluginManager().save_plugin_config(pid="DoubanSync", conf={
+ "onlyonce": False,
+ "enable": _enable,
+ "interval": douban.get("interval"),
+ "auto_search": douban.get("auto_search"),
+ "auto_rss": douban.get("auto_rss"),
+ "cookie": douban.get("cookie"),
+ "users": douban.get("users"),
+ "days": douban.get("days"),
+ "types": douban.get("types")
+ })
+ # 删除旧配置
+ _config.pop("douban")
+ overwrite_cofig = True
+ except Exception as e:
+ ExceptionUtils.exception_traceback(e)
+
# 重写配置文件
if overwrite_cofig:
Config().save_config(_config)
diff --git a/package/builder/Dockerfile b/package/builder/Dockerfile
new file mode 100644
index 00000000..00418fbe
--- /dev/null
+++ b/package/builder/Dockerfile
@@ -0,0 +1,31 @@
+FROM python:3.10.10-bullseye AS Builder
+
+ARG branch
+
+ENV NASTOOL_CONFIG=/nas-tools/config/config.yaml
+ENV py_site_packages=/usr/local/lib/python3.10/site-packages
+
+RUN python -m pip install --upgrade pip setuptools
+RUN pip install wheel cython pyinstaller==5.7.0
+RUN git clone --depth=1 -b ${branch} https://github.com/NAStool/nas-tools --recurse-submodule /nas-tools
+WORKDIR /nas-tools
+RUN pip install -r requirements.txt
+RUN pip install pyparsing
+RUN cp ./package/rely/hook-cn2an.py ${py_site_packages}/PyInstaller/hooks/ && \
+ cp ./package/rely/hook-zhconv.py ${py_site_packages}/PyInstaller/hooks/ && \
+ cp ./package/rely/hook-iso639.py ${py_site_packages}/PyInstaller/hooks/ && \
+ cp ./third_party.txt ./package/ && \
+ mkdir -p ${py_site_packages}/setuptools/_vendor/pyparsing/diagram/ && \
+ cp ./package/rely/template.jinja2 ${py_site_packages}/setuptools/_vendor/pyparsing/diagram/ && \
+ cp -r ./web/. ${py_site_packages}/web/ && \
+ cp -r ./config/. ${py_site_packages}/config/ && \
+ cp -r ./db_scripts/. ${py_site_packages}/db_scripts/
+WORKDIR /nas-tools/package
+RUN pyinstaller nas-tools.spec
+RUN ls -al /nas-tools/package/dist/
+WORKDIR /rootfs
+RUN cp /nas-tools/package/dist/nas-tools .
+
+FROM scratch
+
+COPY --from=Builder /rootfs/nas-tools /nas-tools
\ No newline at end of file
diff --git a/package/builder/alpine.Dockerfile b/package/builder/alpine.Dockerfile
new file mode 100644
index 00000000..c30851ab
--- /dev/null
+++ b/package/builder/alpine.Dockerfile
@@ -0,0 +1,32 @@
+FROM python:3.10.10-alpine AS Builder
+
+ARG branch
+
+ENV NASTOOL_CONFIG=/nas-tools/config/config.yaml
+ENV py_site_packages=/usr/local/lib/python3.10/site-packages
+
+RUN apk add build-base git libxslt-dev libxml2-dev musl-dev gcc libffi-dev
+RUN pip install --upgrade pip setuptools
+RUN pip install wheel cython pyinstaller==5.7.0
+RUN git clone --depth=1 -b ${branch} https://github.com/NAStool/nas-tools --recurse-submodule /nas-tools
+WORKDIR /nas-tools
+RUN pip install -r requirements.txt
+RUN pip install pyparsing
+RUN cp ./package/rely/hook-cn2an.py ${py_site_packages}/PyInstaller/hooks/ && \
+ cp ./package/rely/hook-zhconv.py ${py_site_packages}/PyInstaller/hooks/ && \
+ cp ./package/rely/hook-iso639.py ${py_site_packages}/PyInstaller/hooks/ && \
+ cp ./third_party.txt ./package/ && \
+ mkdir -p ${py_site_packages}/setuptools/_vendor/pyparsing/diagram/ && \
+ cp ./package/rely/template.jinja2 ${py_site_packages}/setuptools/_vendor/pyparsing/diagram/ && \
+ cp -r ./web/. ${py_site_packages}/web/ && \
+ cp -r ./config/. ${py_site_packages}/config/ && \
+ cp -r ./db_scripts/. ${py_site_packages}/db_scripts/
+WORKDIR /nas-tools/package
+RUN pyinstaller nas-tools.spec
+RUN ls -al /nas-tools/package/dist/
+WORKDIR /rootfs
+RUN cp /nas-tools/package/dist/nas-tools .
+
+FROM scratch
+
+COPY --from=Builder /rootfs/nas-tools /nas-tools
\ No newline at end of file
diff --git a/package/trayicon.py b/package/trayicon.py
index 063fd9c9..d885e688 100644
--- a/package/trayicon.py
+++ b/package/trayicon.py
@@ -7,7 +7,7 @@
class Balloon(wx.adv.TaskBarIcon):
- ICON = os.path.dirname(__file__).replace("windows", "") + "nas-tools.ico"
+ ICON = os.path.dirname(__file__).replace("package", "") + "nas-tools.ico"
def __init__(self, homepage, log_path):
wx.adv.TaskBarIcon.__init__(self)
diff --git a/run.py b/run.py
index dcb619b6..e7b4df04 100644
--- a/run.py
+++ b/run.py
@@ -6,24 +6,21 @@
warnings.filterwarnings('ignore')
# 运行环境判断
-is_windows_exe = getattr(sys, 'frozen', False) and (os.name == "nt")
+is_executable = getattr(sys, 'frozen', False)
+is_windows_exe = is_executable and (os.name == "nt")
if is_windows_exe:
# 托盘相关库
import threading
from package.trayicon import TrayIcon, NullWriter
- # 初始化环境变量
- os.environ["NASTOOL_CONFIG"] = os.path.join(os.path.dirname(sys.executable),
- "config",
- "config.yaml").replace("\\", "/")
- os.environ["NASTOOL_LOG"] = os.path.join(os.path.dirname(sys.executable),
- "config",
- "logs").replace("\\", "/")
+if is_executable:
+ # 可执行文件初始化环境变量
+ config_path = os.path.join(os.path.dirname(sys.executable), "config").replace("\\", "/")
+ os.environ["NASTOOL_CONFIG"] = os.path.join(config_path, "config.yaml").replace("\\", "/")
+ os.environ["NASTOOL_LOG"] = os.path.join(config_path, "logs").replace("\\", "/")
try:
- config_dir = os.path.join(os.path.dirname(sys.executable),
- "config").replace("\\", "/")
- if not os.path.exists(config_dir):
- os.makedirs(config_dir)
+ if not os.path.exists(config_path):
+ os.makedirs(config_path)
except Exception as err:
print(str(err))
@@ -133,9 +130,9 @@ def traystart():
if len(os.popen("tasklist| findstr %s" % os.path.basename(sys.executable), 'r').read().splitlines()) <= 2:
p1 = threading.Thread(target=traystart, daemon=True)
p1.start()
- else:
- # 初始化浏览器驱动
- init_chrome()
+
+ # 初始化浏览器驱动
+ init_chrome()
# gunicorn 启动
App.run(**get_run_config(is_windows_exe))
diff --git a/third_party/plexapi b/third_party/plexapi
index 2f52b300..405d21b6 160000
--- a/third_party/plexapi
+++ b/third_party/plexapi
@@ -1 +1 @@
-Subproject commit 2f52b300f1d3306c072f1d5804a279b2baa65cae
+Subproject commit 405d21b6bc3bf34963aef0bf06f2dfab99b98ca3
diff --git a/version.py b/version.py
index c05a5bc0..1e47be50 100644
--- a/version.py
+++ b/version.py
@@ -1 +1 @@
-APP_VERSION = 'v3.1.2'
+APP_VERSION = 'v3.1.3'
diff --git a/web/action.py b/web/action.py
index 479367ef..1a1c7715 100644
--- a/web/action.py
+++ b/web/action.py
@@ -19,14 +19,13 @@
import log
from app.brushtask import BrushTask
from app.conf import SystemConfig, ModuleConf
-from app.doubansync import DoubanSync
from app.downloader import Downloader
from app.filetransfer import FileTransfer
from app.filter import Filter
from app.helper import DbHelper, ProgressHelper, ThreadHelper, \
MetaHelper, DisplayHelper, WordsHelper, CookieCloudHelper, IndexerHelper
from app.indexer import Indexer
-from app.media import Category, Media, Bangumi, DouBan
+from app.media import Category, Media, Bangumi, DouBan, Scraper
from app.media.meta import MetaInfo, MetaBase
from app.mediaserver import MediaServer
from app.message import Message, MessageCenter
@@ -196,7 +195,7 @@ def __init__(self):
"delete_torrent_remove_task": self.__delete_torrent_remove_task,
"get_remove_torrents": self.__get_remove_torrents,
"auto_remove_torrents": self.__auto_remove_torrents,
- "get_douban_history": self.get_douban_history,
+ "douban_sync": self.douban_sync,
"delete_douban_history": self.__delete_douban_history,
"list_brushtask_torrents": self.__list_brushtask_torrents,
"set_system_config": self.__set_system_config,
@@ -225,6 +224,12 @@ def __init__(self):
"get_movie_rss_items": self.get_movie_rss_items,
"get_tv_rss_items": self.get_tv_rss_items,
"get_ical_events": self.get_ical_events,
+ "install_plugin": self.install_plugin,
+ "uninstall_plugin": self.uninstall_plugin,
+ "get_plugin_apps": self.get_plugin_apps,
+ "get_plugin_page": self.get_plugin_page,
+ "get_plugin_state": self.get_plugin_state,
+ "get_plugins_conf": self.get_plugins_conf
}
def action(self, cmd, data=None):
@@ -332,7 +337,7 @@ def handle_message_job(msg, in_from=SearchType.OT, user_id=None, user_name=None)
"/pts": {"func": SiteSignin().signin, "desp": "站点签到"},
"/rst": {"func": Sync().transfer_all_sync, "desp": "目录同步"},
"/rss": {"func": Rss().rssdownload, "desp": "RSS订阅"},
- "/db": {"func": DoubanSync().sync, "desp": "豆瓣同步"},
+ "/db": {"func": WebAction().douban_sync, "desp": "豆瓣同步"},
"/ssa": {"func": Subscribe().subscribe_search_all, "desp": "订阅搜索"},
"/tbl": {"func": WebAction().truncate_blacklist, "desp": "清理转移缓存"},
"/trh": {"func": WebAction().truncate_rsshistory, "desp": "清理RSS缓存"},
@@ -489,7 +494,7 @@ def __sch(data):
"ptsignin": SiteSignin().signin,
"sync": Sync().transfer_all_sync,
"rssdownload": Rss().rssdownload,
- "douban": DoubanSync().sync,
+ "douban": WebAction().douban_sync,
"subscribe_search_all": Subscribe().subscribe_search_all,
}
sch_item = data.get("item")
@@ -1428,6 +1433,8 @@ def __add_rss_media(self, data):
filter_pix = data.get("filter_pix")
filter_team = data.get("filter_team")
filter_rule = data.get("filter_rule")
+ filter_include = data.get("filter_include")
+ filter_exclude = data.get("filter_exclude")
save_path = data.get("save_path")
download_setting = data.get("download_setting")
total_ep = data.get("total_ep")
@@ -1457,6 +1464,8 @@ def __add_rss_media(self, data):
filter_pix=filter_pix,
filter_team=filter_team,
filter_rule=filter_rule,
+ filter_include=filter_include,
+ filter_exclude=filter_exclude,
save_path=save_path,
download_setting=download_setting,
rssid=rssid)
@@ -1478,6 +1487,8 @@ def __add_rss_media(self, data):
filter_pix=filter_pix,
filter_team=filter_team,
filter_rule=filter_rule,
+ filter_include=filter_include,
+ filter_exclude=filter_exclude,
save_path=save_path,
download_setting=download_setting,
total_ep=total_ep,
@@ -4091,11 +4102,10 @@ def __media_path_scrap(data):
"""
刮削媒体文件夹或文件
"""
- # 触发字幕下载事件
- EventManager().send_event(EventType.MediaScrapStart, {
- "path": data.get("path"),
- "force": True
- })
+ path = data.get("path")
+ if not path:
+ return {"code": -1, "msg": "请指定刮削路径"}
+ ThreadHelper().start_thread(Scraper().folder_scraper, (path, None, 'force_all'))
return {"code": 0, "msg": "刮削任务已提交,正在后台运行。"}
@staticmethod
@@ -4373,13 +4383,6 @@ def __get_site_favicon(data):
sitename = data.get("name")
return {"code": 0, "icon": Sites().get_site_favicon(site_name=sitename)}
- def get_douban_history(self, data=None):
- """
- 查询豆瓣同步历史
- """
- results = self.dbhelper.get_douban_history()
- return {"code": 0, "result": [item.as_dict() for item in results]}
-
def __delete_douban_history(self, data):
"""
删除豆瓣同步历史
@@ -4947,3 +4950,81 @@ def get_ical_events(self, data=None):
Events.append(info)
return {"code": 0, "result": Events}
+
+ @staticmethod
+ def install_plugin(data):
+ """
+ 安装插件
+ """
+ module_id = data.get("id")
+ if not module_id:
+ return {"code": -1, "msg": "参数错误"}
+ # 用户已安装插件列表
+ user_plugins = SystemConfig().get_system_config(SystemConfigKey.UserInstalledPlugins) or []
+ if module_id not in user_plugins:
+ user_plugins.append(module_id)
+ # 保存配置
+ SystemConfig().set_system_config(SystemConfigKey.UserInstalledPlugins, user_plugins)
+ # 重新加载插件
+ PluginManager().init_config()
+ return {"code": 0, "msg": "插件安装成功"}
+
+ @staticmethod
+ def uninstall_plugin(data):
+ """
+ 卸载插件
+ """
+ module_id = data.get("id")
+ if not module_id:
+ return {"code": -1, "msg": "参数错误"}
+ # 用户已安装插件列表
+ user_plugins = SystemConfig().get_system_config(SystemConfigKey.UserInstalledPlugins) or []
+ if module_id in user_plugins:
+ user_plugins.remove(module_id)
+ # 保存配置
+ SystemConfig().set_system_config(SystemConfigKey.UserInstalledPlugins, user_plugins)
+ # 重新加载插件
+ PluginManager().init_config()
+ return {"code": 0, "msg": "插件卸载功"}
+
+ @staticmethod
+ def get_plugin_apps(data=None):
+ """
+ 获取插件列表
+ """
+ return {"code": 0, "result": PluginManager().get_plugin_apps(current_user.level)}
+
+ @staticmethod
+ def get_plugin_page(data):
+ """
+ 查询插件的额外数据
+ """
+ plugin_id = data.get("id")
+ if not plugin_id:
+ return {"code": 1, "msg": "参数错误"}
+ title, content = PluginManager().get_plugin_page(pid=plugin_id)
+ return {"code": 0, "title": title, "content": content}
+
+ @staticmethod
+ def get_plugin_state(data):
+ """
+ 获取插件状态
+ """
+ plugin_id = data.get("id")
+ if not plugin_id:
+ return {"code": 1, "msg": "参数错误"}
+ state = PluginManager().get_plugin_state(plugin_id)
+ return {"code": 0, "state": state}
+
+ @staticmethod
+ def get_plugins_conf(data=None):
+ Plugins = PluginManager().get_plugins_conf(current_user.level)
+ return {"code": 0, "result": Plugins}
+
+ @staticmethod
+ def douban_sync(data=None):
+ """
+ 启动豆瓣同步
+ """
+ # 触发事件
+ EventManager().send_event(EventType.DoubanSync, {})
diff --git a/web/apiv1.py b/web/apiv1.py
index 08f11f2a..bc235c13 100644
--- a/web/apiv1.py
+++ b/web/apiv1.py
@@ -43,7 +43,7 @@
filterrule = Apiv1.namespace('filterrule', description='过滤规则')
words = Apiv1.namespace('words', description='识别词')
message = Apiv1.namespace('message', description='消息通知')
-douban = Apiv1.namespace('douban', description='豆瓣')
+plugin = Apiv1.namespace('plugin', description='插件')
class ApiResource(Resource):
@@ -220,7 +220,7 @@ def post(self):
class ServiceRun(ClientResource):
parser = reqparse.RequestParser()
parser.add_argument('item', type=str,
- help='服务名称(autoremovetorrents、pttransfer、ptsignin、sync、rssdownload、douban、subscribe_search_all)',
+ help='服务名称(autoremovetorrents、pttransfer、ptsignin、sync、rssdownload、subscribe_search_all)',
location='form',
required=True)
@@ -2271,36 +2271,62 @@ def post(self):
return WebAction().api_action(cmd='update_torrent_remove_task', data=self.parser.parse_args())
-@douban.route('/history/list')
-class DoubanHistoryList(ClientResource):
+@plugin.route('/install')
+class PluginInstall(ClientResource):
+ parser = reqparse.RequestParser()
+ parser.add_argument('id', type=int, help='插件ID', location='form', required=True)
- @staticmethod
- def post():
+ @plugin.doc(parser=parser)
+ def post(self):
"""
- 查询豆瓣同步历史记录
+ 安装插件
"""
- return WebAction().api_action(cmd='get_douban_history')
+ return WebAction().api_action(cmd='install_plugin', data=self.parser.parse_args())
-@douban.route('/history/delete')
-class DoubanHistoryDelete(ClientResource):
+@plugin.route('/uninstall')
+class PluginUninstall(ClientResource):
parser = reqparse.RequestParser()
- parser.add_argument('id', type=int, help='ID', location='form', required=True)
+ parser.add_argument('id', type=int, help='插件ID', location='form', required=True)
- @douban.doc(parser=parser)
+ @plugin.doc(parser=parser)
def post(self):
"""
- 删除豆瓣同步历史记录
+ 卸载插件
"""
- return WebAction().api_action(cmd='delete_douban_history', data=self.parser.parse_args())
+ return WebAction().api_action(cmd='uninstall_plugin', data=self.parser.parse_args())
-@douban.route('/run')
-class DoubanRun(ClientResource):
+@plugin.route('/apps')
+class PluginApps(ClientResource):
@staticmethod
+ @plugin.doc()
def post():
"""
- 立即同步豆瓣数据
+ 获取插件市场所有插件
"""
- # 返回站点信息
- return WebAction().api_action(cmd='sch', data={"item": "douban"})
+ return WebAction().api_action(cmd='get_plugin_apps')
+
+
+@plugin.route('/list')
+class PluginList(ClientResource):
+ @staticmethod
+ @plugin.doc()
+ def post():
+ """
+ 获取已安装插件
+ """
+ return WebAction().api_action(cmd='get_plugins_conf')
+
+
+@plugin.route('/status')
+class PluginStatus(ClientResource):
+ parser = reqparse.RequestParser()
+ parser.add_argument('id', type=int, help='插件ID', location='form', required=True)
+
+ @plugin.doc(parser=parser)
+ def post(self):
+ """
+ 获取插件运行状态
+ """
+ return WebAction().api_action(cmd='get_plugin_state', data=self.parser.parse_args())
diff --git a/web/backend/wallpaper.py b/web/backend/wallpaper.py
index 4c946c84..c7ee62e3 100644
--- a/web/backend/wallpaper.py
+++ b/web/backend/wallpaper.py
@@ -7,31 +7,49 @@
from config import Config
-@lru_cache(maxsize=1)
-def get_login_wallpaper(today=datetime.datetime.strftime(datetime.datetime.now(), '%Y%m%d')):
+def get_login_wallpaper(time_now=datetime.datetime.now()):
"""
获取Base64编码的壁纸图片
"""
wallpaper = Config().get_config('app').get('wallpaper')
tmdbkey = Config().get_config('app').get('rmt_tmdbkey')
if (not wallpaper or wallpaper == "themoviedb") and tmdbkey:
- img_url, img_title, img_link = __get_themoviedb_wallpaper()
+ # 每小时更新
+ curr_time = datetime.datetime.strftime(time_now, '%Y%m%d%H')
+ img_url, img_title, img_link = __get_themoviedb_wallpaper(curr_time)
else:
+ # 每天更新
+ today = datetime.datetime.strftime(time_now, '%Y%m%d')
img_url, img_title, img_link = __get_bing_wallpaper(today)
+ img_enc = __get_image_b64(img_url)
+ if img_enc:
+ return img_enc, img_title, img_link
+ return "", "", ""
+
+
+@lru_cache(maxsize=1)
+def __get_image_b64(img_url, cache_tag=None):
+ """
+ 根据图片URL缓存
+ 如果遇到同一地址返回随机图片的情况, 需要视情况传递cache_tag参数
+ """
if img_url:
res = RequestUtils().get_res(img_url)
if res and res.status_code == 200:
- return base64.b64encode(res.content).decode(), img_title, img_link
- return "", "", ""
+ return base64.b64encode(res.content).decode()
+ return ""
-def __get_themoviedb_wallpaper():
+@lru_cache(maxsize=1)
+def __get_themoviedb_wallpaper(cache_tag):
"""
获取TheMovieDb的随机背景图
+ cache_tag 缓存标记, 相同时会命中缓存
"""
return Media().get_random_discover_backdrop()
+@lru_cache(maxsize=1)
def __get_bing_wallpaper(today):
"""
获取Bing每日壁纸
diff --git a/web/backend/web_utils.py b/web/backend/web_utils.py
index a67a0f69..89458ec0 100644
--- a/web/backend/web_utils.py
+++ b/web/backend/web_utils.py
@@ -82,6 +82,9 @@ def get_mediainfo_from_id(mtype, mediaid):
title = info.get("title")
original_title = info.get("original_title")
year = info.get("year")
+ # 支持自动识别类型
+ if not mtype:
+ mtype = MediaType.TV if info.get("episodes_count") else MediaType.MOVIE
if original_title:
media_info = Media().get_media_info(title=f"{original_title} {year}",
mtype=mtype,
diff --git a/web/main.py b/web/main.py
index ae030f63..94d8451b 100644
--- a/web/main.py
+++ b/web/main.py
@@ -33,7 +33,7 @@
from app.media.meta import MetaInfo
from app.mediaserver import MediaServer
from app.message import Message
-from app.plugins import EventManager, PluginManager
+from app.plugins import EventManager
from app.rsschecker import RssChecker
from app.sites import Sites, SiteUserInfo
from app.subscribe import Subscribe
@@ -127,7 +127,7 @@ def redirect_to_navigation():
跳转到导航页面
"""
if GoPage and GoPage != 'web':
- return redirect('/web?next=' + GoPage)
+ return redirect('/web#' + GoPage)
else:
return redirect('/web')
@@ -718,18 +718,6 @@ def service():
else:
Services.pop('sync')
- # 豆瓣同步
- if "douban" in Services:
- interval = Config().get_config('douban').get('interval')
- if interval:
- interval = "%s 小时" % interval
- Services['douban'].update({
- 'state': 'ON',
- 'time': interval,
- })
- else:
- Services.pop('douban')
-
return render_template("service.html",
Count=len(Services),
RuleGroups=RuleGroups,
@@ -871,17 +859,6 @@ def directorysync():
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
@@ -1020,9 +997,10 @@ def rss_parser():
@App.route('/plugin', methods=['POST', 'GET'])
@login_required
def plugin():
- Plugins = PluginManager().get_plugins_conf(current_user.level)
+ Plugins = WebAction().get_plugins_conf().get("result")
return render_template("setting/plugin.html",
- Plugins=Plugins)
+ Plugins=Plugins,
+ Count=len(Plugins))
# 事件响应
@@ -1226,13 +1204,18 @@ def jellyfin_webhook():
# Emby Webhook
-@App.route('/emby', methods=['POST'])
+@App.route('/emby', methods=['GET', 'POST'])
@require_auth(force=False)
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', {}))
+ if request.method == 'POST':
+ log.debug("Emby Webhook data: %s" % str(request.form.get('data', {})))
+ request_json = json.loads(request.form.get('data', {}))
+ else:
+ log.debug("Emby Webhook data: %s" % str(dict(request.args)))
+ request_json = dict(request.args)
log.debug("收到Emby Webhook报文:%s" % str(request_json))
# 发送消息
ThreadHelper().start_thread(MediaServer().webhook_message_handler,
diff --git a/web/static/components/cmd-dialog/cmd-action.js b/web/static/components/cmd-dialog/cmd-action.js
new file mode 100644
index 00000000..047e530b
--- /dev/null
+++ b/web/static/components/cmd-dialog/cmd-action.js
@@ -0,0 +1,108 @@
+import {classMap, html, LitElement, nothing, repeat, unsafeCSS, unsafeHTML} from "../utility/lit-core.min.js";
+import style from './style.js';
+
+export class CmdAction extends LitElement {
+
+ static styles = unsafeCSS(style);
+
+ static properties = {
+ theme: {attribute: "theme"},
+ action: {attribute: "action"},
+ selected: {attribute: "selected "},
+ };
+
+ constructor() {
+ super();
+ this.selected = false
+ this.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ this.addEventListener('click', this.click);
+ }
+
+ /**
+ * Scroll to show element
+ */
+ ensureInView() {
+ requestAnimationFrame(() => {
+ this.scrollIntoView({block: 'nearest'});
+ });
+ }
+
+ /**
+ * Click event
+ */
+ click() {
+ this.dispatchEvent(
+ new CustomEvent('actionSelected', {
+ detail: this.action,
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ /**
+ * Updated
+ * @param changedProperties
+ */
+ updated(changedProperties) {
+ if (changedProperties.has('selected') && this.selected) {
+ this.ensureInView();
+ }
+ }
+
+ render() {
+ const classes = {
+ selected: this.selected,
+ dark: this.theme === 'dark',
+ };
+
+ return html`
+
+ ${this.img}
+
+ ${this.action.title}
+ ${this.description}
+
+ ${this.hotkeys}
+
+ `;
+ }
+
+ /**
+ * Get hotkeys
+ * @private
+ */
+ get hotkeys() {
+ if (this.action?.hotkey) {
+ const hotkeys = this.action.hotkey
+ .replace('cmd', '⌘')
+ .replace('shift', '⇧')
+ .replace('alt', '⌥')
+ .replace('ctrl', '⌃')
+ .toUpperCase()
+ .split('+');
+ return hotkeys.length > 0 ? html`${repeat(hotkeys, hotkey => html`${hotkey} `)} ` : '';
+ }
+
+ return nothing;
+ }
+
+ /**
+ * Get description
+ * @private
+ */
+ get description() {
+ return this.action.description ? html`${this.action.description} ` : nothing;
+ }
+
+ /**
+ * Get icon
+ * @private
+ */
+ get img() {
+ return this.action.img ? html`${unsafeHTML(this.action.img)} ` : nothing;
+ }
+}
+
+window.customElements.define("cmd-action", CmdAction);
\ No newline at end of file
diff --git a/web/static/components/cmd-dialog/index.js b/web/static/components/cmd-dialog/index.js
new file mode 100644
index 00000000..6de9af44
--- /dev/null
+++ b/web/static/components/cmd-dialog/index.js
@@ -0,0 +1,289 @@
+import {html, LitElement, unsafeCSS, live, repeat, unsafeHTML} from "../utility/lit-core.min.js";
+import Fuse from '../../js/modules/fuse.esm.min.js';
+import hotkeys from '../../js/modules/hotkeys.esm.js';
+
+import './cmd-action.js'; // eslint-disable-line import/no-unassigned-import
+import style from './style.js';
+
+
+export class CmdDialog extends LitElement {
+ static styles = unsafeCSS(style);
+
+ static properties = {
+ theme: {attribute: "theme"},
+ placeholder: {attribute: "placeholder"},
+ note: {attribute: "note"},
+ hotkey: {attribute: "hotkey"},
+ actions: {attribute: "actions"},
+ _search: {state: true},
+ _selected: {state: true},
+ _results: {state: true},
+ };
+
+ constructor() {
+ super();
+ this.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ this.placeholder = '搜索...';
+ this.note = '';
+ this.hotkey = 'cmd+k,ctrl+k';
+ this.actions = [];
+ this._search = '';
+ this._selected = null;
+ this._results = [];
+ this.fuse = null;
+ }
+
+ /**
+ * Open the dialog.
+ */
+ open() {
+ if (!this.dialog.open) {
+ this.dialog.showModal();
+ }
+ }
+
+ /**
+ * Close the dialog.
+ */
+ close() {
+ this.input.value = '';
+ this.dialog.close();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+
+ // Open dialog
+ hotkeys(this.hotkey, event => {
+ this.open();
+ event.preventDefault();
+ });
+
+ // Select next
+ hotkeys('down,tab', event => {
+ if (this.dialog.open) {
+ event.preventDefault();
+ this._selected = this._selectedIndex >= this._results.length - 1 ? this._results[0] : this._results[this._selectedIndex + 1];
+ }
+ });
+
+ // Select previous
+ hotkeys('up,shift+tab', event => {
+ if (this.dialog.open) {
+ event.preventDefault();
+ this._selected = this._selectedIndex === 0 ? this._results[this._results.length - 1] : this._results[this._selectedIndex - 1];
+ }
+ });
+
+ // Trigger action
+ hotkeys('enter', event => {
+ if (this.dialog.open) {
+ event.preventDefault();
+ this._triggerAction(this._results[this._selectedIndex]);
+ }
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ // Unregister hotkeys
+ hotkeys.unbind(this.hotkey);
+ hotkeys.unbind('down,tab');
+ hotkeys.unbind('up,shift+tab');
+ hotkeys.unbind('enter');
+ }
+
+ update(changedProperties) {
+ if (changedProperties.has('actions')) {
+ // Register action hotkeys
+ for (const action of this.actions.filter(item => Boolean(item.hotkey))) {
+ hotkeys(action.hotkey ?? '', event => {
+ event.preventDefault();
+ this._triggerAction(action);
+ });
+ }
+
+ // Setup fuse search
+ this.fuse = new Fuse(this.actions,
+ {
+ keys: [
+ {name: 'title', weight: 2},
+ {name: 'tags', weight: 1},
+ {name: 'url', weight: 1},
+ ],
+ });
+ }
+
+ super.update(changedProperties);
+ }
+
+ render() {
+ // Search for matches
+ const results = this.fuse?.search(this._search);
+ if (results) {
+ this._results = results.map(item => item.item);
+ }
+
+ if (this._search.length > 0) {
+ const results = this.fuse?.search(this._search);
+ if (results) {
+ this._results = results.map(item => item.item);
+ }
+ } else {
+ this._results = this.actions;
+ }
+
+ // Select first result
+ if (this._results.length > 0 && this._selectedIndex === -1) {
+ this._selected = this._results[0];
+ }
+
+ // Nothing was found
+ if (this._results.length === 0) {
+ this._selected = undefined;
+ }
+
+ const actionList = html`
+ ${repeat(
+ this._results,
+ action =>
+ html`
+ {
+ this._actionFocused(action, event);
+ }}
+ @actionSelected=${(event) => {
+ this._triggerAction(event.detail);
+ }}
+ >
+ `)}
+
+ `;
+
+ return html`
+ {
+ if (event.target === this.dialog) {
+ this.close();
+ } // Close on backdrop click
+ }}">
+
+
+
+ ${actionList}
+
+
+ ⏎ 确定 ↑ ↓ 选择 esc 关闭
+ ${unsafeHTML(this.note ?? `${this._results.length} options `)}
+
+
+ `;
+ }
+
+ /**
+ * Render the results on input.
+ * @param event
+ * @private
+ */
+ async _onInput(event) {
+ const input = event.target;
+ this._search = input.value;
+ await this.updateComplete;
+
+ this.dispatchEvent(
+ new CustomEvent(
+ 'change', {
+ detail: {
+ search: input.value,
+ actions: this._results,
+ },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ /**
+ * Handle focus on action.
+ * @param action
+ * @param $event
+ * @private
+ */
+ _actionFocused(action, $event) {
+ this._selected = action;
+ ($event.target).ensureInView();
+ }
+
+ /**
+ * Trigger the action.
+ * @param action
+ * @private
+ */
+ _triggerAction(action) {
+ this._selected = action;
+
+ // Fire selected event even when action is empty/not selected,
+ // so possible handle api search for example
+ this.dispatchEvent(
+ new CustomEvent('selected', {
+ detail: {search: this._search, action},
+ bubbles: true,
+ composed: true,
+ }),
+ );
+
+ // Trigger action
+ if (action) {
+ if (action.onAction) {
+ const result = action.onAction(action);
+ if (!result?.keepOpen) {
+ this.close();
+ }
+ } else if (action.url) {
+ window.open(action.url, action.target ?? '_self');
+ this.close();
+ }
+ }
+ }
+
+ /**
+ * Return the index of the selected action.
+ * @private
+ */
+ get _selectedIndex() {
+ return this._selected ? this._results.indexOf(this._selected) : -1;
+ }
+
+ /**
+ * Return the dialog element.
+ */
+ get dialog() {
+ return this.shadowRoot?.querySelector('dialog');
+ }
+
+ /**
+ * Return the input element.
+ */
+ get input() {
+ return this.shadowRoot?.querySelector('input');
+ }
+}
+
+
+window.customElements.define("cmd-dialog", CmdDialog);
\ No newline at end of file
diff --git a/web/static/components/cmd-dialog/style.js b/web/static/components/cmd-dialog/style.js
new file mode 100644
index 00000000..0fd1298c
--- /dev/null
+++ b/web/static/components/cmd-dialog/style.js
@@ -0,0 +1,2 @@
+const style = `*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[type=text],[type=email],[type=url],[type=password],[type=number],[type=date],[type=datetime-local],[type=month],[type=search],[type=tel],[type=time],[type=week],[multiple],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow: 0 0 #0000}[type=text]:focus,[type=email]:focus,[type=url]:focus,[type=password]:focus,[type=number]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=month]:focus,[type=search]:focus,[type=tel]:focus,[type=time]:focus,[type=week]:focus,[multiple]:focus,textarea:focus,select:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset: var(--tw-empty, );--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: #2563eb;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple]{background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow: 0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset: var(--tw-empty, );--tw-ring-offset-width: 2px;--tw-ring-offset-color: #fff;--tw-ring-color: #2563eb;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:center;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}[type=radio]:checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}[type=checkbox]:checked:hover,[type=checkbox]:checked:focus,[type=radio]:checked:hover,[type=radio]:checked:focus{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:center;background-repeat:no-repeat}[type=checkbox]:indeterminate:hover,[type=checkbox]:indeterminate:focus{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}dialog::backdrop{--tw-backdrop-brightness: brightness(.75);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}dialog{padding:0;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:1rem;line-height:1.5rem;line-height:1.5;letter-spacing:0em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow);--tw-shadow-color: rgb(23 23 23 / .1);--tw-shadow: var(--tw-shadow-colored);margin-left:auto;margin-right:auto;width:100%;max-width:42rem;overflow:hidden;border-radius:.75rem;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;border-width:1px;--tw-border-opacity: 1;border-color:rgb(229 229 229 / var(--tw-border-opacity))}dialog>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse));border-color:#e5e5e5cc}dialog{--tw-bg-opacity: 1;background-color:rgb(250 250 250 / var(--tw-bg-opacity));position:absolute;margin-left:auto;margin-right:auto;margin-top:20vh}dialog.dark{--tw-bg-opacity: 1;background-color:rgb(23 23 23 / var(--tw-bg-opacity));--tw-text-opacity: 1;color:rgb(212 212 212 / var(--tw-text-opacity));--tw-border-opacity: 1;border-color:rgb(38 38 38 / var(--tw-border-opacity))}dialog.dark>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(38 38 38 / var(--tw-divide-opacity))}dialog.dark{--tw-shadow-color: rgb(115 115 115 / .1);--tw-shadow: var(--tw-shadow-colored)}dialog input[type=text]{width:100%;border-width:0px;background-color:transparent;padding:.75rem 1rem;font-size:1.5rem;line-height:2rem;font-weight:400}dialog input[type=text]::-moz-placeholder{--tw-text-opacity: 1;color:rgb(115 115 115 / var(--tw-text-opacity))}dialog input[type=text]::placeholder{--tw-text-opacity: 1;color:rgb(115 115 115 / var(--tw-text-opacity))}dialog input[type=text]:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.dark dialog input[type=text]{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}[name=footer]{display:flex;justify-content:space-between;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity: 1;color:rgb(64 64 64 / var(--tw-text-opacity))}.dark [name=footer]{--tw-text-opacity: 1;color:rgb(163 163 163 / var(--tw-text-opacity))}ul{max-height:20rem;overflow-y:auto}ul:first-child{margin-top:.5rem}ul:last-child{margin-bottom:.5rem}li{display:flex;cursor:default;-webkit-user-select:none;-moz-user-select:none;user-select:none;align-items:center;gap:1rem;padding:.875rem 1rem;margin-left:.5rem;margin-right:.5rem;cursor:pointer}li.selected{outline:2px solid transparent;outline-offset:2px;--tw-bg-opacity: 1;background-color:rgb(229 229 229 / var(--tw-bg-opacity));border-radius:.375rem}li.selected.dark{--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity))}li strong{flex-grow:1;font-weight:500}li strong small{display:block;font-size:.875rem;line-height:1.25rem;font-weight:400;--tw-text-opacity: 1;color:rgb(115 115 115 / var(--tw-text-opacity))}li svg,li img{height:1.25rem;width:1.25rem}kbd{white-space:nowrap;text-align:center;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-weight:600;border-radius:.125rem;background-color:#40404033}.dark kbd{--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity))}kbd{margin-left:.125rem;margin-right:.125rem;display:inline-block;padding-left:.25rem;padding-right:.25rem;line-height:1.5;min-width:1.3rem}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.static{position:static}.block{display:block}.inline{display:inline}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}`;
+export default style
diff --git a/web/static/components/custom/chips/index.html b/web/static/components/custom/chips/index.html
index 1d99287a..1b714374 100644
--- a/web/static/components/custom/chips/index.html
+++ b/web/static/components/custom/chips/index.html
@@ -44,7 +44,7 @@
data_dict.title = (!data_dict.tmdbid) ? "未匹配TMDB" : data_dict.title;
break;
}
- console.log("正在新增chips: ", key, data_dict[key], href);
+ // console.log("正在新增chips: ", key, data_dict[key], href);
this.render(key, data_dict[key], href, flag);
this.style.display = "block";
}
diff --git a/web/static/components/layout/navbar/index.js b/web/static/components/layout/navbar/index.js
index ab81907e..51a92018 100644
--- a/web/static/components/layout/navbar/index.js
+++ b/web/static/components/layout/navbar/index.js
@@ -24,19 +24,23 @@ export class LayoutNavbar extends CustomElement {
this._update_appversion = "";
this._update_url = "https://github.com/linyuan0213/nas-tools";
this._is_update = false;
+ this._is_expand = false;
this.classList.add("navbar","navbar-vertical","navbar-expand-lg","lit-navbar-fixed","lit-navbar","lit-navbar-hide-scrollbar");
-
// 加载菜单
Golbal.get_cache_or_ajax("get_user_menus", "usermenus", {},
(ret) => {
if (ret.code === 0) {
this.navbar_list = ret.menus;
- this._init_page();
}
- }
+ },false
);
}
+ firstUpdated() {
+ // 初始化页面
+ this._init_page();
+ }
+
_init_page() {
// 加载页面
if (this.layout_gopage) {
@@ -55,7 +59,9 @@ export class LayoutNavbar extends CustomElement {
navmenu(page);
}
// 默认展开探索
- setTimeout(() => { this.show_collapse("ranking") }, 200);
+ if (!this._is_expand) {
+ this.show_collapse("ranking");
+ }
}
// 删除logo动画 加点延迟切换体验好
@@ -115,11 +121,12 @@ export class LayoutNavbar extends CustomElement {
}
show_collapse(page) {
- for (const item of this.querySelectorAll("[id^='lit-navbar-collapse-']")) {
+ for (const item of this.querySelectorAll("div[id^='lit-navbar-collapse-']")) {
for (const a of item.querySelectorAll("a")) {
if (page === a.getAttribute("data-lit-page")) {
item.classList.add("show");
this.querySelectorAll(`button[data-bs-target='#${item.id}']`)[0].classList.remove("collapsed");
+ this._is_expand = true;
return;
}
}
diff --git a/web/static/components/lit-index.js b/web/static/components/lit-index.js
index 0d16ca30..569e3311 100644
--- a/web/static/components/lit-index.js
+++ b/web/static/components/lit-index.js
@@ -4,3 +4,4 @@ export * from "./page/index.js";
export * from "./layout/index.js";
export * from "./plugin/index.js";
export * from "./accordion/index.js";
+export * from "./cmd-dialog/index.js";
diff --git a/web/static/components/plugin/modal/index.js b/web/static/components/plugin/modal/index.js
index 86c2d9c8..39de8863 100644
--- a/web/static/components/plugin/modal/index.js
+++ b/web/static/components/plugin/modal/index.js
@@ -8,6 +8,7 @@ export class PluginModal extends CustomElement {
config: {attribute: "plugin-config", type: Object},
fields: {attribute: "plugin-fields", type: Array},
prefix: {attribute: "plugin-prefix"},
+ page: {attribute: "plugin-page"},
};
constructor() {
@@ -240,6 +241,9 @@ export class PluginModal extends CustomElement {
${this.__render_fields()}
+
+
+
+
+
+
{{ SVG.alert_triangle('icon-lg') }}
+
失败
+
错误信息
+
+
+
+
+
+
+
{{ SVG.alert_triangle('icon-lg') }}
-
失败
-
失败!
+
注意
+
警告信息
diff --git a/web/templates/rss/tv_rss.html b/web/templates/rss/tv_rss.html
index 9960ba96..6696912b 100644
--- a/web/templates/rss/tv_rss.html
+++ b/web/templates/rss/tv_rss.html
@@ -90,10 +90,10 @@
{% else %} class=“text-muted"{% endif %}>
{% if Attr.over_edition %}
- 洗版
+ 洗版
{% endif %}
{% if Attr.download_setting|string in DownloadSettings %}
-
+
{{ DownloadSettings[Attr.download_setting|string].name }}
{% endif %}
@@ -108,7 +108,7 @@
{% endif %}
{% if Attr.filter_team %}
-
+
{{ Attr.filter_team }}
{% endif %}
@@ -117,6 +117,16 @@
{{ RuleGroups[Attr.filter_rule|string] }}
{% endif %}
+ {% if Attr.filter_include %}
+
+ {{ Attr.filter_include }}
+
+ {% endif %}
+ {% if Attr.filter_exclude %}
+
+ {{ Attr.filter_exclude }}
+
+ {% endif %}
diff --git a/web/templates/search.html b/web/templates/search.html
index fdde1acd..7886b03d 100644
--- a/web/templates/search.html
+++ b/web/templates/search.html
@@ -34,7 +34,7 @@
{% endif %}
-
+
{% if Item.poster %}
实验室
Config.laboratory and Config.laboratory.search_keyword %}checked{% endif %}>
辅助识别 ?
+ data-bs-toggle="tooltip">?
+
+
+
+
+
@@ -640,7 +653,8 @@
实验室
Config.laboratory and Config.laboratory.chatgpt_enable %}checked{% endif %}>
ChatGPT增强识别 ?
+ data-bs-toggle="tooltip">?
+
@@ -651,7 +665,8 @@ 实验室
Config.laboratory and Config.laboratory.tmdb_cache_expire %}checked{% endif %}>
TMDB缓存过期策略 ?
+ data-bs-toggle="tooltip">?
+
@@ -660,8 +675,8 @@
实验室
- 使用豆瓣名称联想 默认搜索豆瓣资源 ?
@@ -673,7 +688,8 @@
实验室
Config.laboratory and Config.laboratory.search_en_title %}checked{% endif %}>
搜索优先使用英文名 ?
+ data-bs-toggle="tooltip">?
+
@@ -684,7 +700,8 @@ 实验室
and Config.laboratory.tmdb_proxy %}checked{% endif %}>
使用TMDB代理服务 ?
+ data-bs-toggle="tooltip">?
+
diff --git a/web/templates/setting/douban.html b/web/templates/setting/douban.html
deleted file mode 100644
index 6cee062b..00000000
--- a/web/templates/setting/douban.html
+++ /dev/null
@@ -1,218 +0,0 @@
-{% import 'macro/svg.html' as SVG %}
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/web/templates/setting/plugin.html b/web/templates/setting/plugin.html
index 59f62e49..b3760b95 100644
--- a/web/templates/setting/plugin.html
+++ b/web/templates/setting/plugin.html
@@ -1,4 +1,6 @@
+{% import 'macro/svg.html' as SVG %}
{% import 'macro/form.html' as FORM %}
+{% import 'macro/oops.html' as OOPS %}
-
-
-
+{% else %}
+ {{ OOPS.empty('没有插件', '没有安装任何插件,请前往插件市场选择安装。') }}
+{% endif %}
{% for Id, Plugin in Plugins.items() %}
plugin-config='{{ Plugin.config|tojson|safe }}'
plugin-fields='{{ Plugin.fields|tojson|safe }}'
plugin-prefix="{{ Plugin.prefix }}"
+ plugin-page="{{ Plugin.page }}"
>
{% endfor %}
+
+