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 %} + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
标题类型状态添加时间
+ + +
{{ 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 '' }} + + +
没有数据
+
+ + """ + 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` + + `; + + return html` + + +
    + +
    + +
    ${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()}