diff --git a/.dockerignore b/.dockerignore index 94a64c67..595cb0a5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,8 +19,8 @@ coverage.xml ../.pytest_cache .hypothesis -src/module/tests -src/module/conf/const_dev.py +backend/src/module/tests +backend/src/module/conf/const_dev.py config/bangumi.json/config/bangumi.json /docs /.github @@ -33,8 +33,8 @@ config/bangumi.json/config/bangumi.json dist.zip data config -/src/config -/src/data +/backend/src/config +/backend/src/data .pytest_cache test .env diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 14514ac3..74367ca5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,12 +1,12 @@ name: 问题反馈 description: File a bug report -title: "[错误报告] 请在此处简单描述你的问题" +title: "[错误报告]请在此处简单描述你的问题" labels: ["bug"] body: - type: markdown attributes: value: | - 描述问题前,请先更新到最新版本。2.5 之前的版本升级请参考 [升级指南](https://github.com/EstrellaXD/Auto_Bangumi/wiki/2.6更新说明#如何从老版本更新的注意事项) + 描述问题前,请先更新到最新版本。2.5 之前的版本升级请参考 [升级指南](https://www.autobangumi.org/changelog/2.6.html#如何从老版本更新的注意事项) 请确认以下信息,如果你的问题可以直接在文档中找到,那么你的 issue 将会被直接关闭。 解析器问题请转到[专用模板](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=bug&template=parser_bug.yml&title=%5B解析器错误%5D), 重命名问题请到[专用模板](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=bug&template=rename_bug.yml&title=%5B重命名错误%5D) @@ -18,9 +18,9 @@ body: options: - label: 我的版本是最新版本,我的版本号与 [version](https://github.com/EstrellaXD/Auto_Bangumi/releases/latest) 相同。 required: true - - label: 我已经查阅了[排错流程](https://github.com/EstrellaXD/Auto_Bangumi/wiki/排错流程),确保提出的问题不在其中。 + - label: 我已经查阅了[排错流程](https://autobangumi.org/faq/排错流程.html),确保提出的问题不在其中。 required: true - - label: 我已经查阅了[已知问题](https://github.com/EstrellaXD/Auto_Bangumi/wiki/常见问题),并确认我的问题不在其中。 + - label: 我已经查阅了[已知问题](https://autobangumi.org/faq/常见问题.html),并确认我的问题不在其中。 required: true - label: 我已经 [issue](https://github.com/EstrellaXD/Auto_Bangumi/issues) 中搜索过,确认我的问题没有被提出过。 required: true diff --git a/.github/ISSUE_TEMPLATE/discussion.yml b/.github/ISSUE_TEMPLATE/discussion.yml index 26f18ef6..6c50712c 100644 --- a/.github/ISSUE_TEMPLATE/discussion.yml +++ b/.github/ISSUE_TEMPLATE/discussion.yml @@ -1,6 +1,6 @@ name: 项目讨论 description: discussion -title: "[Discussion]: " +title: "[Discussion] " labels: ["discussion"] body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index bec710d0..c1572144 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: 功能改进 description: Feature Request -title: "[Feature Request]: " +title: "[Feature Request]" labels: ["feature request"] body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/rfc.yml b/.github/ISSUE_TEMPLATE/rfc.yml new file mode 100644 index 00000000..6b3b8aad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/rfc.yml @@ -0,0 +1,53 @@ +# https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms + +name: 功能提案 +description: Request for Comments +title: "[RFC]" +labels: ["RFC"] +body: + - type: markdown + attributes: + value: | + 一份提案(RFC)定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**, + 目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论; + 以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突), + 因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。 + + 如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D+) + + - type: textarea + id: background + attributes: + label: 背景 or 问题 + description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。 + validations: + required: true + + - type: textarea + id: goal + attributes: + label: "目标 & 方案简述" + description: 简单描述提案此提案实现后,**预期的目标效果**,以及简单大致描述会采取的方案/步骤,可能会/不会产生什么影响。 + validations: + required: true + + - type: textarea + id: design + attributes: + label: "方案设计 & 实现步骤" + description: | + 详细描述你设计的具体方案,可以考虑拆分列表或要点,一步步描述具体打算如何实现的步骤和相关细节。 + 这部份不需要一次性写完整,即使在创建完此提案 issue 后,依旧可以再次编辑修改。 + validations: + required: false + + + - type: textarea + id: alternative + attributes: + label: "替代方案 & 对比" + description: | + [可选] 为来实现目标效果,还考虑过什么其他方案,有什么对比? + validations: + required: false + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fcc9fa70..1ce4f933 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,10 @@ name: Build Docker on: pull_request: + types: + - closed + branches: + - main push: jobs: @@ -24,15 +28,62 @@ jobs: mkdir -p config pytest - build-webui: - if: > - (github.event_name == 'pull_request') || - (github.event_name == 'push' && github.ref_type == 'tag' && (contains(github.ref, 'alpha') || contains(github.ref, 'beta'))) + version-info: runs-on: ubuntu-latest - needs: [test] + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: If release + id: release + run: | + if [ '${{ github.event_name }}' == 'pull_request' ]; then + if [ ${{ github.event.pull_request.merged }} == true ]; then + echo "release=1" >> $GITHUB_OUTPUT + else + echo "release=0" >> $GITHUB_OUTPUT + fi + elif [[ '${{ github.event_name }}' == 'push' && (${{ github.ref }} == *'alpha'* || ${{ github.ref }} == *'beta'*) ]]; then + echo "release=1" >> $GITHUB_OUTPUT + else + echo "release=0" >> $GITHUB_OUTPUT + fi + - name: If dev + id: dev + run: | + if [[ '${{ github.event_name }}' == 'push' && (${{ github.ref }} == *'alpha'* || ${{ github.ref }} == *'beta'*) ]]; then + echo "dev=1" >> $GITHUB_OUTPUT + else + echo "dev=0" >> $GITHUB_OUTPUT + fi + - name: Check version + id: version + run: | + if [ '${{ github.event_name }}' == 'pull_request' ]; then + if [ ${{ github.event.pull_request.merged }} == true ]; then + echo "version=${{ github.event.pull_request.title }}" >> $GITHUB_OUTPUT + fi + elif [[ ${{ github.event_name }} == 'push' && (${{ github.ref }} == *'alpha'* || ${{ github.ref }} == *'beta'*) ]]; then + echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + else + echo "version=Test" >> $GITHUB_OUTPUT + fi + - name: Check result + run: | + echo "release: ${{ steps.release.outputs.release }}" + echo "dev: ${{ steps.dev.outputs.dev }}" + echo "version: ${{ steps.version.outputs.version }}" + outputs: + release: ${{ steps.release.outputs.release }} + dev: ${{ steps.dev.outputs.dev }} + version: ${{ steps.version.outputs.version }} + + build-webui: + runs-on: ubuntu-latest + needs: [ test, version-info ] + if: ${{ needs.version-info.outputs.release == 1 || needs.version-info.outputs.dev == 1 }} strategy: matrix: - node-version: [18] + node-version: [ 18 ] steps: - name: Checkout uses: actions/checkout@v3 @@ -63,24 +114,16 @@ jobs: build-docker: runs-on: ubuntu-latest - needs: [build-webui] + needs: [ build-webui, version-info ] steps: - name: Checkout uses: actions/checkout@v3 - - name: Create Version info + - name: Create Version info via tag working-directory: ./backend/src run: | - echo "VERSION = '$GITHUB_REF_NAME'" > module/__version__.py - - - name: Create Tag - if: ${{ github.pull_request.merged == true }} - id: create-tag - run: | - git config --local user.email " - git config --local user.name "github-actions" - git tag -a ${{ github.event.pull_request.title }} -m ${{ github.event.pull_request.body }} - git push origin ${{ github.event.pull_request.title }} + echo ${{ needs.version-info.outputs.version }} + echo "VERSION='${{ needs.version-info.outputs.version }}'" >> module/__version__.py - name: Set up QEMU uses: docker/setup-qemu-action@v2 @@ -90,6 +133,7 @@ jobs: uses: docker/setup-buildx-action@v2 - name: Docker metadata main + if: ${{ needs.version-info.outputs.release == 1 && needs.version-info.outputs.dev != 1 }} id: meta uses: docker/metadata-action@v4 with: @@ -97,30 +141,30 @@ jobs: estrellaxd/auto_bangumi ghcr.io/${{ github.repository }} tags: | - type=semver,pattern={{version}} + type=raw,value=${{ needs.version-info.outputs.version }} type=raw,value=latest - name: Docker metadata dev - if: contains(github.ref, 'tags') && contains(github.ref, 'alpha') || contains(github.ref, 'beta') + if: ${{ needs.version-info.outputs.dev == 1 }} id: meta-dev uses: docker/metadata-action@v4 with: images: | - estrellaxd/auto_bangumi:dev + estrellaxd/auto_bangumi ghcr.io/${{ github.repository }} tags: | - type=raw,value=${{ github.ref_name }} + type=raw,value=${{ needs.version-info.outputs.version }} type=raw,value=dev-latest - name: Login to DockerHub - if: ${{ github.event_name == 'push' }} + if: ${{ needs.version-info.outputs.release == 1 }} uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Login to ghcr.io - if: ${{ github.event_name == 'push' }} + if: ${{ needs.version-info.outputs.release == 1 }} uses: docker/login-action@v2 with: registry: ghcr.io @@ -133,31 +177,54 @@ jobs: name: dist path: backend/src/dist - - name: View files - run: | - pwd && ls -al && tree - - name: Build and push + if: ${{ needs.version-info.outputs.release == 1 && needs.version-info.outputs.dev != 1 }} + uses: docker/build-push-action@v4 + with: + context: . + builder: ${{ steps.buildx.output.name }} + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: True + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha, scope=${{ github.workflow }} + cache-to: type=gha, scope=${{ github.workflow }} + + - name: Build and push dev + if: ${{ needs.version-info.outputs.dev == 1 }} uses: docker/build-push-action@v4 with: context: . builder: ${{ steps.buildx.output.name }} platforms: linux/amd64,linux/arm64,linux/arm/v7 push: ${{ github.event_name == 'push' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta-dev.outputs.tags }} + labels: ${{ steps.meta-dev.outputs.labels }} + cache-from: type=gha, scope=${{ github.workflow }} + cache-to: type=gha, scope=${{ github.workflow }} + + - name: Build test + if: ${{ needs.version-info.outputs.release == 0 }} + uses: docker/build-push-action@v4 + with: + context: . + builder: ${{ steps.buildx.output.name }} + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: false + tags: estrellaxd/auto_bangumi:test cache-from: type=gha, scope=${{ github.workflow }} cache-to: type=gha, scope=${{ github.workflow }} release: - if: > - (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || - (github.event_name == 'push' && contains(github.ref, 'tags')) runs-on: ubuntu-latest - needs: [build-docker] + needs: [ build-docker, version-info ] + if: ${{ needs.version-info.outputs.release == 1 }} + outputs: + url: ${{ steps.release.outputs.url }} + version: ${{ needs.version-info.outputs.version }} steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Download artifact uses: actions/download-artifact@v3 @@ -169,37 +236,34 @@ jobs: run: | cd webui && ls -al && tree && zip -r dist.zip dist - - name: Generate Release - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.merged == true }} + - name: Generate Release info + id: release-info + run: | + if ${{ needs.version-info.outputs.dev == 1 }}; then + echo "version=🌙${{ needs.version-info.outputs.version }}" >> $GITHUB_OUTPUT + echo "pre_release=true" >> $GITHUB_OUTPUT + else + echo "version=🌟${{ needs.version-info.outputs.version }}" >> $GITHUB_OUTPUT + echo "pre_release=false" >> $GITHUB_OUTPUT + fi + - name: Release + id: release uses: softprops/action-gh-release@v1 with: - tag_name: ${{ github.event.pull_request.title }} - name: 🌟${{ github.event.pull_request.title }} + tag_name: ${{ needs.version-info.outputs.version }} + name: ${{ steps.release-info.outputs.version }} body: ${{ github.event.pull_request.body }} draft: false - prerelease: false + prerelease: ${{ steps.release-info.outputs.pre_release == 'true' }} files: | webui/dist.zip env: GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} - - name: Generate dev Release - if: ${{ github.event_name == 'push' && contains(github.ref, 'alpha') || contains(github.ref, 'beta') }} - uses: softprops/action-gh-release@v1 - with: - tag_name: ${{ github.ref_name }} - name: 🌙${{ github.ref_name }} - body: ${{ github.event.pull_request.body }} - draft: true - prerelease: true - files: | - webui/dist.zip - env: - GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} telegram: runs-on: ubuntu-latest - needs: [release] + needs: [ release ] steps: - name: send telegram message on push uses: appleboy/telegram-action@master @@ -207,5 +271,5 @@ jobs: to: ${{ secrets.TELEGRAM_TO }} token: ${{ secrets.TELEGRAM_TOKEN }} message: | - New release: ${{ github.event.release.title }} - Link: ${{ github.event.release.html_url }} + New release: ${{ needs.release.outputs.version }} + Link: ${{ needs.release.outputs.url }} diff --git a/.gitignore b/.gitignore index 7069090d..34d4906b 100644 --- a/.gitignore +++ b/.gitignore @@ -194,6 +194,7 @@ dist dist.zip dist-ssr *.local +dev-dist # Editor directories and files .vscode/* diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..b3db48e8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,18 @@ +{ + "recommendations": [ + // https://marketplace.visualstudio.com/items?itemName=antfu.unocss + "antfu.unocss", + // https://marketplace.visualstudio.com/items?itemName=formulahendry.auto-rename-tag + "formulahendry.auto-rename-tag", + // https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker + "streetsidesoftware.code-spell-checker", + // https://marketplace.visualstudio.com/items?itemName=naumovs.color-highlight + "naumovs.color-highlight", + // https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance + "ms-python.vscode-pylance", + // https://marketplace.visualstudio.com/items?itemName=ms-python.python + "ms-python.python", + // https://marketplace.visualstudio.com/items?itemName=vue.volar + "vue.volar" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c8358720 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Dev Backend", + "type": "python", + "request": "launch", + "cwd": "${workspaceFolder}/backend/src", + "program": "main.py", + "env": { + "HOST": "127.0.0.1", + }, + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 28838c61..5d6b2455 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,4 +10,10 @@ "editor.wordWrap": "off", }, "python.venvPath": "./backend/venv", + "cSpell.words": [ + "Bangumi", + "fastapi", + "mikan", + "starlette" + ], } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6834d93c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# [3.1] - 2023-08 + +- 合并了后端和前端仓库,优化了项目目录 +- 优化了版本发布流程。 +- Wiki 迁移至 Vitepress,地址:https://autobangumi.org + +## Backend + +### Features + +- 新增 `RSS Engine` 模块,从现在起,AB 可以自主对 RSS 进行更新支持 `RSS` 订阅并且发送种子给下载器。 + - 现在支持多个聚合 RSS 订阅源,可以通过 `RSS Engine` 模块进行管理。 + - 支持下载去重功能,重复的订阅的种子不会被下载。 + - 增加手动刷新 API,可以手动刷新 RSS 订阅。 + - 新增 RSS 订阅管理 API。 +- 新增 `Search Engine`模块,可以通过关键词搜索种子并解析成收集或者订阅任务。 + - 插件化的搜索引擎,可以通过插件的方式添加新的搜索目标,目前支持 `mikan`、`dmhy` 和 `nyaa` +- 新增对字幕组的特异性规则,可以针对不同的字幕组进行单独设置。 +- 新增 IPv6 监听支持,需要在环境变量中设置 `IPV6=1`。 +- API 新增批量操作,可以批量管理规则和 RSS 订阅。 + +### Changes + +- 数据库结构变更,更换为 `sqlmodel` 管理数据库。 +- 新增版本管理,可以无缝更新软件数据。 +- 调整 API 格式,更加统一。 +- 增加 API 返回语言选项。 +- 增加数据库 mock test。 +- 优化代码。 + +### Bugfixes + +- 修复了一些小问题。 +- 增加了一些大问题。 + +## Frontend + +### Features + +- 增加 `i18n` 支持,目前支持 `zh-CN` 和 `en-US`。 +- 增加 pwa 支持。 +- 增加 RSS 管理页面。 +- 增加搜索顶栏。 + +### Changes + +- 调整一些 UI 细节。 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 474ca56b..9d66f04d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,8 @@ # syntax=docker/dockerfile:1 -FROM alpine:3.18 AS APP +FROM alpine:3.18 -ENV S6_SERVICES_GRACETIME=30000 \ - S6_KILL_GRACETIME=60000 \ - S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 \ - S6_SYNC_DISKS=1 \ - TERM="xterm" \ - HOME="/ab" \ - LANG="C.UTF-8" \ +ENV LANG="C.UTF-8" \ TZ=Asia/Shanghai \ PUID=1000 \ PGID=1000 \ @@ -20,33 +14,29 @@ COPY backend/requirements.txt . RUN set -ex && \ apk add --no-cache \ bash \ - ca-certificates \ - coreutils \ - curl \ - jq \ - netcat-openbsd \ - procps-ng \ python3 \ py3-bcrypt \ py3-pip \ - s6-overlay \ + su-exec \ shadow \ + tini \ tzdata && \ python3 -m pip install --no-cache-dir --upgrade pip && \ sed -i '/bcrypt/d' requirements.txt && \ pip install --no-cache-dir -r requirements.txt && \ # Add user + mkdir -p /home/ab && \ addgroup -S ab -g 911 && \ - adduser -S ab -G ab -h /ab -s /bin/bash -u 911 && \ + adduser -S ab -G ab -h /home/ab -s /sbin/nologin -u 911 && \ # Clear rm -rf \ - /ab/.cache \ + /root/.cache \ /tmp/* COPY --chmod=755 backend/src/. . -COPY --chmod=755 docker/ / +COPY --chmod=755 entrypoint.sh /entrypoint.sh -ENTRYPOINT [ "/init" ] +ENTRYPOINT ["tini", "-g", "--", "/entrypoint.sh"] EXPOSE 7892 VOLUME [ "/app/config" , "/app/data" ] diff --git a/README.md b/README.md index df7e266a..970937d8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@

+

+ 官方网站 | 快速开始 | 更新日志 | TG 群组 +

+ # 项目说明

@@ -17,10 +21,6 @@ 本项目是基于 [Mikan Project](https://mikanani.me)、[qBittorrent](https://qbittorrent.org) 的全自动追番整理下载工具。只需要在 [Mikan Project](https://mikanani.me) 上订阅番剧,就可以全自动追番。并且整理完成的名称和目录可以直接被 [Plex]()、[Jellyfin]() 等媒体库软件识别,无需二次刮削。 -[主项目地址](https://www.github.com/EstrellaXD/Auto_Bangumi) -/ [WebUI 仓库](https://github.com/Rewrite0/Auto_Bangumi_WebUI) -/ [Wiki 说明](https://www.github.com/EstrellaXD/Auto_Bangumi/wiki) - ## AutoBangumi 功能说明 - 简易单次配置就能持续使用 @@ -59,19 +59,12 @@ - 内置 TDMB 解析器,可以直接生成完整的 TMDB 格式的文件以及番剧信息。 - 对于 Mikan RSS 的反代支持。 -## 如何开始 - -- **[部署说明 (Official)](https://github.com/EstrellaXD/Auto_Bangumi/wiki)** -- **[2.6版本更新说明](https://github.com/EstrellaXD/Auto_Bangumi/wiki/2.6更新说明)** -- **[3.0版本更新说明](https://github.com/EstrellaXD/Auto_Bangumi/wiki/3.0更新说明)** -- **[部署说明 (手把手)](https://www.himiku.com/archives/auto-bangumi.html)** - ## 相关群组 - 更新推送:[Telegram Channel](https://t.me/autobangumi_update) - Bug 反馈群:[Telegram](https://t.me/+yNisOnDGaX5jMTM9) -## Roadmap +## [Roadmap](https://github.com/users/EstrellaXD/projects/2) ***开发中的功能:*** @@ -82,19 +75,21 @@ - 对其他站点种子的解析归类。 - 本地化番剧订阅方式。 -- Transmission & Aria2 的支持。 - -# 声明 - -## 致谢 - -感谢 [Sean](https://github.com/findix) 提供的大量帮助 -感谢 [Rewrite0](https://github.com/Rewrite0) 开发的 WebUI +- Transmission 的支持。 ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=EstrellaXD/Auto_Bangumi&type=Date)](https://star-history.com/#EstrellaXD/Auto_Bangumi) +## 贡献 + +欢迎提供 ISSUE 或者 PR, 贡献代码前建议阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。 + +贡献者名单请见: + + + + ## Licence [MIT licence](https://github.com/EstrellaXD/Auto_Bangumi/blob/main/LICENSE) diff --git a/backend/dev.sh b/backend/dev.sh index 84cf8f80..4234c27d 100755 --- a/backend/dev.sh +++ b/backend/dev.sh @@ -25,4 +25,4 @@ if [ ! -f "$VERSION_FILE" ]; then echo "VERSION='DEV_VERSION'" >>"$VERSION_FILE" fi -../venv/bin/python3 main.py +../venv/bin/uvicorn main:app --reload --port 7892 diff --git a/backend/requirements.txt b/backend/requirements.txt index ba894d35..0ac360cd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,25 +1,28 @@ -anyio -bs4 -certifi -charset-normalizer -click -fastapi -h11 -idna +anyio==3.7.0 +bs4==0.0.1 +certifi==2023.5.7 +charset-normalizer==3.1.0 +click==8.1.3 +fastapi==0.97.0 +h11==0.14.0 +idna==3.4 pydantic~=1.10 -PySocks -qbittorrent-api -requests -six -sniffio -soupsieve -typing_extensions -urllib3 -uvicorn -attrdict -jinja2 -python-dotenv -python-jose -passlib -bcrypt -python-multipart +PySocks==1.7.1 +qbittorrent-api==2023.6.49 +requests==2.31.0 +six==1.16.0 +sniffio==1.3.0 +soupsieve==2.4.1 +typing_extensions==4.6.3 +urllib3==2.0.3 +uvicorn==0.22.0 +attrdict==2.0.1 +Jinja2==3.1.2 +python-dotenv==1.0.0 +python-jose==3.3.0 +passlib==1.7.4 +bcrypt==4.0.1 +python-multipart==0.0.6 +sqlmodel==0.0.8 +sse-starlette==1.6.5 +semver==3.0.1 diff --git a/backend/scripts/pip-lock-version.sh b/backend/scripts/pip-lock-version.sh new file mode 100755 index 00000000..676501a1 --- /dev/null +++ b/backend/scripts/pip-lock-version.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +# +# Usage: +# `bash scripts/pip-lock-version.sh` +# +# ```prompt +# Lock the library versions in `requirements.txt` to the current ones from `pip freeze` using shell script, +# but don't change any order in `requirements.txt` +# ``` +# + + +# Create a temporary requirements file using pip freeze +pip freeze > pip_freeze.log + +# Read the existing requirements.txt line by line +while IFS= read -r line +do + # Extract the library name without version + lib_name=$(echo $line | cut -d'=' -f1) + + # Find the corresponding library in the temporary requirements file + lib_line=$(grep "^$lib_name==" pip_freeze.log) + + # If the library is found, update the line + if [[ $lib_line ]] + then + echo $lib_line + else + echo $line + fi + +# Redirect the output to a new requirements file +done < requirements.txt > new_requirements.log + +# Remove the temporary requirements file +rm pip_freeze.log + +# Replace the old requirements file with the new one +mv new_requirements.log requirements.txt + diff --git a/backend/src/main.py b/backend/src/main.py index 5aae408c..581d2173 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,3 +1,4 @@ +import os import logging import uvicorn @@ -42,32 +43,51 @@ app = create_app() if VERSION != "DEV_VERSION": app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets") - # app.mount("/pwa", StaticFiles(directory="dist/pwa"), name="pwa") + app.mount("/images", StaticFiles(directory="dist/images"), name="images") # app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons") templates = Jinja2Templates(directory="dist") # Resource - @app.get("/favicon.svg", tags=["html"]) - def favicon(): - return FileResponse("dist/favicon.svg") + # @app.get("/favicon.svg", tags=["html"]) + # def favicon(): + # return FileResponse("dist/favicon.svg") + # + # @app.get("/AutoBangumi.svg", tags=["html"]) + # def logo(): + # return FileResponse("dist/AutoBangumi.svg") + # + # @app.get("/favicon-light.svg", tags=["html"]) + # def favicon_light(): + # return FileResponse("dist/favicon-light.svg") + # + # @app.get("/robots.txt", tags=["html"]) + # def robots(): + # return FileResponse("dist/robots.txt") + # + # @app.get("/manifest.webmanifest", tags=["html"]) + # def manifest(): + # return FileResponse("dist/manifest.webmanifest") + # + # @app.get("/sw.js", tags=["html"]) + # def sw(): + # return FileResponse("dist/sw.js") - @app.get("/AutoBangumi.svg", tags=["html"]) - def logo(): - return FileResponse("dist/AutoBangumi.svg") - - @app.get("/favicon-light.svg", tags=["html"]) - def favicon_light(): - return FileResponse("dist/favicon-light.svg") - - @app.get("/robots.txt", tags=["html"]) - def robots(): - return FileResponse("dist/robots.txt") + @app.get("/{path:path}") + def html(request: Request, path: str): + files = os.listdir("dist") + if path in files: + return FileResponse(f"dist/{path}") + else: + context = {"request": request} + return templates.TemplateResponse("index.html", context) # HTML Response - @app.get("/{full_path:path}", response_class=HTMLResponse, tags=["html"]) - def index(request: Request): - context = {"request": request} - return templates.TemplateResponse("index.html", context) + # @app.get("/{path:path}", response_class=HTMLResponse, tags=["html"]) + # def index(request: Request, path: str): + # print(request) + # print(path) + # context = {"request": request} + # return templates.TemplateResponse("index.html", context) else: @@ -77,9 +97,13 @@ else: if __name__ == "__main__": + if os.getenv("IPV6"): + host = "::" + else: + host = os.getenv("HOST", "0.0.0.0") uvicorn.run( app, - host="0.0.0.0", + host=host, port=settings.program.webui_port, log_config=uvicorn_logging_config, ) diff --git a/backend/src/module/ab_decorator/__init__.py b/backend/src/module/ab_decorator/__init__.py index 00d2bef5..85b84082 100644 --- a/backend/src/module/ab_decorator/__init__.py +++ b/backend/src/module/ab_decorator/__init__.py @@ -2,6 +2,8 @@ import logging import threading import time +from .timeout import timeout + logger = logging.getLogger(__name__) lock = threading.Lock() diff --git a/backend/src/module/ab_decorator/timeout.py b/backend/src/module/ab_decorator/timeout.py new file mode 100644 index 00000000..32f3a985 --- /dev/null +++ b/backend/src/module/ab_decorator/timeout.py @@ -0,0 +1,23 @@ +import signal + + +def timeout(seconds): + def decorator(func): + def handler(signum, frame): + raise TimeoutError("Function timed out.") + + def wrapper(*args, **kwargs): + # 设置信号处理程序,当超时时触发TimeoutError异常 + signal.signal(signal.SIGALRM, handler) + signal.alarm(seconds) # 设置alarm定时器 + + try: + result = func(*args, **kwargs) + finally: + signal.alarm(0) # 取消alarm定时器 + + return result + + return wrapper + + return decorator diff --git a/backend/src/module/api/__init__.py b/backend/src/module/api/__init__.py index 741548a9..38999e4d 100644 --- a/backend/src/module/api/__init__.py +++ b/backend/src/module/api/__init__.py @@ -3,9 +3,10 @@ from fastapi import APIRouter from .auth import router as auth_router from .bangumi import router as bangumi_router from .config import router as config_router -from .download import router as download_router from .log import router as log_router from .program import router as program_router +from .rss import router as rss_router +from .search import router as search_router __all__ = "v1" @@ -14,6 +15,7 @@ v1 = APIRouter(prefix="/v1") v1.include_router(auth_router) v1.include_router(log_router) v1.include_router(program_router) -v1.include_router(download_router) v1.include_router(bangumi_router) v1.include_router(config_router) +v1.include_router(rss_router) +v1.include_router(search_router) diff --git a/backend/src/module/api/auth.py b/backend/src/module/api/auth.py index a1a2fc10..410ab036 100644 --- a/backend/src/module/api/auth.py +++ b/backend/src/module/api/auth.py @@ -2,57 +2,65 @@ from datetime import timedelta from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm +from fastapi.responses import JSONResponse, Response -from module.models.user import User -from module.security import ( +from .response import u_response + +from module.models.user import User, UserUpdate +from module.models import APIResponse +from module.security.api import ( auth_user, - create_access_token, get_current_user, update_user_info, + active_user ) +from module.security.jwt import create_access_token router = APIRouter(prefix="/auth", tags=["auth"]) @router.post("/login", response_model=dict) -async def login(form_data: OAuth2PasswordRequestForm = Depends()): - username = form_data.username - password = form_data.password - auth_user(username, password) - token = create_access_token(data={"sub": username}, expires_delta=timedelta(days=1)) - - return {"access_token": token, "token_type": "bearer", "expire": 86400} - - -@router.get("/refresh_token", response_model=dict) -async def refresh(current_user: User = Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" +async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)): + user = User(username=form_data.username, password=form_data.password) + resp = auth_user(user) + if resp.status: + token = create_access_token( + data={"sub": user.username}, expires_delta=timedelta(days=1) ) - token = create_access_token(data={"sub": current_user.username}) - return {"access_token": token, "token_type": "bearer", "expire": 86400} + response.set_cookie(key="token", value=token, httponly=True, max_age=86400) + return {"access_token": token, "token_type": "bearer"} + return u_response(resp) + +@router.get("/refresh_token", response_model=dict, dependencies=[Depends(get_current_user)]) +async def refresh(response: Response): + token = create_access_token( + data={"sub": active_user[0]}, expires_delta=timedelta(days=1) + ) + response.set_cookie(key="token", value=token, httponly=True, max_age=86400) + return {"access_token": token, "token_type": "bearer"} -@router.get("/logout", response_model=dict) -async def logout(current_user: User = Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" +@router.get("/logout", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def logout(response: Response): + active_user.clear() + response.delete_cookie(key="token") + return JSONResponse( + status_code=200, + content={"msg_en": "Logout successfully.", "msg_zh": "登出成功。"}, + ) + + +@router.post("/update", response_model=dict, dependencies=[Depends(get_current_user)]) +async def update_user( + user_data: UserUpdate, response: Response +): + old_user = active_user[0] + if update_user_info(user_data, old_user): + token = create_access_token(data={"sub": old_user}, expires_delta=timedelta(days=1)) + response.set_cookie( + key="token", + value=token, + httponly=True, + max_age=86400, ) - return {"message": "logout success"} - - -@router.post("/update", response_model=dict) -async def update_user(user_data: User, current_user: User = Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - if update_user_info(user_data, current_user): - return { - "message": "update success", - "access_token": create_access_token({"sub": user_data.username}), - "token_type": "bearer", - "expire": 86400, - } + return {"access_token": token, "token_type": "bearer", "message": "update success"} diff --git a/backend/src/module/api/bangumi.py b/backend/src/module/api/bangumi.py index 16c206f3..a96c22c2 100644 --- a/backend/src/module/api/bangumi.py +++ b/backend/src/module/api/bangumi.py @@ -1,83 +1,85 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse +from .response import u_response + from module.manager import TorrentManager -from module.models import BangumiData -from module.security import get_current_user +from module.models import Bangumi, BangumiUpdate, APIResponse +from module.security.api import get_current_user, UNAUTHORIZED router = APIRouter(prefix="/bangumi", tags=["bangumi"]) -@router.get("/getAll", response_model=list[BangumiData]) -async def get_all_data(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - with TorrentManager() as torrent: - return torrent.search_all() +def str_to_list(data: Bangumi): + data.filter = data.filter.split(",") + data.rss_link = data.rss_link.split(",") + return data -@router.get("/getData/{bangumi_id}", response_model=BangumiData) -async def get_data(bangumi_id: str, current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - with TorrentManager() as torrent: - return torrent.search_one(bangumi_id) +@router.get("/get/all", response_model=list[Bangumi], dependencies=[Depends(get_current_user)]) +async def get_all_data(): + with TorrentManager() as manager: + return manager.bangumi.search_all() -@router.post("/updateRule") -async def update_rule(data: BangumiData, current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - with TorrentManager() as torrent: - return torrent.update_rule(data) +@router.get("/get/{bangumi_id}", response_model=Bangumi, dependencies=[Depends(get_current_user)]) +async def get_data(bangumi_id: str): + with TorrentManager() as manager: + resp = manager.search_one(bangumi_id) + return resp -@router.delete("/deleteRule/{bangumi_id}") -async def delete_rule( - bangumi_id: str, file: bool = False, current_user=Depends(get_current_user) +@router.patch("/update/{bangumi_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def update_rule( + bangumi_id: int, data: BangumiUpdate, ): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + with TorrentManager() as manager: + resp = manager.update_rule(bangumi_id, data) + return u_response(resp) + + +@router.delete(path="/delete/{bangumi_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def delete_rule(bangumi_id: str, file: bool = False): + with TorrentManager() as manager: + resp = manager.delete_rule(bangumi_id, file) + return u_response(resp) + + +@router.delete(path="/delete/many/", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def delete_many_rule(bangumi_id: list, file: bool = False): + with TorrentManager() as manager: + for i in bangumi_id: + resp = manager.delete_rule(i, file) + return u_response(resp) + + +@router.delete(path="/disable/{bangumi_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def disable_rule(bangumi_id: str, file: bool = False): + with TorrentManager() as manager: + resp = manager.disable_rule(bangumi_id, file) + return u_response(resp) + + +@router.delete(path="/disable/many/", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def disable_many_rule(bangumi_id: list, file: bool = False): + with TorrentManager() as manager: + for i in bangumi_id: + resp = manager.disable_rule(i, file) + return u_response(resp) + + +@router.get(path="/enable/{bangumi_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def enable_rule(bangumi_id: str): + with TorrentManager() as manager: + resp = manager.enable_rule(bangumi_id) + return u_response(resp) + + +@router.get("/reset/all", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def reset_all(): + with TorrentManager() as manager: + manager.bangumi.delete_all() + return JSONResponse( + status_code=200, + content={"msg_en": "Reset all rules successfully.", "msg_zh": "重置所有规则成功。"}, ) - with TorrentManager() as torrent: - return torrent.delete_rule(bangumi_id, file) - - -@router.delete("/disableRule/{bangumi_id}") -async def disable_rule( - bangumi_id: str, file: bool = False, current_user=Depends(get_current_user) -): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - with TorrentManager() as torrent: - return torrent.disable_rule(bangumi_id, file) - - -@router.get("/enableRule/{bangumi_id}") -async def enable_rule(bangumi_id: str, current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - with TorrentManager() as torrent: - return torrent.enable_rule(bangumi_id) - - -@router.get("/resetAll") -async def reset_all(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - with TorrentManager() as torrent: - torrent.delete_all() - return JSONResponse(status_code=200, content={"message": "OK"}) diff --git a/backend/src/module/api/config.py b/backend/src/module/api/config.py index 2c396919..3b307599 100644 --- a/backend/src/module/api/config.py +++ b/backend/src/module/api/config.py @@ -1,35 +1,35 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse from module.conf import settings -from module.models import Config -from module.security import get_current_user +from module.models import Config, APIResponse +from module.security.api import get_current_user, UNAUTHORIZED -router = APIRouter(tags=["config"]) +router = APIRouter(prefix="/config", tags=["config"]) logger = logging.getLogger(__name__) -@router.get("/getConfig", response_model=Config) -async def get_config(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - return settings.dict() +@router.get("/get", response_model=Config, dependencies=[Depends(get_current_user)]) +async def get_config(): + return settings -@router.post("/updateConfig") -async def update_config(config: Config, current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) +@router.patch("/update", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def update_config(config: Config): try: settings.save(config_dict=config.dict()) settings.load() + # update_rss() logger.info("Config updated") - return {"message": "Success"} + return JSONResponse( + status_code=200, + content={"msg_en": "Update config successfully.", "msg_zh": "更新配置成功。"} + ) except Exception as e: logger.warning(e) - return {"message": "Failed to update config"} + return JSONResponse( + status_code=406, + content={"msg_en": "Update config failed.", "msg_zh": "更新配置失败。"} + ) diff --git a/backend/src/module/api/download.py b/backend/src/module/api/download.py deleted file mode 100644 index 278b1bb5..00000000 --- a/backend/src/module/api/download.py +++ /dev/null @@ -1,54 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status - -from module.manager import SeasonCollector -from module.models import BangumiData -from module.models.api import RssLink -from module.rss import analyser -from module.security import get_current_user - -router = APIRouter(prefix="/download", tags=["download"]) - - -@router.post("/analysis") -async def analysis(link: RssLink, current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - data = analyser.link_to_data(link.rss_link) - if data: - return data - else: - return {"status": "Failed to parse link"} - - -@router.post("/collection") -async def download_collection( - data: BangumiData, current_user=Depends(get_current_user) -): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - if data: - with SeasonCollector() as collector: - if collector.collect_season(data, data.rss_link[0], proxy=True): - return {"status": "Success"} - else: - return {"status": "Failed to add torrent"} - else: - return {"status": "Failed to parse link"} - - -@router.post("/subscribe") -async def subscribe(data: BangumiData, current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - if data: - with SeasonCollector() as collector: - collector.subscribe_season(data) - return {"status": "Success"} - else: - return {"status": "Failed to parse link"} diff --git a/backend/src/module/api/log.py b/backend/src/module/api/log.py index 88ec2c57..04d1434d 100644 --- a/backend/src/module/api/log.py +++ b/backend/src/module/api/log.py @@ -1,19 +1,15 @@ -import os - from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi.responses import JSONResponse from module.conf import LOG_PATH -from module.security import get_current_user +from module.security.api import get_current_user, UNAUTHORIZED +from module.models import APIResponse router = APIRouter(prefix="/log", tags=["log"]) -@router.get("") -async def get_log(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) +@router.get("", response_model=str, dependencies=[Depends(get_current_user)]) +async def get_log(): if LOG_PATH.exists(): with open(LOG_PATH, "rb") as f: return Response(f.read(), media_type="text/plain") @@ -21,14 +17,16 @@ async def get_log(current_user=Depends(get_current_user)): return Response("Log file not found", status_code=404) -@router.get("/clear") -async def clear_log(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) +@router.get("/clear", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def clear_log(): if LOG_PATH.exists(): LOG_PATH.write_text("") - return {"status": "ok"} + return JSONResponse( + status_code=200, + content={"msg_en": "Log cleared successfully.", "msg_zh": "日志清除成功。"}, + ) else: - return Response("Log file not found", status_code=404) + return JSONResponse( + status_code=406, + content={"msg_en": "Log file not found.", "msg_zh": "日志文件未找到。"}, + ) diff --git a/backend/src/module/api/program.py b/backend/src/module/api/program.py index d75bd4ea..691bd149 100644 --- a/backend/src/module/api/program.py +++ b/backend/src/module/api/program.py @@ -1,12 +1,16 @@ import logging import os import signal -import sys -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse + +from .response import u_response from module.core import Program -from module.security import get_current_user +from module.models import APIResponse +from module.conf import VERSION +from module.security.api import get_current_user, UNAUTHORIZED logger = logging.getLogger(__name__) program = Program() @@ -21,85 +25,75 @@ async def startup(): @router.on_event("shutdown") async def shutdown(): program.stop() - sys.exit(0) -@router.get("/restart") -async def restart(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) +@router.get("/restart", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def restart(): try: - program.restart() - return {"status": "ok"} + resp = program.restart() + return u_response(resp) except Exception as e: logger.debug(e) logger.warning("Failed to restart program") - raise HTTPException(status_code=500, detail="Failed to restart program") - - -@router.get("/start") -async def start(current_user=Depends(get_current_user)): - if not current_user: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + status_code=500, + detail={ + "msg_en": "Failed to restart program.", + "msg_zh": "重启程序失败。", + } ) + + +@router.get("/start", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def start(): try: - return program.start() + resp = program.start() + return u_response(resp) except Exception as e: logger.debug(e) logger.warning("Failed to start program") - raise HTTPException(status_code=500, detail="Failed to start program") - - -@router.get("/stop") -async def stop(current_user=Depends(get_current_user)): - if not current_user: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + status_code=500, + detail={ + "msg_en": "Failed to start program.", + "msg_zh": "启动程序失败。", + } ) - return program.stop() -@router.get("/status") -async def program_status(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) +@router.get("/stop", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def stop(): + return u_response(program.stop()) + + +@router.get("/status", response_model=dict, dependencies=[Depends(get_current_user)]) +async def program_status(): if not program.is_running: - return {"status": "stop"} + return { + "status": False, + "version": VERSION, + "first_run": program.first_run, + } else: - return {"status": "running"} + return { + "status": True, + "version": VERSION, + "first_run": program.first_run, + } -@router.get("/shutdown") -async def shutdown_program(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) +@router.get("/shutdown", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def shutdown_program(): program.stop() logger.info("Shutting down program...") os.kill(os.getpid(), signal.SIGINT) - return {"status": "ok"} + return JSONResponse( + status_code=200, + content={"msg_en": "Shutdown program successfully.", "msg_zh": "关闭程序成功。"}, + ) # Check status -@router.get("/check/downloader", tags=["check"]) -async def check_downloader_status(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) +@router.get("/check/downloader", tags=["check"], response_model=bool, dependencies=[Depends(get_current_user)]) +async def check_downloader_status(): return program.check_downloader() - - -@router.get("/check/rss", tags=["check"]) -async def check_rss_status(current_user=Depends(get_current_user)): - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) - return program.check_analyser() diff --git a/backend/src/module/api/response.py b/backend/src/module/api/response.py new file mode 100644 index 00000000..b0a2dc26 --- /dev/null +++ b/backend/src/module/api/response.py @@ -0,0 +1,14 @@ +from fastapi.responses import JSONResponse +from fastapi.exceptions import HTTPException + +from module.models.response import ResponseModel + + +def u_response(response_model: ResponseModel): + return JSONResponse( + status_code=response_model.status_code, + content={ + "msg_en": response_model.msg_en, + "msg_zh": response_model.msg_zh, + }, + ) \ No newline at end of file diff --git a/backend/src/module/api/rss.py b/backend/src/module/api/rss.py new file mode 100644 index 00000000..2244c67d --- /dev/null +++ b/backend/src/module/api/rss.py @@ -0,0 +1,150 @@ +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse + +from .response import u_response + +from module.models import RSSItem, RSSUpdate, Torrent, APIResponse, Bangumi +from module.rss import RSSEngine, RSSAnalyser +from module.security.api import get_current_user, UNAUTHORIZED +from module.downloader import DownloadClient +from module.manager import SeasonCollector + + +router = APIRouter(prefix="/rss", tags=["rss"]) + + +@router.get(path="", response_model=list[RSSItem], dependencies=[Depends(get_current_user)]) +async def get_rss(): + with RSSEngine() as engine: + return engine.rss.search_all() + + +@router.post(path="/add", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def add_rss(rss: RSSItem): + with RSSEngine() as engine: + result = engine.add_rss(rss.url, rss.name, rss.aggregate, rss.parser) + return u_response(result) + + +@router.post(path="/enable/many", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def enable_many_rss(rss_ids: list[int], ): + with RSSEngine() as engine: + result = engine.enable_list(rss_ids) + return u_response(result) + + +@router.delete(path="/delete/{rss_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def delete_rss(rss_id: int): + with RSSEngine() as engine: + if engine.rss.delete(rss_id): + return JSONResponse( + status_code=200, + content={"msg_en": "Delete RSS successfully.", "msg_zh": "删除 RSS 成功。"}, + ) + else: + return JSONResponse( + status_code=406, + content={"msg_en": "Delete RSS failed.", "msg_zh": "删除 RSS 失败。"}, + ) + + +@router.post(path="/delete/many", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def delete_many_rss(rss_ids: list[int], ): + with RSSEngine() as engine: + result = engine.delete_list(rss_ids) + return u_response(result) + + +@router.patch(path="/disable/{rss_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def disable_rss(rss_id: int): + with RSSEngine() as engine: + if engine.rss.disable(rss_id): + return JSONResponse( + status_code=200, + content={"msg_en": "Disable RSS successfully.", "msg_zh": "禁用 RSS 成功。"}, + ) + else: + return JSONResponse( + status_code=406, + content={"msg_en": "Disable RSS failed.", "msg_zh": "禁用 RSS 失败。"}, + ) + + +@router.post(path="/disable/many", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def disable_many_rss(rss_ids: list[int]): + with RSSEngine() as engine: + result = engine.disable_list(rss_ids) + return u_response(result) + + +@router.patch(path="/update/{rss_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def update_rss( + rss_id: int, data: RSSUpdate, current_user=Depends(get_current_user) +): + if not current_user: + raise UNAUTHORIZED + with RSSEngine() as engine: + if engine.rss.update(rss_id, data): + return JSONResponse( + status_code=200, + content={"msg_en": "Update RSS successfully.", "msg_zh": "更新 RSS 成功。"}, + ) + else: + return JSONResponse( + status_code=406, + content={"msg_en": "Update RSS failed.", "msg_zh": "更新 RSS 失败。"}, + ) + + +@router.get(path="/refresh/all", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def refresh_all(): + with RSSEngine() as engine, DownloadClient() as client: + engine.refresh_rss(client) + return JSONResponse( + status_code=200, + content={"msg_en": "Refresh all RSS successfully.", "msg_zh": "刷新 RSS 成功。"}, + ) + + +@router.get(path="/refresh/{rss_id}", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def refresh_rss(rss_id: int): + with RSSEngine() as engine, DownloadClient() as client: + engine.refresh_rss(client, rss_id) + return JSONResponse( + status_code=200, + content={"msg_en": "Refresh RSS successfully.", "msg_zh": "刷新 RSS 成功。"}, + ) + + +@router.get(path="/torrent/{rss_id}", response_model=list[Torrent], dependencies=[Depends(get_current_user)]) +async def get_torrent(rss_id: int, ): + with RSSEngine() as engine: + return engine.get_rss_torrents(rss_id) + + +# Old API +analyser = RSSAnalyser() + + +@router.post("/analysis", response_model=Bangumi, dependencies=[Depends(get_current_user)]) +async def analysis(rss: RSSItem): + data = analyser.link_to_data(rss) + if isinstance(data, Bangumi): + return data + else: + return u_response(data) + + +@router.post("/collect", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def download_collection(data: Bangumi): + with SeasonCollector() as collector: + resp = collector.collect_season(data, data.rss_link) + return u_response(resp) + + +@router.post("/subscribe", response_model=APIResponse, dependencies=[Depends(get_current_user)]) +async def subscribe(data: Bangumi): + with SeasonCollector() as collector: + resp = collector.subscribe_season(data) + return u_response(resp) + diff --git a/backend/src/module/api/search.py b/backend/src/module/api/search.py new file mode 100644 index 00000000..3ad90984 --- /dev/null +++ b/backend/src/module/api/search.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Query, Depends +from sse_starlette.sse import EventSourceResponse + +from module.searcher import SearchTorrent, SEARCH_CONFIG +from module.security.api import get_current_user, UNAUTHORIZED +from module.models import Bangumi + + +router = APIRouter(prefix="/search", tags=["search"]) + + +@router.get("/bangumi", response_model=list[Bangumi], dependencies=[Depends(get_current_user)]) +async def search_torrents( + site: str = "mikan", + keywords: str = Query(None) +): + """ + Server Send Event for per Bangumi item + """ + if not keywords: + return [] + keywords = keywords.split(" ") + with SearchTorrent() as st: + return EventSourceResponse( + content=st.analyse_keyword(keywords=keywords, site=site), + ) + + +@router.get("/provider", response_model=list[str], dependencies=[Depends(get_current_user)]) +async def search_provider(): + return list(SEARCH_CONFIG.keys()) diff --git a/backend/src/module/checker/checker.py b/backend/src/module/checker/checker.py index 8966c46d..09fc222c 100644 --- a/backend/src/module/checker/checker.py +++ b/backend/src/module/checker/checker.py @@ -1,7 +1,13 @@ -from module.conf import settings +import logging +import requests +from pathlib import Path + +from module.conf import settings, VERSION from module.downloader import DownloadClient from module.models import Config -from module.network import RequestContent +from module.update import version_check + +logger = logging.getLogger(__name__) class Checker: @@ -22,30 +28,50 @@ class Checker: else: return False - @staticmethod - def check_downloader() -> bool: - with DownloadClient() as client: - if client.authed: - return True - else: - return False - - @staticmethod - def check_torrents() -> bool: - with RequestContent() as req: - try: - torrents = req.get_torrents(settings.rss_link, retry=2) - if torrents: - return True - except AttributeError: - link = f"https://mikanani.me/RSS/MyBangumi?token={settings.rss_parser.token}" - if req.get_torrents(link): - return True - return False - @staticmethod def check_first_run() -> bool: if settings.dict() == Config().dict(): return True else: return False + + @staticmethod + def check_version() -> bool: + return version_check() + + @staticmethod + def check_database() -> bool: + db_path = Path("data/data.db") + if not db_path.exists(): + return False + else: + return True + + @staticmethod + def check_downloader() -> bool: + try: + url = f"http://{settings.downloader.host}" if "://" not in settings.downloader.host else f"{settings.downloader.host}" + response = requests.get(url, timeout=2) + if settings.downloader.type in response.text.lower(): + with DownloadClient() as client: + if client.authed: + return True + else: + return False + else: + return False + except requests.exceptions.ReadTimeout: + logger.error("[Checker] Downloader connect timeout.") + return False + except requests.exceptions.ConnectionError: + logger.error("[Checker] Downloader connect failed.") + return False + except Exception as e: + logger.error(f"[Checker] Downloader connect failed: {e}") + return False + + +if __name__ == "__main__": + # print(Checker().check_downloader()) + requests.get("http://162.200.20.1", timeout=2) + diff --git a/backend/src/module/conf/__init__.py b/backend/src/module/conf/__init__.py index 00dbc6fe..9a1b7aef 100644 --- a/backend/src/module/conf/__init__.py +++ b/backend/src/module/conf/__init__.py @@ -2,9 +2,11 @@ from pathlib import Path from .config import VERSION, settings from .log import LOG_PATH, setup_logger +from .search_provider import SEARCH_CONFIG TMDB_API = "32b19d6a05b512190a056fa4e747cbbc" -DATA_PATH = Path("data/data.db") +DATA_PATH = "sqlite:///data/data.db" LEGACY_DATA_PATH = Path("data/data.json") +VERSION_PATH = Path("config/version.info") PLATFORM = "Windows" if "\\" in settings.downloader.path else "Unix" diff --git a/backend/src/module/conf/config.py b/backend/src/module/conf/config.py index 00debcce..1a9cf1f4 100644 --- a/backend/src/module/conf/config.py +++ b/backend/src/module/conf/config.py @@ -53,14 +53,6 @@ class Settings(Config): self.__load_from_env() self.save() - @property - def rss_link(self) -> str: - if "://" not in self.rss_parser.custom_url: - return f"https://{self.rss_parser.custom_url}/RSS/MyBangumi?token={self.rss_parser.token}" - return ( - f"{self.rss_parser.custom_url}/RSS/MyBangumi?token={self.rss_parser.token}" - ) - def __load_from_env(self): config_dict = self.dict() for key, section in ENV_TO_ATTR.items(): @@ -87,5 +79,9 @@ class Settings(Config): else: return os.environ[env] + @property + def group_rules(self): + return self.__dict__["group_rules"] + settings = Settings() diff --git a/backend/src/module/conf/search_provider.py b/backend/src/module/conf/search_provider.py new file mode 100644 index 00000000..610c7f55 --- /dev/null +++ b/backend/src/module/conf/search_provider.py @@ -0,0 +1,23 @@ +from pathlib import Path +from module.utils import json_config + +DEFAULT_PROVIDER = { + "mikan": "https://mikanani.me/RSS/Search?searchstr=%s", + "nyaa": "https://nyaa.si/?page=rss&q=%s&c=0_0&f=0", + "dmhy": "http://dmhy.org/topics/rss/rss.xml?keyword=%s" +} + +PROVIDER_PATH = Path("config/search_provider.json") + + +def load_provider(): + if PROVIDER_PATH.exists(): + return json_config.load(PROVIDER_PATH) + else: + json_config.save(PROVIDER_PATH, DEFAULT_PROVIDER) + return DEFAULT_PROVIDER + + +SEARCH_CONFIG = load_provider() + + diff --git a/backend/src/module/core/program.py b/backend/src/module/core/program.py index 855e52ff..fa7ee3ec 100644 --- a/backend/src/module/core/program.py +++ b/backend/src/module/core/program.py @@ -1,9 +1,9 @@ import logging from module.conf import VERSION, settings -from module.update import data_migration +from module.update import data_migration, from_30_to_31, start_up, first_run +from module.models import ResponseModel -from .rss_feed import add_rss_feed from .sub_thread import RenameThread, RSSThread logger = logging.getLogger(__name__) @@ -33,42 +33,78 @@ class Program(RenameThread, RSSThread): def startup(self): self.__start_info() - if self.first_run: - logger.info("First run detected, please configure the program in webui.") + if not self.database: + first_run() + logger.info("[Core] No db file exists, create database file.") return {"status": "First run detected."} if self.legacy_data: logger.info( - "Legacy data detected, starting data migration, please wait patiently." + "[Core] Legacy data detected, starting data migration, please wait patiently." ) data_migration() + elif self.version_update: + # Update database + from_30_to_31() + logger.info("[Core] Database updated.") self.start() def start(self): - if self.first_run: - return {"status": "Not ready to start."} self.stop_event.clear() settings.load() if self.downloader_status: if self.enable_renamer: self.rename_start() if self.enable_rss: - add_rss_feed() self.rss_start() logger.info("Program running.") - return {"status": "Program started."} + return ResponseModel( + status=True, + status_code=200, + msg_en="Program started.", + msg_zh="程序启动成功。", + ) else: - return {"status": "Can't connect to downloader. Program not paused."} + self.stop_event.set() + logger.warning("Program failed to start.") + return ResponseModel( + status=False, + status_code=406, + msg_en="Program failed to start.", + msg_zh="程序启动失败。", + ) def stop(self): if self.is_running: self.stop_event.set() self.rename_stop() self.rss_stop() - return {"status": "Program stopped."} + return ResponseModel( + status=True, + status_code=200, + msg_en="Program stopped.", + msg_zh="程序停止成功。", + ) else: - return {"status": "Program is not running."} + return ResponseModel( + status=False, + status_code=406, + msg_en="Program is not running.", + msg_zh="程序未运行。", + ) def restart(self): self.stop() self.start() - return {"status": "Program restarted."} + return ResponseModel( + status=True, + status_code=200, + msg_en="Program restarted.", + msg_zh="程序重启成功。", + ) + + def update_database(self): + if not self.version_update: + return {"status": "No update found."} + else: + start_up() + return {"status": "Database updated."} diff --git a/backend/src/module/core/rss_feed.py b/backend/src/module/core/rss_feed.py deleted file mode 100644 index 82006602..00000000 --- a/backend/src/module/core/rss_feed.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging - -from module.conf import settings -from module.downloader import DownloadClient - -logger = logging.getLogger(__name__) - - -def add_rss_feed(): - with DownloadClient() as client: - # Check Feed if exists - add = True - remove = False - feeds = client.get_rss_feed() - for item_path, value in feeds.items(): - if value.url == settings.rss_link: - add = False - break - elif item_path == "Mikan_RSS": - remove = True - if remove: - client.remove_rss_feed("Mikan_RSS") - logger.info("Remove Old RSS Feed: Mikan_RSS") - # Add Feed - if add: - client.add_rss_feed(settings.rss_link) - logger.info(f"Add RSS Feed: {settings.rss_link}") - - -if __name__ == "__main__": - from module.conf import setup_logger - - setup_logger() - add_rss_feed() diff --git a/backend/src/module/core/status.py b/backend/src/module/core/status.py index 9d98daaf..5e55e42a 100644 --- a/backend/src/module/core/status.py +++ b/backend/src/module/core/status.py @@ -31,12 +31,6 @@ class ProgramStatus(Checker): self._downloader_status = self.check_downloader() return self._downloader_status - @property - def torrents_status(self): - if not self._torrents_status: - self._torrents_status = self.check_torrents() - return self._torrents_status - @property def enable_rss(self): return self.check_analyser() @@ -52,3 +46,11 @@ class ProgramStatus(Checker): @property def legacy_data(self): return LEGACY_DATA_PATH.exists() + + @property + def version_update(self): + return not self.check_version() + + @property + def database(self): + return self.check_database() diff --git a/backend/src/module/core/sub_thread.py b/backend/src/module/core/sub_thread.py index 26eaa676..4968a2c2 100644 --- a/backend/src/module/core/sub_thread.py +++ b/backend/src/module/core/sub_thread.py @@ -2,11 +2,10 @@ import threading import time from module.conf import settings -from module.database import BangumiDatabase from module.downloader import DownloadClient from module.manager import Renamer, eps_complete from module.notification import PostNotification -from module.rss import analyser +from module.rss import RSSAnalyser, RSSEngine from .status import ProgramStatus @@ -17,21 +16,17 @@ class RSSThread(ProgramStatus): self._rss_thread = threading.Thread( target=self.rss_loop, ) + self.analyser = RSSAnalyser() def rss_loop(self): - with DownloadClient() as client: - client.init_downloader() while not self.stop_event.is_set(): - # Analyse RSS - with BangumiDatabase() as db: - new_data = analyser.rss_to_data(rss_link=settings.rss_link, database=db) - if new_data: - db.insert_list(new_data) - bangumi_list = db.not_added() - if bangumi_list: - with DownloadClient() as client: - client.set_rules(bangumi_list) - db.update_list(bangumi_list) + with DownloadClient() as client, RSSEngine() as engine: + # Analyse RSS + rss_list = engine.rss.search_aggregate() + for rss in rss_list: + self.analyser.rss_to_data(rss, engine) + # Run RSS Engine + engine.refresh_rss(client) if settings.bangumi_manage.eps_complete: eps_complete() self.stop_event.wait(settings.program.rss_time) diff --git a/backend/src/module/database/__init__.py b/backend/src/module/database/__init__.py index c5d59b08..87d8425a 100644 --- a/backend/src/module/database/__init__.py +++ b/backend/src/module/database/__init__.py @@ -1 +1,2 @@ -from .bangumi import BangumiDatabase +from .combine import Database +from .engine import engine diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index dd25edb5..d9d3db09 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -1,121 +1,110 @@ import logging -from module.database.orm import Connector -from module.models import BangumiData -from module.conf import DATA_PATH +from sqlmodel import Session, select, delete, or_, and_ +from sqlalchemy.sql import func +from typing import Optional + +from module.models import Bangumi, BangumiUpdate logger = logging.getLogger(__name__) -class BangumiDatabase(Connector): - def __init__(self, database: str = DATA_PATH): - super().__init__( - table_name="bangumi", - data=self.__data_to_db(BangumiData()), - database=database, - ) +class BangumiDatabase: + def __init__(self, session: Session): + self.session = session - def update_table(self): - self.update.table() - - @staticmethod - def __data_to_db(data: BangumiData) -> dict: - db_data = data.dict() - for key, value in db_data.items(): - if isinstance(value, bool): - db_data[key] = int(value) - elif isinstance(value, list): - db_data[key] = ",".join(value) - return db_data - - @staticmethod - def __db_to_data(db_data: dict) -> BangumiData: - for key, item in db_data.items(): - if isinstance(item, int): - if key not in ["id", "offset", "season", "year"]: - db_data[key] = bool(item) - elif key in ["filter", "rss_link"]: - db_data[key] = item.split(",") - return BangumiData(**db_data) - - def insert_one(self, data: BangumiData): - db_data = self.__data_to_db(data) - self.insert.one(db_data) + def add(self, data: Bangumi): + self.session.add(data) + self.session.commit() logger.debug(f"[Database] Insert {data.official_title} into database.") - # if self.__check_exist(data): - # self.update_one(data) - # else: - # db_data = self.__data_to_db(data) - # db_data["id"] = self.gen_id() - # self._insert(db_data=db_data, table_name=self.__table_name) - # logger.debug(f"[Database] Insert {data.official_title} into database.") - def insert_list(self, data: list[BangumiData]): - data_list = [self.__data_to_db(x) for x in data] - self.insert.many(data_list) - # _id = self.gen_id() - # for i, item in enumerate(data): - # item.id = _id + i - # data_list = [self.__data_to_db(x) for x in data] - # self._insert_list(data_list=data_list, table_name=self.__table_name) - logger.debug(f"[Database] Insert {len(data)} bangumi into database.") + def add_all(self, datas: list[Bangumi]): + self.session.add_all(datas) + self.session.commit() + logger.debug(f"[Database] Insert {len(datas)} bangumi into database.") - def update_one(self, data: BangumiData) -> bool: - db_data = self.__data_to_db(data) - return self.update.one(db_data) + def update(self, data: Bangumi | BangumiUpdate, _id: int = None) -> bool: + if _id and isinstance(data, BangumiUpdate): + db_data = self.session.get(Bangumi, _id) + elif isinstance(data, Bangumi): + db_data = self.session.get(Bangumi, data.id) + else: + return False + if not db_data: + return False + bangumi_data = data.dict(exclude_unset=True) + for key, value in bangumi_data.items(): + setattr(db_data, key, value) + self.session.add(db_data) + self.session.commit() + self.session.refresh(db_data) + logger.debug(f"[Database] Update {data.official_title}") + return True - def update_list(self, data: list[BangumiData]): - data_list = [self.__data_to_db(x) for x in data] - self.update.many(data_list) + def update_all(self, datas: list[Bangumi]): + self.session.add_all(datas) + self.session.commit() + logger.debug(f"[Database] Update {len(datas)} bangumi.") def update_rss(self, title_raw, rss_set: str): # Update rss and added - location = {"title_raw": title_raw} - set_value = {"rss_link": rss_set, "added": 0} - self.update.value(location, set_value) + statement = select(Bangumi).where(Bangumi.title_raw == title_raw) + bangumi = self.session.exec(statement).first() + bangumi.rss_link = rss_set + bangumi.added = False + self.session.add(bangumi) + self.session.commit() + self.session.refresh(bangumi) logger.debug(f"[Database] Update {title_raw} rss_link to {rss_set}.") def update_poster(self, title_raw, poster_link: str): - location = {"title_raw": title_raw} - set_value = {"poster_link": poster_link} - self.update.value(location, set_value) + statement = select(Bangumi).where(Bangumi.title_raw == title_raw) + bangumi = self.session.exec(statement).first() + bangumi.poster_link = poster_link + self.session.add(bangumi) + self.session.commit() + self.session.refresh(bangumi) logger.debug(f"[Database] Update {title_raw} poster_link to {poster_link}.") def delete_one(self, _id: int): - self.delete.one(_id) + statement = select(Bangumi).where(Bangumi.id == _id) + bangumi = self.session.exec(statement).first() + self.session.delete(bangumi) + self.session.commit() logger.debug(f"[Database] Delete bangumi id: {_id}.") def delete_all(self): - self.delete.all() + statement = delete(Bangumi) + self.session.exec(statement) + self.session.commit() - def search_all(self) -> list[BangumiData]: - all_data = self.select.all() - return [self.__db_to_data(x) for x in all_data] + def search_all(self) -> list[Bangumi]: + statement = select(Bangumi) + return self.session.exec(statement).all() - def search_id(self, _id: int) -> BangumiData | None: - dict_data = self.select.one(conditions={"id": _id}) - if dict_data is None: + def search_id(self, _id: int) -> Optional[Bangumi]: + statement = select(Bangumi).where(Bangumi.id == _id) + bangumi = self.session.exec(statement).first() + if bangumi is None: logger.warning(f"[Database] Cannot find bangumi id: {_id}.") return None - logger.debug(f"[Database] Find bangumi id: {_id}.") - return self.__db_to_data(dict_data) + else: + logger.debug(f"[Database] Find bangumi id: {_id}.") + return self.session.exec(statement).first() def match_poster(self, bangumi_name: str) -> str: - condition = {"official_title": bangumi_name} - keys = ["poster_link"] - data = self.select.one( - keys=keys, - conditions=condition, - combine_operator="INSTR", + # Use like to match + statement = select(Bangumi).where( + func.instr(bangumi_name, Bangumi.official_title) > 0 ) - if not data: + data = self.session.exec(statement).first() + if data: + return data.poster_link + else: return "" - return data.get("poster_link") def match_list(self, torrent_list: list, rss_link: str) -> list: - # Match title_raw in database - keys = ["title_raw", "rss_link", "poster_link"] - match_datas = self.select.column(keys) + match_datas = self.search_all() if not match_datas: return torrent_list # Match title @@ -123,36 +112,51 @@ class BangumiDatabase(Connector): while i < len(torrent_list): torrent = torrent_list[i] for match_data in match_datas: - if match_data.get("title_raw") in torrent.name: - if rss_link not in match_data.get("rss_link"): - match_data["rss_link"] += f",{rss_link}" - self.update_rss( - match_data.get("title_raw"), match_data.get("rss_link") - ) - if not match_data.get("poster_link"): - self.update_poster( - match_data.get("title_raw"), torrent.poster_link - ) + if match_data.title_raw in torrent.name: + if rss_link not in match_data.rss_link: + match_data.rss_link += f",{rss_link}" + self.update_rss(match_data.title_raw, match_data.rss_link) + # if not match_data.poster_link: + # self.update_poster(match_data.title_raw, torrent.poster_link) torrent_list.pop(i) break else: i += 1 return torrent_list - def not_complete(self) -> list[BangumiData]: - # Find eps_complete = False - condition = {"eps_collect": 0} - dict_data = self.select.many( - conditions=condition, + def match_torrent(self, torrent_name: str) -> Optional[Bangumi]: + statement = select(Bangumi).where( + and_( + func.instr(torrent_name, Bangumi.title_raw) > 0, + Bangumi.deleted == False, + ) ) - return [self.__db_to_data(x) for x in dict_data] + return self.session.exec(statement).first() - def not_added(self) -> list[BangumiData]: - conditions = {"added": 0, "rule_name": None, "save_path": None} - dict_data = self.select.many(conditions=conditions, combine_operator="OR") - return [self.__db_to_data(x) for x in dict_data] + def not_complete(self) -> list[Bangumi]: + # Find eps_complete = False + condition = select(Bangumi).where(Bangumi.eps_collect == False) + datas = self.session.exec(condition).all() + return datas + def not_added(self) -> list[Bangumi]: + conditions = select(Bangumi).where( + or_( + Bangumi.added == 0, Bangumi.rule_name is None, Bangumi.save_path is None + ) + ) + datas = self.session.exec(conditions).all() + return datas -if __name__ == "__main__": - with BangumiDatabase() as db: - print(db.match_poster("久保")) + def disable_rule(self, _id: int): + statement = select(Bangumi).where(Bangumi.id == _id) + bangumi = self.session.exec(statement).first() + bangumi.deleted = True + self.session.add(bangumi) + self.session.commit() + self.session.refresh(bangumi) + logger.debug(f"[Database] Disable rule {bangumi.title_raw}.") + + def search_rss(self, rss_link: str) -> list[Bangumi]: + statement = select(Bangumi).where(func.instr(rss_link, Bangumi.rss_link) > 0) + return self.session.exec(statement).all() diff --git a/backend/src/module/database/combine.py b/backend/src/module/database/combine.py new file mode 100644 index 00000000..41550134 --- /dev/null +++ b/backend/src/module/database/combine.py @@ -0,0 +1,44 @@ +from sqlmodel import Session, SQLModel + +from .rss import RSSDatabase +from .torrent import TorrentDatabase +from .bangumi import BangumiDatabase +from .user import UserDatabase +from .engine import engine as e + +from module.models import User, Bangumi + + +class Database(Session): + def __init__(self, engine=e): + self.engine = engine + super().__init__(engine) + self.rss = RSSDatabase(self) + self.torrent = TorrentDatabase(self) + self.bangumi = BangumiDatabase(self) + self.user = UserDatabase(self) + + def create_table(self): + SQLModel.metadata.create_all(self.engine) + + def drop_table(self): + SQLModel.metadata.drop_all(self.engine) + + def migrate(self): + # Run migration online + bangumi_data = self.bangumi.search_all() + user_data = self.exec("SELECT * FROM user").all() + readd_bangumi = [] + for bangumi in bangumi_data: + dict_data = bangumi.dict() + del dict_data["id"] + readd_bangumi.append(Bangumi(**dict_data)) + self.drop_table() + self.create_table() + self.commit() + bangumi_data = self.bangumi.search_all() + self.bangumi.add_all(readd_bangumi) + self.add(User(**user_data[0])) + self.commit() + + diff --git a/backend/src/module/database/connector.py b/backend/src/module/database/connector.py deleted file mode 100644 index 506bb7b1..00000000 --- a/backend/src/module/database/connector.py +++ /dev/null @@ -1,174 +0,0 @@ -import logging -import os -import sqlite3 - -from module.conf import DATA_PATH - -logger = logging.getLogger(__name__) - - -class DataConnector: - def __init__(self): - # Create folder if not exists - DATA_PATH.parent.mkdir(parents=True, exist_ok=True) - - self._conn = sqlite3.connect(DATA_PATH) - self._cursor = self._conn.cursor() - - def _update_table(self, table_name: str, db_data: dict): - columns = ", ".join( - [ - f"{key} {self.__python_to_sqlite_type(value)}" - for key, value in db_data.items() - ] - ) - create_table_sql = f"CREATE TABLE IF NOT EXISTS {table_name} ({columns});" - self._cursor.execute(create_table_sql) - self._cursor.execute(f"PRAGMA table_info({table_name})") - existing_columns = { - column_info[1]: column_info for column_info in self._cursor.fetchall() - } - for key, value in db_data.items(): - if key not in existing_columns: - insert_column = self.__python_to_sqlite_type(value) - if value is None: - value = "NULL" - add_column_sql = f"ALTER TABLE {table_name} ADD COLUMN {key} {insert_column} DEFAULT {value};" - self._cursor.execute(add_column_sql) - self._conn.commit() - logger.debug(f"Create / Update table {table_name}.") - - def _insert(self, table_name: str, db_data: dict): - columns = ", ".join(db_data.keys()) - values = ", ".join([f":{key}" for key in db_data.keys()]) - self._cursor.execute( - f"INSERT INTO {table_name} ({columns}) VALUES ({values})", db_data - ) - self._conn.commit() - - def _insert_list(self, table_name: str, data_list: list[dict]): - columns = ", ".join(data_list[0].keys()) - values = ", ".join([f":{key}" for key in data_list[0].keys()]) - self._cursor.executemany( - f"INSERT INTO {table_name} ({columns}) VALUES ({values})", data_list - ) - self._conn.commit() - - def _select(self, keys: list[str], table_name: str, condition: str = None) -> dict: - if condition is None: - self._cursor.execute(f"SELECT {', '.join(keys)} FROM {table_name}") - else: - self._cursor.execute( - f"SELECT {', '.join(keys)} FROM {table_name} WHERE {condition}" - ) - return dict(zip(keys, self._cursor.fetchone())) - - def _update(self, table_name: str, db_data: dict): - _id = db_data.get("id") - if _id is None: - raise ValueError("No _id in db_data.") - set_sql = ", ".join([f"{key} = :{key}" for key in db_data.keys()]) - self._cursor.execute( - f"UPDATE {table_name} SET {set_sql} WHERE id = {_id}", db_data - ) - self._conn.commit() - return self._cursor.rowcount == 1 - - def _update_list(self, table_name: str, data_list: list[dict]): - if len(data_list) == 0: - return - set_sql = ", ".join( - [f"{key} = :{key}" for key in data_list[0].keys() if key != "id"] - ) - self._cursor.executemany( - f"UPDATE {table_name} SET {set_sql} WHERE id = :id", data_list - ) - self._conn.commit() - - def _update_section(self, table_name: str, location: dict, update_dict: dict): - set_sql = ", ".join([f"{key} = :{key}" for key in update_dict.keys()]) - sql_loc = f"{location['key']} = {location['value']}" - self._cursor.execute( - f"UPDATE {table_name} SET {set_sql} WHERE {sql_loc}", update_dict - ) - self._conn.commit() - - def _delete_all(self, table_name: str): - self._cursor.execute(f"DELETE FROM {table_name}") - self._conn.commit() - - def _delete(self, table_name: str, condition: dict): - condition_sql = " AND ".join([f"{key} = :{key}" for key in condition.keys()]) - self._cursor.execute( - f"DELETE FROM {table_name} WHERE {condition_sql}", condition - ) - self._conn.commit() - - def _search( - self, table_name: str, keys: list[str] | None = None, condition: dict = None - ): - if keys is None: - select_sql = "*" - else: - select_sql = ", ".join(keys) - if condition is None: - self._cursor.execute(f"SELECT {select_sql} FROM {table_name}") - else: - custom_condition = condition.pop("_custom_condition", None) - condition_sql = " AND ".join( - [f"{key} = :{key}" for key in condition.keys()] - ) + (f" AND {custom_condition}" if custom_condition else "") - self._cursor.execute( - f"SELECT {select_sql} FROM {table_name} WHERE {condition_sql}", - condition, - ) - - def _search_data( - self, table_name: str, keys: list[str] | None = None, condition: dict = None - ) -> dict: - if keys is None: - keys = self.__get_table_columns(table_name) - self._search(table_name, keys, condition) - return dict(zip(keys, self._cursor.fetchone())) - - def _search_datas( - self, table_name: str, keys: list[str] | None = None, condition: dict = None - ) -> list[dict]: - if keys is None: - keys = self.__get_table_columns(table_name) - self._search(table_name, keys, condition) - return [dict(zip(keys, row)) for row in self._cursor.fetchall()] - - def _table_exists(self, table_name: str) -> bool: - self._cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name=?;", - (table_name,), - ) - return len(self._cursor.fetchall()) == 1 - - def __get_table_columns(self, table_name: str) -> list[str]: - self._cursor.execute(f"PRAGMA table_info({table_name})") - return [column_info[1] for column_info in self._cursor.fetchall()] - - @staticmethod - def __python_to_sqlite_type(value) -> str: - if isinstance(value, int): - return "INTEGER NOT NULL" - elif isinstance(value, float): - return "REAL NOT NULL" - elif isinstance(value, str): - return "TEXT NOT NULL" - elif isinstance(value, bool): - return "INTEGER NOT NULL" - elif isinstance(value, list): - return "TEXT NOT NULL" - elif value is None: - return "TEXT" - else: - raise ValueError(f"Unsupported data type: {type(value)}") - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self._conn.close() diff --git a/backend/src/module/database/engine.py b/backend/src/module/database/engine.py new file mode 100644 index 00000000..94fa37b0 --- /dev/null +++ b/backend/src/module/database/engine.py @@ -0,0 +1,7 @@ +from sqlmodel import create_engine, Session +from module.conf import DATA_PATH + + +engine = create_engine(DATA_PATH) + +db_session = Session(engine) diff --git a/backend/src/module/database/orm/__init__.py b/backend/src/module/database/orm/__init__.py deleted file mode 100644 index 4b56580f..00000000 --- a/backend/src/module/database/orm/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .connector import Connector diff --git a/backend/src/module/database/orm/connector.py b/backend/src/module/database/orm/connector.py deleted file mode 100644 index 7f10106a..00000000 --- a/backend/src/module/database/orm/connector.py +++ /dev/null @@ -1,71 +0,0 @@ -from os import PathLike -from pathlib import Path -import sqlite3 - -from .delete import Delete -from .insert import Insert -from .select import Select -from .update import Update - -from module.conf import DATA_PATH - - -class Connector: - def __init__( - self, table_name: str, data: dict, database: PathLike[str] | Path = DATA_PATH - ): - # Create folder if not exists - if isinstance(database, (PathLike, str)): - database = Path(database) - database.parent.mkdir(parents=True, exist_ok=True) - - self._conn = sqlite3.connect(database) - self._cursor = self._conn.cursor() - self.update = Update(self, table_name, data) - self.insert = Insert(self, table_name, data) - self.select = Select(self, table_name, data) - self.delete = Delete(self, table_name, data) - self._columns = self.__get_columns(table_name) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self._conn.close() - - def __get_columns(self, table_name: str) -> list[str]: - self._cursor.execute(f"PRAGMA table_info({table_name})") - return [x[1] for x in self._cursor.fetchall()] - - def execute(self, sql: str, params: tuple = None): - if params is None: - self._cursor.execute(sql) - else: - self._cursor.execute(sql, params) - self._conn.commit() - - def executemany(self, sql: str, params: list[tuple]): - self._cursor.executemany(sql, params) - self._conn.commit() - - def fetchall(self, keys: str = None) -> list[dict]: - datas = self._cursor.fetchall() - if keys: - return [dict(zip(keys, data)) for data in datas] - return [dict(zip(self._columns, data)) for data in datas] - - def fetchone(self, keys: list[str] = None) -> dict: - data = self._cursor.fetchone() - if data: - if keys: - return dict(zip(keys, data)) - return dict(zip(self._columns, data)) - - def fetchmany(self, keys: list[str], size: int) -> list[dict]: - datas = self._cursor.fetchmany(size) - if keys: - return [dict(zip(keys, data)) for data in datas] - return [dict(zip(self._columns, data)) for data in datas] - - def fetch(self): - return self._cursor.fetchall() diff --git a/backend/src/module/database/orm/delete.py b/backend/src/module/database/orm/delete.py deleted file mode 100644 index aa5a705b..00000000 --- a/backend/src/module/database/orm/delete.py +++ /dev/null @@ -1,23 +0,0 @@ -class Delete: - def __init__(self, connector, table_name: str, data: dict): - self._connector = connector - self._table_name = table_name - self._data = data - - def one(self, _id: int) -> bool: - self._connector.execute( - f""" - DELETE FROM {self._table_name} - WHERE id = :id - """, - {"id": _id}, - ) - return True - - def all(self): - self._connector.execute( - f""" - DELETE FROM {self._table_name} - """, - ) - return True diff --git a/backend/src/module/database/orm/insert.py b/backend/src/module/database/orm/insert.py deleted file mode 100644 index d78c6ca7..00000000 --- a/backend/src/module/database/orm/insert.py +++ /dev/null @@ -1,33 +0,0 @@ -class Insert: - def __init__(self, connector, table_name: str, data: dict): - self._connector = connector - self._table_name = table_name - self._columns = data.items() - - def __gen_id(self) -> int: - self._connector.execute( - f""" - SELECT MAX(id) FROM {self._table_name} - """, - ) - max_id = self._connector.fetchone(keys=["id"]).get("id") - if max_id is None: - return 1 - return max_id + 1 - - def one(self, data: dict): - _id = self.__gen_id() - data["id"] = _id - columns = ", ".join(data.keys()) - placeholders = ", ".join([f":{key}" for key in data.keys()]) - self._connector.execute( - f""" - INSERT INTO {self._table_name} ({columns}) - VALUES ({placeholders}) - """, - data, - ) - - def many(self, data: list[dict]): - for item in data: - self.one(item) diff --git a/backend/src/module/database/orm/select.py b/backend/src/module/database/orm/select.py deleted file mode 100644 index 198c2ff5..00000000 --- a/backend/src/module/database/orm/select.py +++ /dev/null @@ -1,96 +0,0 @@ -class Select: - def __init__(self, connector, table_name: str, data: dict): - self._connector = connector - self._table_name = table_name - self._data = data - - def id(self, _id: int): - self._connector.execute( - f""" - SELECT * FROM {self._table_name} - WHERE id = :id - """, - {"id": _id}, - ) - return self._connector.fetchone() - - def all(self, limit: int = None): - if limit is None: - limit = 10000 - self._connector.execute( - f""" - SELECT * FROM {self._table_name} LIMIT {limit} - """, - ) - return self._connector.fetchall() - - def one( - self, - keys: list[str] | None = None, - conditions: dict = None, - combine_operator: str = "AND", - ): - if keys is None: - columns = "*" - else: - columns = ", ".join(keys) - condition_sql = self.__select_condition(conditions, combine_operator) - self._connector.execute( - f""" - SELECT {columns} FROM {self._table_name} - WHERE {condition_sql} - """, - conditions, - ) - return self._connector.fetchone(keys) - - def many( - self, - keys: list[str] | None = None, - conditions: dict = None, - combine_operator: str = "AND", - limit: int = None, - ): - if keys is None: - columns = "*" - else: - columns = ", ".join(keys) - if limit is None: - limit = 10000 - condition_sql = self.__select_condition(conditions, combine_operator) - self._connector.execute( - f""" - SELECT {columns} FROM {self._table_name} - WHERE {condition_sql} - LIMIT {limit} - """, - conditions, - ) - return self._connector.fetchall(keys) - - def column(self, keys: list[str]): - columns = ", ".join(keys) - self._connector.execute( - f""" - SELECT {columns} FROM {self._table_name} - """, - ) - return self._connector.fetchall(keys) - - @staticmethod - def __select_condition(conditions: dict, combine_operator: str = "AND"): - if not conditions: - raise ValueError("No conditions provided.") - if combine_operator not in ["AND", "OR", "INSTR"]: - raise ValueError( - "Invalid combine_operator, must be 'AND' or 'OR' or 'INSTR'." - ) - if combine_operator == "INSTR": - condition_sql = f" AND ".join( - [f"INSTR({key}, :{key})" for key in conditions.keys()] - ) - else: - condition_sql = f" {combine_operator} ".join( - [f"{key} = :{key}" for key in conditions.keys()] - ) - return condition_sql diff --git a/backend/src/module/database/orm/update.py b/backend/src/module/database/orm/update.py deleted file mode 100644 index 7b022418..00000000 --- a/backend/src/module/database/orm/update.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging - -logger = logging.getLogger(__name__) - - -class Update: - def __init__(self, connector, table_name: str, data: dict): - self._connector = connector - self._table_name = table_name - self._example_data = data - - def __table_exists(self) -> bool: - self._connector.execute( - f""" - SELECT name FROM sqlite_master - WHERE type='table' AND name='{self._table_name}' - """ - ) - return self._connector.fetch() is not None - - def table(self): - columns = ", ".join( - [ - f"{key} {self.__python_to_sqlite_type(value)}" - for key, value in self._example_data.items() - ] - ) - create_table_sql = f"CREATE TABLE IF NOT EXISTS {self._table_name} ({columns});" - self._connector.execute(create_table_sql) - logger.debug(f"Create table {self._table_name}.") - self._connector.execute(f"PRAGMA table_info({self._table_name})") - existing_columns = [x[1] for x in self._connector.fetch()] - for key, value in self._example_data.items(): - if key not in existing_columns: - insert_column = self.__python_to_sqlite_type(value) - if value is None: - value = "NULL" - add_column_sql = f"ALTER TABLE {self._table_name} ADD COLUMN {key} {insert_column} DEFAULT {value};" - self._connector.execute(add_column_sql) - logger.debug(f"Update table {self._table_name}.") - - def one(self, data: dict) -> bool: - _id = data["id"] - set_sql = ", ".join([f"{key} = :{key}" for key in data.keys()]) - self._connector.execute( - f""" - UPDATE {self._table_name} - SET {set_sql} - WHERE id = :id - """, - data, - ) - logger.debug(f"Update {_id} in {self._table_name}.") - return True - - def many(self, data: list[dict]) -> bool: - columns = ", ".join([f"{key} = :{key}" for key in data[0].keys()]) - self._connector.executemany( - f""" - UPDATE {self._table_name} - SET {columns} - WHERE id = :id - """, - data, - ) - logger.debug(f"Update {self._table_name}.") - return True - - def value(self, location: dict, set_value: dict) -> bool: - set_sql = ", ".join([f"{key} = :{key}" for key in set_value.keys()]) - params = {**location, **set_value} - self._connector.execute( - f""" - UPDATE {self._table_name} - SET {set_sql} - WHERE {location["key"]} = :{location["key"]} - """, - params, - ) - logger.debug(f"Update {self._table_name}.") - return True - - @staticmethod - def __python_to_sqlite_type(value) -> str: - if isinstance(value, int): - return "INTEGER NOT NULL" - elif isinstance(value, float): - return "REAL NOT NULL" - elif isinstance(value, str): - return "TEXT NOT NULL" - elif isinstance(value, bool): - return "INTEGER NOT NULL" - elif isinstance(value, list): - return "TEXT NOT NULL" - elif value is None: - return "TEXT" - else: - raise ValueError(f"Unsupported data type: {type(value)}") diff --git a/backend/src/module/database/rss.py b/backend/src/module/database/rss.py new file mode 100644 index 00000000..cfc229b2 --- /dev/null +++ b/backend/src/module/database/rss.py @@ -0,0 +1,97 @@ +import logging + +from sqlmodel import Session, select, delete, and_ + +from module.models import RSSItem, RSSUpdate + +logger = logging.getLogger(__name__) + + +class RSSDatabase: + def __init__(self, session: Session): + self.session = session + + def add(self, data: RSSItem): + # Check if exists + statement = select(RSSItem).where(RSSItem.url == data.url) + db_data = self.session.exec(statement).first() + if db_data: + logger.debug(f"RSS Item {data.url} already exists.") + return False + else: + logger.debug(f"RSS Item {data.url} not exists, adding...") + self.session.add(data) + self.session.commit() + self.session.refresh(data) + return True + + def add_all(self, data: list[RSSItem]): + for item in data: + self.add(item) + + def update(self, _id: int, data: RSSUpdate): + # Check if exists + statement = select(RSSItem).where(RSSItem.id == _id) + db_data = self.session.exec(statement).first() + if not db_data: + return False + # Update + dict_data = data.dict(exclude_unset=True) + for key, value in dict_data.items(): + setattr(db_data, key, value) + self.session.add(db_data) + self.session.commit() + self.session.refresh(db_data) + return True + + def enable(self, _id: int): + statement = select(RSSItem).where(RSSItem.id == _id) + db_data = self.session.exec(statement).first() + if not db_data: + return False + db_data.enabled = True + self.session.add(db_data) + self.session.commit() + self.session.refresh(db_data) + return True + + def disable(self, _id: int): + statement = select(RSSItem).where(RSSItem.id == _id) + db_data = self.session.exec(statement).first() + if not db_data: + return False + db_data.enabled = False + self.session.add(db_data) + self.session.commit() + self.session.refresh(db_data) + return True + + + def search_id(self, _id: int) -> RSSItem: + return self.session.get(RSSItem, _id) + + def search_all(self) -> list[RSSItem]: + return self.session.exec(select(RSSItem)).all() + + def search_active(self) -> list[RSSItem]: + return self.session.exec(select(RSSItem).where(RSSItem.enabled)).all() + + def search_aggregate(self) -> list[RSSItem]: + return self.session.exec( + select(RSSItem).where(and_(RSSItem.aggregate, RSSItem.enabled)) + ).all() + + def delete(self, _id: int) -> bool: + condition = delete(RSSItem).where(RSSItem.id == _id) + try: + self.session.exec(condition) + self.session.commit() + return True + except Exception as e: + logger.error("Delete RSS Item failed.") + return False + + def delete_all(self): + condition = delete(RSSItem) + self.session.exec(condition) + self.session.commit() diff --git a/backend/src/module/database/torrent.py b/backend/src/module/database/torrent.py index bbdf0f8c..c35dbadd 100644 --- a/backend/src/module/database/torrent.py +++ b/backend/src/module/database/torrent.py @@ -1,47 +1,57 @@ import logging -from .connector import DataConnector +from sqlmodel import Session, select + +from module.models import Torrent logger = logging.getLogger(__name__) -class TorrentDatabase(DataConnector): - def update_table(self): - table_name = "torrent" - db_data = self.__data_to_db() - self._update_table(table_name, db_data) +class TorrentDatabase: + def __init__(self, session: Session): + self.session = session - def __data_to_db(self, data: SaveTorrent): - db_data = data.dict() - for key, value in db_data.items(): - if isinstance(value, bool): - db_data[key] = int(value) - elif isinstance(value, list): - db_data[key] = ",".join(value) - return db_data + def add(self, data: Torrent): + self.session.add(data) + self.session.commit() + self.session.refresh(data) + logger.debug(f"Insert {data.name} in database.") - def __db_to_data(self, db_data: dict): - for key, item in db_data.items(): - if isinstance(item, int): - if key not in ["id", "offset", "season", "year"]: - db_data[key] = bool(item) - elif key in ["filter", "rss_link"]: - db_data[key] = item.split(",") - return SaveTorrent(**db_data) + def add_all(self, datas: list[Torrent]): + self.session.add_all(datas) + self.session.commit() + logger.debug(f"Insert {len(datas)} torrents in database.") - def if_downloaded(self, torrent_url: str, torrent_name: str) -> bool: - self._cursor.execute( - "SELECT * FROM torrent WHERE torrent_url = ? OR torrent_name = ?", - (torrent_url, torrent_name), - ) - return bool(self._cursor.fetchone()) + def update(self, data: Torrent): + self.session.add(data) + self.session.commit() + self.session.refresh(data) + logger.debug(f"Update {data.name} in database.") - def insert(self, data: SaveTorrent): - db_data = self.__data_to_db(data) - columns = ", ".join(db_data.keys()) - values = ", ".join([f":{key}" for key in db_data.keys()]) - self._cursor.execute( - f"INSERT INTO torrent ({columns}) VALUES ({values})", db_data - ) - logger.debug(f"Add {data.torrent_name} into database.") - self._conn.commit() + def update_all(self, datas: list[Torrent]): + self.session.add_all(datas) + self.session.commit() + + def update_one_user(self, data: Torrent): + self.session.add(data) + self.session.commit() + self.session.refresh(data) + logger.debug(f"Update {data.name} in database.") + + def search(self, _id: int) -> Torrent: + return self.session.exec(select(Torrent).where(Torrent.id == _id)).first() + + def search_all(self) -> list[Torrent]: + return self.session.exec(select(Torrent)).all() + + def search_rss(self, rss_id: int) -> list[Torrent]: + return self.session.exec(select(Torrent).where(Torrent.rss_id == rss_id)).all() + + def check_new(self, torrents_list: list[Torrent]) -> list[Torrent]: + new_torrents = [] + old_torrents = self.search_all() + old_urls = [t.url for t in old_torrents] + for torrent in torrents_list: + if torrent.url not in old_urls: + new_torrents.append(torrent) + return new_torrents diff --git a/backend/src/module/database/user.py b/backend/src/module/database/user.py index 69dd9dea..fdfa6464 100644 --- a/backend/src/module/database/user.py +++ b/backend/src/module/database/user.py @@ -2,72 +2,101 @@ import logging from fastapi import HTTPException -from module.database.connector import DataConnector -from module.models.user import User +from module.models.user import User, UserUpdate, UserLogin +from module.models import ResponseModel from module.security.jwt import get_password_hash, verify_password +from sqlmodel import Session, select logger = logging.getLogger(__name__) -class AuthDB(DataConnector): - def __init__(self): - super().__init__() - self.__table_name = "user" - if not self._table_exists(self.__table_name): - self.__update_table() - - def __update_table(self): - db_data = self.__data_to_db(User()) - self._update_table(self.__table_name, db_data) - self._insert(self.__table_name, db_data) - - @staticmethod - def __data_to_db(data: User) -> dict: - db_data = data.dict() - db_data["password"] = get_password_hash(db_data["password"]) - return db_data - - @staticmethod - def __db_to_data(db_data: dict) -> User: - return User(**db_data) +class UserDatabase: + def __init__(self, session: Session): + self.session = session def get_user(self, username): - self._cursor.execute( - f"SELECT * FROM {self.__table_name} WHERE username=?", (username,) - ) - result = self._cursor.fetchone() + statement = select(User).where(User.username == username) + result = self.session.exec(statement).first() if not result: - return None - db_data = dict(zip([x[0] for x in self._cursor.description], result)) - return self.__db_to_data(db_data) + raise HTTPException(status_code=404, detail="User not found") + return result - def auth_user(self, username, password) -> bool: - self._cursor.execute( - f"SELECT username, password FROM {self.__table_name} WHERE username=?", - (username,), - ) - result = self._cursor.fetchone() + def auth_user(self, user: User): + statement = select(User).where(User.username == user.username) + result = self.session.exec(statement).first() if not result: - raise HTTPException(status_code=401, detail="User not found") - if not verify_password(password, result[1]): - raise HTTPException(status_code=401, detail="Password error") - return True + return ResponseModel( + status_code=401, + status=False, + msg_en="User not found", + msg_zh="用户不存在" + ) + if not verify_password(user.password, result.password): + return ResponseModel( + status_code=401, + status=False, + msg_en="Incorrect password", + msg_zh="密码错误" + ) + return ResponseModel( + status_code=200, + status=True, + msg_en="Login successfully", + msg_zh="登录成功" + ) - def update_user(self, username, update_user: User): + def update_user(self, username, update_user: UserUpdate): # Update username and password - new_username = update_user.username - new_password = update_user.password - self._cursor.execute( - f""" - UPDATE {self.__table_name} - SET username = '{new_username}', password = '{get_password_hash(new_password)}' - WHERE username = '{username}' + statement = select(User).where(User.username == username) + result = self.session.exec(statement).first() + if not result: + raise HTTPException(status_code=404, detail="User not found") + if update_user.username: + result.username = update_user.username + if update_user.password: + result.password = get_password_hash(update_user.password) + self.session.add(result) + self.session.commit() + return result + + def merge_old_user(self): + # get old data + statement = """ + SELECT * FROM user """ + result = self.session.exec(statement).first() + if not result: + return + # add new data + user = User(username=result.username, password=result.password) + # Drop old table + statement = """ + DROP TABLE user + """ + self.session.exec(statement) + # Create new table + statement = """ + CREATE TABLE user ( + id INTEGER NOT NULL PRIMARY KEY, + username VARCHAR NOT NULL, + password VARCHAR NOT NULL ) - self._conn.commit() + """ + self.session.exec(statement) + self.session.add(user) + self.session.commit() - -if __name__ == "__main__": - with AuthDB() as db: - # db.update_user(UserLogin(username="admin", password="adminadmin"), User(username="admin", password="cica1234")) - db.update_user("admin", User(username="estrella", password="cica1234")) + def add_default_user(self): + # Check if user exists + statement = select(User) + try: + result = self.session.exec(statement).all() + except Exception as e: + self.merge_old_user() + result = self.session.exec(statement).all() + if len(result) != 0: + return + # Add default user + user = User(username="admin", password=get_password_hash("adminadmin")) + self.session.add(user) + self.session.commit() diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index ea587daa..fe6805f5 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -82,10 +82,10 @@ class QbDownloader: status_filter=status_filter, category=category, tag=tag ) - def torrents_add(self, urls, save_path, category, torrent_files=None): + def add_torrents(self, torrent_urls, torrent_files, save_path, category): resp = self._client.torrents_add( is_paused=False, - urls=urls, + urls=torrent_urls, torrent_files=torrent_files, save_path=save_path, category=category, diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index 675e180a..d01d4fa3 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -1,7 +1,8 @@ import logging from module.conf import settings -from module.models import BangumiData +from module.models import Bangumi, Torrent +from module.network import RequestContent from .path import TorrentPath @@ -68,7 +69,7 @@ class DownloadClient(TorrentPath): prefs = self.client.get_app_prefs() settings.downloader.path = self._join_path(prefs["save_path"], "Bangumi") - def set_rule(self, data: BangumiData): + def set_rule(self, data: Bangumi): data.rule_name = self._rule_name(data) data.save_path = self._gen_save_path(data) rule = { @@ -92,7 +93,7 @@ class DownloadClient(TorrentPath): f"[Downloader] Add {data.official_title} Season {data.season} to auto download rules." ) - def set_rules(self, bangumi_info: list[BangumiData]): + def set_rules(self, bangumi_info: list[Bangumi]): logger.debug("[Downloader] Start adding rules.") for info in bangumi_info: self.set_rule(info) @@ -113,17 +114,37 @@ class DownloadClient(TorrentPath): self.client.torrents_delete(hashes) logger.info("[Downloader] Remove torrents.") - def add_torrent(self, torrent: dict): - if self.client.torrents_add( - urls=torrent.get("urls"), - torrent_files=torrent.get("torrent_files"), - save_path=torrent.get("save_path"), + def add_torrent(self, torrent: Torrent | list, bangumi: Bangumi) -> bool: + if not bangumi.save_path: + bangumi.save_path = self._gen_save_path(bangumi) + with RequestContent() as req: + if isinstance(torrent, list): + if len(torrent) == 0: + logger.debug(f"[Downloader] No torrent found: {bangumi.official_title}") + return False + if "magnet" in torrent[0].url: + torrent_url = [t.url for t in torrent] + torrent_file = None + else: + torrent_file = [req.get_content(t.url) for t in torrent] + torrent_url = None + else: + if "magnet" in torrent.url: + torrent_url = torrent.url + torrent_file = None + else: + torrent_file = req.get_content(torrent.url) + torrent_url = None + if self.client.add_torrents( + torrent_urls=torrent_url, + torrent_files=torrent_file, + save_path=bangumi.save_path, category="Bangumi", ): - logger.debug(f"[Downloader] Add torrent: {torrent.get('save_path')}") + logger.debug(f"[Downloader] Add torrent: {bangumi.official_title}") return True else: - logger.error(f"[Downloader] Add torrent failed: {torrent.get('save_path')}") + logger.debug(f"[Downloader] Torrent added before: {bangumi.official_title}") return False def move_torrent(self, hashes, location): diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index f1099191..86d2e8e2 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -4,8 +4,7 @@ import re from pathlib import Path from module.conf import settings -from module.models import BangumiData - +from module.models import Bangumi, BangumiUpdate logger = logging.getLogger(__name__) @@ -50,7 +49,7 @@ class TorrentPath: return self._file_depth(file_path) <= 2 @staticmethod - def _gen_save_path(data: BangumiData): + def _gen_save_path(data: Bangumi | BangumiUpdate): folder = ( f"{data.official_title} ({data.year})" if data.year else data.official_title ) @@ -58,7 +57,7 @@ class TorrentPath: return str(save_path) @staticmethod - def _rule_name(data: BangumiData): + def _rule_name(data: Bangumi): rule_name = ( f"[{data.group_name}] {data.official_title} S{data.season}" if settings.bangumi_manage.group_tag diff --git a/backend/src/module/manager/collector.py b/backend/src/module/manager/collector.py index 979aa5cb..f6f1dab8 100644 --- a/backend/src/module/manager/collector.py +++ b/backend/src/module/manager/collector.py @@ -1,56 +1,58 @@ import logging -from module.database import BangumiDatabase from module.downloader import DownloadClient -from module.models import BangumiData +from module.models import Bangumi, ResponseModel from module.searcher import SearchTorrent +from module.rss import RSSEngine logger = logging.getLogger(__name__) class SeasonCollector(DownloadClient): - def add_season_torrents(self, data: BangumiData, torrents, torrent_files=None): - if torrent_files: - download_info = { - "torrent_files": torrent_files, - "save_path": self._gen_save_path(data), - } - return self.add_torrent(download_info) - else: - download_info = { - "urls": [torrent.torrent_link for torrent in torrents], - "save_path": self._gen_save_path(data), - } - return self.add_torrent(download_info) - - def collect_season(self, data: BangumiData, link: str = None, proxy: bool = False): - logger.info(f"Start collecting {data.official_title} Season {data.season}...") + def collect_season(self, bangumi: Bangumi, link: str = None): + logger.info( + f"Start collecting {bangumi.official_title} Season {bangumi.season}..." + ) with SearchTorrent() as st: if not link: - torrents = st.search_season(data) + torrents = st.search_season(bangumi) else: - torrents = st.get_torrents(link, _filter="|".join(data.filter)) - torrent_files = None - if proxy: - torrent_files = [ - st.get_content(torrent.torrent_link) for torrent in torrents - ] - return self.add_season_torrents( - data=data, torrents=torrents, torrent_files=torrent_files - ) + torrents = st.get_torrents(link, bangumi.filter.replace(",", "|")) + if self.add_torrent(torrents, bangumi): + logger.info(f"Collections of {bangumi.official_title} Season {bangumi.season} completed.") + bangumi.eps_collect = True + with RSSEngine() as engine: + engine.bangumi.update(bangumi) + return ResponseModel( + status=True, + status_code=200, + msg_en=f"Collections of {bangumi.official_title} Season {bangumi.season} completed.", + msg_zh=f"收集 {bangumi.official_title} 第 {bangumi.season} 季完成。", + ) + else: + logger.warning(f"Collection of {bangumi.official_title} Season {bangumi.season} failed.") + return ResponseModel( + status=False, + status_code=406, + msg_en=f"Collection of {bangumi.official_title} Season {bangumi.season} failed.", + msg_zh=f"收集 {bangumi.official_title} 第 {bangumi.season} 季失败。", + ) - def subscribe_season(self, data: BangumiData): - with BangumiDatabase() as db: + @staticmethod + def subscribe_season(data: Bangumi): + with RSSEngine() as engine: data.added = True data.eps_collect = True - self.set_rule(data) - db.insert(data) - self.add_rss_feed(data.rss_link[0], item_path=data.official_title) + engine.add_rss( + rss_link=data.rss_link, name=data.official_title, aggregate=False + ) + engine.bangumi.add(data) + return engine.download_bangumi(data) def eps_complete(): - with BangumiDatabase() as bd: - datas = bd.not_complete() + with RSSEngine() as engine: + datas = engine.bangumi.not_complete() if datas: logger.info("Start collecting full season...") for data in datas: @@ -58,4 +60,4 @@ def eps_complete(): with SeasonCollector() as sc: sc.collect_season(data) data.eps_collect = True - bd.update_list(datas) + engine.bangumi.update_all(datas) diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py index f3a69edb..9a559fbc 100644 --- a/backend/src/module/manager/torrent.py +++ b/backend/src/module/manager/torrent.py @@ -2,137 +2,153 @@ import logging from fastapi.responses import JSONResponse -from module.database import BangumiDatabase +from module.database import Database from module.downloader import DownloadClient -from module.models import BangumiData +from module.models import Bangumi, BangumiUpdate, ResponseModel logger = logging.getLogger(__name__) -class TorrentManager(BangumiDatabase): +class TorrentManager(Database): @staticmethod - def __match_torrents_list(data: BangumiData) -> list: + def __match_torrents_list(data: Bangumi | BangumiUpdate) -> list: with DownloadClient() as client: torrents = client.get_torrent_info(status_filter=None) return [ torrent.hash for torrent in torrents if torrent.save_path == data.save_path ] - def delete_torrents(self, data: BangumiData, client: DownloadClient): + def delete_torrents(self, data: Bangumi, client: DownloadClient): hash_list = self.__match_torrents_list(data) if hash_list: client.delete_torrent(hash_list) logger.info(f"Delete rule and torrents for {data.official_title}") - return f"Delete {data.official_title} torrents." + return ResponseModel( + status_code=200, + status=True, + msg_en=f"Delete rule and torrents for {data.official_title}", + msg_zh=f"删除 {data.official_title} 规则和种子", + ) else: - return f"Can't find {data.official_title} torrents." + return ResponseModel( + status_code=406, + status=False, + msg_en=f"Can't find torrents for {data.official_title}", + msg_zh=f"无法找到 {data.official_title} 的种子", + ) def delete_rule(self, _id: int | str, file: bool = False): - data = self.search_id(int(_id)) - if isinstance(data, BangumiData): + data = self.bangumi.search_id(int(_id)) + if isinstance(data, Bangumi): with DownloadClient() as client: - client.remove_rule(data.rule_name) - client.remove_rss_feed(data.official_title) - self.delete_one(int(_id)) + # client.remove_rule(data.rule_name) + # client.remove_rss_feed(data.official_title) + self.rss.delete(data.official_title) + self.bangumi.delete_one(int(_id)) if file: torrent_message = self.delete_torrents(data, client) - return JSONResponse( - status_code=200, - content={ - "msg": f"Delete {data.official_title} rule. {torrent_message}" - }, - ) + return torrent_message logger.info(f"[Manager] Delete rule for {data.official_title}") - return JSONResponse( + return ResponseModel( status_code=200, - content={"msg": f"Delete rule for {data.official_title}"}, + status=True, + msg_en=f"Delete rule for {data.official_title}", + msg_zh=f"删除 {data.official_title} 规则", ) else: - return JSONResponse( - status_code=406, content={"msg": f"Can't find id {_id}"} + return ResponseModel( + status_code=406, + status=False, + msg_en=f"Can't find id {_id}", + msg_zh=f"无法找到 id {_id}", ) def disable_rule(self, _id: str | int, file: bool = False): - data = self.search_id(int(_id)) - if isinstance(data, BangumiData): + data = self.bangumi.search_id(int(_id)) + if isinstance(data, Bangumi): with DownloadClient() as client: - client.remove_rule(data.rule_name) + # client.remove_rule(data.rule_name) data.deleted = True - self.update_one(data) + self.bangumi.update(data) if file: torrent_message = self.delete_torrents(data, client) - return JSONResponse( - status_code=200, - content={ - "msg": f"Disable {data.official_title} rule. {torrent_message}" - }, - ) + return torrent_message logger.info(f"[Manager] Disable rule for {data.official_title}") - return JSONResponse( + return ResponseModel( status_code=200, - content={ - "msg": f"Disable {data.official_title} rule.", - }, + status=True, + msg_en=f"Disable rule for {data.official_title}", + msg_zh=f"禁用 {data.official_title} 规则", ) else: - return JSONResponse( - status_code=406, content={"msg": f"Can't find id {_id}"} + return ResponseModel( + status_code=406, + status=False, + msg_en=f"Can't find id {_id}", + msg_zh=f"无法找到 id {_id}", ) def enable_rule(self, _id: str | int): - data = self.search_id(int(_id)) - if isinstance(data, BangumiData): + data = self.bangumi.search_id(int(_id)) + if data: data.deleted = False - self.update_one(data) - with DownloadClient() as client: - client.set_rule(data) + self.bangumi.update(data) logger.info(f"[Manager] Enable rule for {data.official_title}") - return JSONResponse( + return ResponseModel( status_code=200, - content={ - "msg": f"Enable {data.official_title} rule.", - }, + status=True, + msg_en=f"Enable rule for {data.official_title}", + msg_zh=f"启用 {data.official_title} 规则", ) else: - return JSONResponse( - status_code=406, content={"msg": f"Can't find bangumi id {_id}"} + return ResponseModel( + status_code=406, + status=False, + msg_en=f"Can't find id {_id}", + msg_zh=f"无法找到 id {_id}", ) - def update_rule(self, data: BangumiData): - old_data = self.search_id(data.id) - if not old_data: - logger.error(f"[Manager] Can't find data with {data.id}") - return JSONResponse( - status_code=406, content={"msg": f"Can't find data with {data.id}"} - ) - else: + def update_rule(self, bangumi_id, data: BangumiUpdate): + old_data: Bangumi = self.bangumi.search_id(bangumi_id) + if old_data: # Move torrent - match_list = self.__match_torrents_list(data) + match_list = self.__match_torrents_list(old_data) with DownloadClient() as client: path = client._gen_save_path(data) if match_list: client.move_torrent(match_list, path) - # Set new download rule - client.remove_rule(data.rule_name) - client.set_rule(data) - self.update_one(data) - return JSONResponse( + self.bangumi.update(data, bangumi_id) + return ResponseModel( status_code=200, - content={ - "msg": f"Set new path for {data.official_title}", - }, + status=True, + msg_en=f"Update rule for {data.official_title}", + msg_zh=f"更新 {data.official_title} 规则", + ) + else: + logger.error(f"[Manager] Can't find data with {bangumi_id}") + return ResponseModel( + status_code=406, + status=False, + msg_en=f"Can't find data with {bangumi_id}", + msg_zh=f"无法找到 id {bangumi_id} 的数据", ) + def search_all_bangumi(self): - datas = self.search_all() + datas = self.bangumi.search_all() if not datas: return [] return [data for data in datas if not data.deleted] def search_one(self, _id: int | str): - data = self.search_id(int(_id)) + data = self.bangumi.search_id(int(_id)) if not data: logger.error(f"[Manager] Can't find data with {_id}") - return {"status": "error", "msg": f"Can't find data with {_id}"} + return ResponseModel( + status_code=406, + status=False, + msg_en=f"Can't find data with {_id}", + msg_zh=f"无法找到 id {_id} 的数据", + ) else: return data diff --git a/backend/src/module/models/__init__.py b/backend/src/module/models/__init__.py index a73f18ed..7a00b90d 100644 --- a/backend/src/module/models/__init__.py +++ b/backend/src/module/models/__init__.py @@ -1,5 +1,6 @@ -from .bangumi import * +from .bangumi import Bangumi, Episode, BangumiUpdate, Notification from .config import Config -from .rss import RSSTorrents -from .torrent import EpisodeFile, SubtitleFile, TorrentBase -from .user import UserLogin +from .rss import RSSItem, RSSUpdate +from .torrent import EpisodeFile, SubtitleFile, Torrent, TorrentUpdate +from .user import UserLogin, User, UserUpdate +from .response import ResponseModel, APIResponse diff --git a/backend/src/module/models/bangumi.py b/backend/src/module/models/bangumi.py index b8af3670..82ee9747 100644 --- a/backend/src/module/models/bangumi.py +++ b/backend/src/module/models/bangumi.py @@ -1,27 +1,54 @@ from dataclasses import dataclass -from pydantic import BaseModel, Field +from pydantic import BaseModel +from sqlmodel import SQLModel, Field +from typing import Optional -class BangumiData(BaseModel): - id: int = Field(0, alias="id", title="番剧ID") - official_title: str = Field("official_title", alias="official_title", title="番剧中文名") - year: str | None = Field(None, alias="year", title="番剧年份") - title_raw: str = Field("title_raw", alias="title_raw", title="番剧原名") - season: int = Field(1, alias="season", title="番剧季度") - season_raw: str | None = Field(None, alias="season_raw", title="番剧季度原名") - group_name: str | None = Field(None, alias="group_name", title="字幕组") - dpi: str | None = Field(None, alias="dpi", title="分辨率") - source: str | None = Field(None, alias="source", title="来源") - subtitle: str | None = Field(None, alias="subtitle", title="字幕") - eps_collect: bool = Field(False, alias="eps_collect", title="是否已收集") - offset: int = Field(0, alias="offset", title="番剧偏移量") - filter: list[str] = Field(["720", "\\d+-\\d+"], alias="filter", title="番剧过滤器") - rss_link: list[str] = Field([], alias="rss_link", title="番剧RSS链接") - poster_link: str | None = Field(None, alias="poster_link", title="番剧海报链接") - added: bool = Field(False, alias="added", title="是否已添加") - rule_name: str | None = Field(None, alias="rule_name", title="番剧规则名") - save_path: str | None = Field(None, alias="save_path", title="番剧保存路径") +class Bangumi(SQLModel, table=True): + id: int = Field(default=None, primary_key=True) + official_title: str = Field( + default="official_title", alias="official_title", title="番剧中文名" + ) + year: Optional[str] = Field(alias="year", title="番剧年份") + title_raw: str = Field(default="title_raw", alias="title_raw", title="番剧原名") + season: int = Field(default=1, alias="season", title="番剧季度") + season_raw: Optional[str] = Field(alias="season_raw", title="番剧季度原名") + group_name: Optional[str] = Field(alias="group_name", title="字幕组") + dpi: Optional[str] = Field(alias="dpi", title="分辨率") + source: Optional[str] = Field(alias="source", title="来源") + subtitle: Optional[str] = Field(alias="subtitle", title="字幕") + eps_collect: bool = Field(default=False, alias="eps_collect", title="是否已收集") + offset: int = Field(default=0, alias="offset", title="番剧偏移量") + filter: str = Field(default="720,\\d+-\\d+", alias="filter", title="番剧过滤器") + rss_link: str = Field(default="", alias="rss_link", title="番剧RSS链接") + poster_link: Optional[str] = Field(alias="poster_link", title="番剧海报链接") + added: bool = Field(default=False, alias="added", title="是否已添加") + rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名") + save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径") + deleted: bool = Field(False, alias="deleted", title="是否已删除") + + +class BangumiUpdate(SQLModel): + official_title: str = Field( + default="official_title", alias="official_title", title="番剧中文名" + ) + year: Optional[str] = Field(alias="year", title="番剧年份") + title_raw: str = Field(default="title_raw", alias="title_raw", title="番剧原名") + season: int = Field(default=1, alias="season", title="番剧季度") + season_raw: Optional[str] = Field(alias="season_raw", title="番剧季度原名") + group_name: Optional[str] = Field(alias="group_name", title="字幕组") + dpi: Optional[str] = Field(alias="dpi", title="分辨率") + source: Optional[str] = Field(alias="source", title="来源") + subtitle: Optional[str] = Field(alias="subtitle", title="字幕") + eps_collect: bool = Field(default=False, alias="eps_collect", title="是否已收集") + offset: int = Field(default=0, alias="offset", title="番剧偏移量") + filter: str = Field(default="720,\\d+-\\d+", alias="filter", title="番剧过滤器") + rss_link: str = Field(default="", alias="rss_link", title="番剧RSS链接") + poster_link: Optional[str] = Field(alias="poster_link", title="番剧海报链接") + added: bool = Field(default=False, alias="added", title="是否已添加") + rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名") + save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径") deleted: bool = Field(False, alias="deleted", title="是否已删除") @@ -29,14 +56,14 @@ class Notification(BaseModel): official_title: str = Field(..., alias="official_title", title="番剧名") season: int = Field(..., alias="season", title="番剧季度") episode: int = Field(..., alias="episode", title="番剧集数") - poster_path: str | None = Field(None, alias="poster_path", title="番剧海报路径") + poster_path: Optional[str] = Field(None, alias="poster_path", title="番剧海报路径") @dataclass class Episode: - title_en: str | None - title_zh: str | None - title_jp: str | None + title_en: Optional[str] + title_zh: Optional[str] + title_jp: Optional[str] season: int season_raw: str episode: int diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index a2e4d2a6..df15546a 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -1,23 +1,27 @@ from os.path import expandvars from pydantic import BaseModel, Field -# Sub config - class Program(BaseModel): - rss_time: int = Field(7200, description="Sleep time") + rss_time: int = Field(900, description="Sleep time") rename_time: int = Field(60, description="Rename times in one loop") webui_port: int = Field(7892, description="WebUI port") class Downloader(BaseModel): type: str = Field("qbittorrent", description="Downloader type") - host: str = Field("172.17.0.1:8080", description="Downloader host") + host_: str = Field("172.17.0.1:8080", alias="host", description="Downloader host") username_: str = Field("admin", alias="username", description="Downloader username") - password_: str = Field("adminadmin", alias="password", description="Downloader password") + password_: str = Field( + "adminadmin", alias="password", description="Downloader password" + ) path: str = Field("/downloads/Bangumi", description="Downloader path") ssl: bool = Field(False, description="Downloader ssl") + @property + def host(self): + return expandvars(self.host_) + @property def username(self): return expandvars(self.username_) @@ -26,18 +30,12 @@ class Downloader(BaseModel): def password(self): return expandvars(self.password_) + class RSSParser(BaseModel): enable: bool = Field(True, description="Enable RSS parser") - type: str = Field("mikan", description="RSS parser type") - token_: str = Field("token", alias="token", description="RSS parser token") - custom_url: str = Field("mikanani.me", description="Custom RSS host url") - parser_type: str = Field("parser", description="Parser type") filter: list[str] = Field(["720", r"\d+-\d"], description="Filter") language: str = "zh" - @property - def token(self): - return expandvars(self.token_) class BangumiManage(BaseModel): enable: bool = Field(True, description="Enable bangumi manage") @@ -82,6 +80,7 @@ class Notification(BaseModel): def chat_id(self): return expandvars(self.chat_id_) + class Config(BaseModel): program: Program = Program() downloader: Downloader = Downloader() diff --git a/backend/src/module/models/response.py b/backend/src/module/models/response.py new file mode 100644 index 00000000..9bd35272 --- /dev/null +++ b/backend/src/module/models/response.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, Field + + +class ResponseModel(BaseModel): + status: bool = Field(..., example=True) + status_code: int = Field(..., example=200) + msg_en: str + msg_zh: str + + +class APIResponse(BaseModel): + status: bool = Field(..., example=True) + msg_en: str = Field(..., example="Success") + msg_zh: str = Field(..., example="成功") \ No newline at end of file diff --git a/backend/src/module/models/rss.py b/backend/src/module/models/rss.py index c763c4c5..7275bb12 100644 --- a/backend/src/module/models/rss.py +++ b/backend/src/module/models/rss.py @@ -1,9 +1,19 @@ -from pydantic import BaseModel, Field +from sqlmodel import SQLModel, Field +from typing import Optional -class RSSTorrents(BaseModel): - name: str = Field(..., alias="item_path") - url: str = Field(..., alias="url") - analyze: bool = Field(..., alias="analyze") - enabled: bool = Field(..., alias="enabled") - torrents: list[str] = Field(..., alias="torrents") +class RSSItem(SQLModel, table=True): + id: int = Field(default=None, primary_key=True, alias="id") + name: Optional[str] = Field(None, alias="name") + url: str = Field("https://mikanani.me", alias="url") + aggregate: bool = Field(False, alias="aggregate") + parser: str = Field("mikan", alias="parser") + enabled: bool = Field(True, alias="enabled") + + +class RSSUpdate(SQLModel): + name: Optional[str] = Field(None, alias="name") + url: Optional[str] = Field("https://mikanani.me", alias="url") + aggregate: Optional[bool] = Field(True, alias="aggregate") + parser: Optional[str] = Field("mikan", alias="parser") + enabled: Optional[bool] = Field(True, alias="enabled") diff --git a/backend/src/module/models/torrent.py b/backend/src/module/models/torrent.py index 892d66d6..57e818a3 100644 --- a/backend/src/module/models/torrent.py +++ b/backend/src/module/models/torrent.py @@ -1,16 +1,20 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel +from sqlmodel import SQLModel, Field +from typing import Optional -class TorrentBase(BaseModel): - name: str = Field(...) - torrent_link: str = Field(...) - homepage: str | None = Field(None) +class Torrent(SQLModel, table=True): + id: int = Field(default=None, primary_key=True, alias="id") + bangumi_id: Optional[int] = Field(None, alias="refer_id", foreign_key="bangumi.id") + rss_id: Optional[int] = Field(None, alias="rss_id", foreign_key="rssitem.id") + name: str = Field("", alias="name") + url: str = Field("https://example.com/torrent", alias="url") + homepage: Optional[str] = Field(None, alias="homepage") + downloaded: bool = Field(False, alias="downloaded") -class FileSet(BaseModel): - media_path: str = Field(...) - sc_subtitle: str | None = Field(None) - tc_subtitle: str | None = Field(None) +class TorrentUpdate(SQLModel): + downloaded: bool = Field(False, alias="downloaded") class EpisodeFile(BaseModel): diff --git a/backend/src/module/models/user.py b/backend/src/module/models/user.py index 36512642..3e5cef29 100644 --- a/backend/src/module/models/user.py +++ b/backend/src/module/models/user.py @@ -1,14 +1,24 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel +from typing import Optional +from sqlmodel import SQLModel, Field -class User(BaseModel): +class User(SQLModel, table=True): + id: int = Field(default=None, primary_key=True) username: str = Field( "admin", min_length=4, max_length=20, regex=r"^[a-zA-Z0-9_]+$" ) password: str = Field("adminadmin", min_length=8) -class UserLogin(BaseModel): +class UserUpdate(SQLModel): + username: Optional[str] = Field( + None, min_length=4, max_length=20, regex=r"^[a-zA-Z0-9_]+$" + ) + password: Optional[str] = Field(None, min_length=8) + + +class UserLogin(SQLModel): username: str password: str = Field(..., min_length=8) diff --git a/backend/src/module/network/__init__.py b/backend/src/module/network/__init__.py index aca7b27b..e576795a 100644 --- a/backend/src/module/network/__init__.py +++ b/backend/src/module/network/__init__.py @@ -1 +1 @@ -from .request_contents import RequestContent, TorrentInfo +from .request_contents import RequestContent diff --git a/backend/src/module/network/request_contents.py b/backend/src/module/network/request_contents.py index fcef7774..bde907a4 100644 --- a/backend/src/module/network/request_contents.py +++ b/backend/src/module/network/request_contents.py @@ -1,85 +1,52 @@ import re +import logging import xml.etree.ElementTree -from dataclasses import dataclass - -from bs4 import BeautifulSoup from module.conf import settings +from module.models import Torrent from .request_url import RequestURL -from .site import mikan_parser +from .site import rss_parser - -@dataclass -class TorrentInfo: - name: str - torrent_link: str - homepage: str - _poster_link: str | None = None - _official_title: str | None = None - - def __fetch_mikan_info(self): - if self._poster_link is None or self._official_title is None: - with RequestContent() as req: - self._poster_link, self._official_title = req.get_mikan_info( - self.homepage - ) - - @property - def poster_link(self) -> str: - self.__fetch_mikan_info() - return self._poster_link - - @property - def official_title(self) -> str: - self.__fetch_mikan_info() - return self._official_title +logger = logging.getLogger(__name__) class RequestContent(RequestURL): - # Mikanani RSS def get_torrents( self, _url: str, _filter: str = "|".join(settings.rss_parser.filter), + limit: int = 100, retry: int = 3, - ) -> list[TorrentInfo]: - try: - soup = self.get_xml(_url, retry) - torrent_titles, torrent_urls, torrent_homepage = mikan_parser(soup) - torrents: list[TorrentInfo] = [] + ) -> list[Torrent]: + soup = self.get_xml(_url, retry) + if soup: + torrent_titles, torrent_urls, torrent_homepage = rss_parser(soup) + torrents: list[Torrent] = [] for _title, torrent_url, homepage in zip( torrent_titles, torrent_urls, torrent_homepage ): if re.search(_filter, _title) is None: torrents.append( - TorrentInfo( - name=_title, torrent_link=torrent_url, homepage=homepage - ) + Torrent(name=_title, url=torrent_url, homepage=homepage) ) + if len(torrents) >= limit: + break return torrents - except ConnectionError: + else: + logger.warning(f"[Network] Failed to get torrents: {_url}") return [] - def get_mikan_info(self, _url) -> tuple[str, str]: - content = self.get_html(_url) - soup = BeautifulSoup(content, "html.parser") - poster_div = soup.find("div", {"class": "bangumi-poster"}) - poster_style = poster_div.get("style") - official_title = soup.select_one( - 'p.bangumi-title a[href^="/Home/Bangumi/"]' - ).text - if poster_style: - poster_path = poster_style.split("url('")[1].split("')")[0] - return poster_path, official_title - return "", "" - def get_xml(self, _url, retry: int = 3) -> xml.etree.ElementTree.Element: - return xml.etree.ElementTree.fromstring(self.get_url(_url, retry).text) + req = self.get_url(_url, retry) + if req: + return xml.etree.ElementTree.fromstring(req.text) # API JSON def get_json(self, _url) -> dict: - return self.get_url(_url).json() + req = self.get_url(_url) + if req: + return req.json() def post_json(self, _url, data: dict) -> dict: return self.post_url(_url, data).json() @@ -91,7 +58,14 @@ class RequestContent(RequestURL): return self.get_url(_url).text def get_content(self, _url): - return self.get_url(_url).content + req = self.get_url(_url) + if req: + return req.content def check_connection(self, _url): return self.check_url(_url) + + def get_rss_title(self, _url): + soup = self.get_xml(_url) + if soup: + return soup.find("./channel/title").text diff --git a/backend/src/module/network/request_url.py b/backend/src/module/network/request_url.py index 482978dc..bee1a8a2 100644 --- a/backend/src/module/network/request_url.py +++ b/backend/src/module/network/request_url.py @@ -35,7 +35,7 @@ class RequestURL: break logger.error(f"[Network] Failed connecting to {url}") logger.warning("[Network] Please check DNS/Connection settings") - raise ConnectionError(f"Failed connecting to {url}") + return None def post_url(self, url: str, data: dict, retry=3): try_time = 0 @@ -59,7 +59,7 @@ class RequestURL: break logger.error(f"[Network] Failed connecting to {url}") logger.warning("[Network] Please check DNS/Connection settings") - raise ConnectionError(f"Failed connecting to {url}") + return None def check_url(self, url: str): if "://" not in url: diff --git a/backend/src/module/network/site/__init__.py b/backend/src/module/network/site/__init__.py index 70f16f74..adb8e1cc 100644 --- a/backend/src/module/network/site/__init__.py +++ b/backend/src/module/network/site/__init__.py @@ -1 +1 @@ -from .mikan import mikan_parser +from .mikan import rss_parser diff --git a/backend/src/module/network/site/mikan.py b/backend/src/module/network/site/mikan.py index 0ad4314b..00181a87 100644 --- a/backend/src/module/network/site/mikan.py +++ b/backend/src/module/network/site/mikan.py @@ -1,4 +1,4 @@ -def mikan_parser(soup): +def rss_parser(soup): torrent_titles = [] torrent_urls = [] torrent_homepage = [] @@ -7,3 +7,7 @@ def mikan_parser(soup): torrent_urls.append(item.find("enclosure").attrib["url"]) torrent_homepage.append(item.find("link").text) return torrent_titles, torrent_urls, torrent_homepage + + +def mikan_title(soup): + return soup.find("title").text diff --git a/backend/src/module/notification/notification.py b/backend/src/module/notification/notification.py index 29ac9d95..909fc92c 100644 --- a/backend/src/module/notification/notification.py +++ b/backend/src/module/notification/notification.py @@ -1,7 +1,7 @@ import logging from module.conf import settings -from module.database import BangumiDatabase +from module.database import Database from module.models import Notification from .plugin import ( @@ -36,13 +36,9 @@ class PostNotification: @staticmethod def _get_poster(notify: Notification): - with BangumiDatabase() as db: - poster_path = db.match_poster(notify.official_title) - if poster_path: - poster_link = "https://mikanani.me" + poster_path - else: - poster_link = "https://mikanani.me" - notify.poster_path = poster_link + with Database() as db: + poster_path = db.bangumi.match_poster(notify.official_title) + notify.poster_path = poster_path def send_msg(self, notify: Notification) -> bool: self._get_poster(notify) diff --git a/backend/src/module/parser/analyser/__init__.py b/backend/src/module/parser/analyser/__init__.py index 8465565c..da70a3f3 100644 --- a/backend/src/module/parser/analyser/__init__.py +++ b/backend/src/module/parser/analyser/__init__.py @@ -1,3 +1,4 @@ from .raw_parser import raw_parser from .tmdb_parser import tmdb_parser from .torrent_parser import torrent_parser +from .mikan_parser import mikan_parser diff --git a/backend/src/module/parser/analyser/mikan_parser.py b/backend/src/module/parser/analyser/mikan_parser.py new file mode 100644 index 00000000..7cc30d20 --- /dev/null +++ b/backend/src/module/parser/analyser/mikan_parser.py @@ -0,0 +1,21 @@ +from bs4 import BeautifulSoup +from urllib3.util import parse_url + +from module.network import RequestContent + + +def mikan_parser(homepage: str): + root_path = parse_url(homepage).host + with RequestContent() as req: + content = req.get_html(homepage) + soup = BeautifulSoup(content, "html.parser") + poster_div = soup.find("div", {"class": "bangumi-poster"}) + poster_style = poster_div.get("style") + official_title = soup.select_one( + 'p.bangumi-title a[href^="/Home/Bangumi/"]' + ).text + if poster_style: + poster_path = poster_style.split("url('")[1].split("')")[0] + poster_link = f"https://{root_path}{poster_path}" + return poster_link, official_title + return "", "" diff --git a/backend/src/module/parser/analyser/tmdb_parser.py b/backend/src/module/parser/analyser/tmdb_parser.py index 393eec4e..b98ff262 100644 --- a/backend/src/module/parser/analyser/tmdb_parser.py +++ b/backend/src/module/parser/analyser/tmdb_parser.py @@ -6,6 +6,9 @@ from module.conf import TMDB_API from module.network import RequestContent +TMDB_URL = "https://api.themoviedb.org" + + @dataclass class TMDBInfo: id: int @@ -14,17 +17,18 @@ class TMDBInfo: season: list[dict] last_season: int year: str + poster_link: str = None LANGUAGE = {"zh": "zh-CN", "jp": "ja-JP", "en": "en-US"} def search_url(e): - return f"https://api.themoviedb.org/3/search/tv?api_key={TMDB_API}&page=1&query={e}&include_adult=false" + return f"{TMDB_URL}/3/search/tv?api_key={TMDB_API}&page=1&query={e}&include_adult=false" def info_url(e, key): - return f"https://api.themoviedb.org/3/tv/{e}?api_key={TMDB_API}&language={LANGUAGE[key]}" + return f"{TMDB_URL}/3/tv/{e}?api_key={TMDB_API}&language={LANGUAGE[key]}" def is_animation(tv_id, language) -> bool: @@ -37,15 +41,17 @@ def is_animation(tv_id, language) -> bool: return False -def get_season(seasons: list) -> int: - ss = sorted(seasons, key=lambda e: e.get("air_date"), reverse=True) +def get_season(seasons: list) -> tuple[int, str]: + ss = [s for s in seasons if s["air_date"] is not None and "特别" not in s["season"]] + ss = sorted(ss, key=lambda e: e.get("air_date"), reverse=True) for season in ss: if re.search(r"第 \d 季", season.get("season")) is not None: date = season.get("air_date").split("-") [year, _, _] = date now_year = time.localtime().tm_year if int(year) <= now_year: - return int(re.findall(r"\d", season.get("season"))[0]) + return int(re.findall(r"\d", season.get("season"))[0]), season.get("poster_path") + return len(ss), ss[-1].get("poster_path") def tmdb_parser(title, language) -> TMDBInfo | None: @@ -71,10 +77,16 @@ def tmdb_parser(title, language) -> TMDBInfo | None: } for s in info_content.get("seasons") ] - last_season = get_season(season) + last_season, poster_path = get_season(season) + if poster_path is None: + poster_path = info_content.get("poster_path") original_title = info_content.get("original_name") official_title = info_content.get("name") year_number = info_content.get("first_air_date").split("-")[0] + if poster_path: + poster_link = "https://image.tmdb.org/t/p/w300" + poster_path + else: + poster_link = None return TMDBInfo( id, official_title, @@ -82,6 +94,11 @@ def tmdb_parser(title, language) -> TMDBInfo | None: season, last_season, str(year_number), + poster_link, ) else: return None + + +if __name__ == '__main__': + print(tmdb_parser("魔法禁书目录", "zh")) \ No newline at end of file diff --git a/backend/src/module/parser/title_parser.py b/backend/src/module/parser/title_parser.py index 33ada84b..89f0f2b2 100644 --- a/backend/src/module/parser/title_parser.py +++ b/backend/src/module/parser/title_parser.py @@ -1,9 +1,9 @@ import logging from module.conf import settings -from module.models import BangumiData +from module.models import Bangumi -from .analyser import raw_parser, tmdb_parser, torrent_parser +from .analyser import raw_parser, tmdb_parser, torrent_parser, mikan_parser logger = logging.getLogger(__name__) @@ -26,20 +26,18 @@ class TitleParser: @staticmethod def tmdb_parser(title: str, season: int, language: str): - official_title, tmdb_season, year = title, season, None tmdb_info = tmdb_parser(title, language) if tmdb_info: logger.debug(f"TMDB Matched, official title is {tmdb_info.title}") tmdb_season = tmdb_info.last_season if tmdb_info.last_season else season - official_title = tmdb_info.title - year = tmdb_info.year + return tmdb_info.title, tmdb_season, tmdb_info.year, tmdb_info.poster_link else: logger.warning(f"Cannot match {title} in TMDB. Use raw title instead.") logger.warning("Please change bangumi info manually.") - return official_title, tmdb_season, year + return title, season, None, None @staticmethod - def raw_parser(raw: str, rss_link: str) -> BangumiData | None: + def raw_parser(raw: str) -> Bangumi | None: language = settings.rss_parser.language try: episode = raw_parser(raw) @@ -60,7 +58,8 @@ class TitleParser: else: official_title = title_raw _season = episode.season - data = BangumiData( + logger.debug(f"RAW:{raw} >> {title_raw}") + return Bangumi( official_title=official_title, title_raw=title_raw, season=_season, @@ -71,12 +70,13 @@ class TitleParser: subtitle=episode.sub, eps_collect=False if episode.episode > 1 else True, offset=0, - filter=settings.rss_parser.filter, - rss_link=[rss_link], + filter=",".join(settings.rss_parser.filter), ) - logger.debug(f"RAW:{raw} >> {title_raw}") - return data except Exception as e: logger.debug(e) logger.warning(f"Cannot parse {raw}.") return None + + @staticmethod + def mikan_parser(homepage: str) -> tuple[str, str]: + return mikan_parser(homepage) diff --git a/backend/src/module/rss/__init__.py b/backend/src/module/rss/__init__.py index f61d269a..70406ee3 100644 --- a/backend/src/module/rss/__init__.py +++ b/backend/src/module/rss/__init__.py @@ -1,3 +1,2 @@ from .analyser import RSSAnalyser - -analyser = RSSAnalyser() +from .engine import RSSEngine diff --git a/backend/src/module/rss/analyser.py b/backend/src/module/rss/analyser.py index ccd398be..549fdc0a 100644 --- a/backend/src/module/rss/analyser.py +++ b/backend/src/module/rss/analyser.py @@ -1,37 +1,40 @@ import logging import re +from .engine import RSSEngine + from module.conf import settings -from module.database import BangumiDatabase -from module.models import BangumiData -from module.network import RequestContent, TorrentInfo +from module.models import Bangumi, Torrent, RSSItem, ResponseModel +from module.network import RequestContent from module.parser import TitleParser logger = logging.getLogger(__name__) -class RSSAnalyser: - def __init__(self): - self._title_analyser = TitleParser() - with BangumiDatabase() as db: - db.update_table() - - def official_title_parser(self, data: BangumiData, mikan_title: str): - if settings.rss_parser.parser_type == "mikan": - data.official_title = mikan_title if mikan_title else data.official_title - elif settings.rss_parser.parser_type == "tmdb": - tmdb_title, season, year = self._title_analyser.tmdb_parser( - data.official_title, data.season, settings.rss_parser.language +class RSSAnalyser(TitleParser): + def official_title_parser(self, bangumi: Bangumi, rss: RSSItem, torrent: Torrent): + if rss.parser == "mikan": + try: + bangumi.poster_link, bangumi.official_title = self.mikan_parser( + torrent.homepage + ) + except AttributeError: + logger.warning("[Parser] Mikan torrent has no homepage info.") + pass + elif rss.parser == "tmdb": + tmdb_title, season, year, poster_link = self.tmdb_parser( + bangumi.official_title, bangumi.season, settings.rss_parser.language ) - data.official_title = tmdb_title - data.year = year - data.season = season + bangumi.official_title = tmdb_title + bangumi.year = year + bangumi.season = season + bangumi.poster_link = poster_link else: pass - data.official_title = re.sub(r"[/:.\\]", " ", data.official_title) + bangumi.official_title = re.sub(r"[/:.\\]", " ", bangumi.official_title) @staticmethod - def get_rss_torrents(rss_link: str, full_parse: bool = True) -> list: + def get_rss_torrents(rss_link: str, full_parse: bool = True) -> list[Torrent]: with RequestContent() as req: if full_parse: rss_torrents = req.get_torrents(rss_link) @@ -40,61 +43,53 @@ class RSSAnalyser: return rss_torrents def torrents_to_data( - self, torrents: list, rss_link: str, full_parse: bool = True + self, torrents: list[Torrent], rss: RSSItem, full_parse: bool = True ) -> list: new_data = [] for torrent in torrents: - data = self._title_analyser.raw_parser(raw=torrent.name, rss_link=rss_link) - if data and data.title_raw not in [i.title_raw for i in new_data]: - try: - poster_link, mikan_title = ( - torrent.poster_link, - torrent.official_title, - ) - except AttributeError: - poster_link, mikan_title = None, None - data.poster_link = poster_link - self.official_title_parser(data, mikan_title) + bangumi = self.raw_parser(raw=torrent.name) + if bangumi and bangumi.title_raw not in [i.title_raw for i in new_data]: + self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent) if not full_parse: - return [data] - new_data.append(data) - logger.debug(f"[RSS] New title found: {data.official_title}") + return [bangumi] + new_data.append(bangumi) + logger.info(f"[RSS] New bangumi founded: {bangumi.official_title}") return new_data - def torrent_to_data( - self, torrent: TorrentInfo, rss_link: str | None = None - ) -> BangumiData: - data = self._title_analyser.raw_parser(raw=torrent.name, rss_link=rss_link) - if data: - try: - poster_link, mikan_title = ( - torrent.poster_link, - torrent.official_title, - ) - except AttributeError: - poster_link, mikan_title = None, None - data.poster_link = poster_link - self.official_title_parser(data, mikan_title) - return data + def torrent_to_data(self, torrent: Torrent, rss: RSSItem) -> Bangumi: + bangumi = self.raw_parser(raw=torrent.name) + if bangumi: + self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent) + bangumi.rss_link = rss.url + return bangumi def rss_to_data( - self, rss_link: str, database: BangumiDatabase, full_parse: bool = True - ) -> list[BangumiData]: - rss_torrents = self.get_rss_torrents(rss_link, full_parse) - torrents_to_add = database.match_list(rss_torrents, rss_link) + self, rss: RSSItem, engine: RSSEngine, full_parse: bool = True + ) -> list[Bangumi]: + rss_torrents = self.get_rss_torrents(rss.url, full_parse) + torrents_to_add = engine.bangumi.match_list(rss_torrents, rss.url) if not torrents_to_add: logger.debug("[RSS] No new title has been found.") return [] # New List - new_data = self.torrents_to_data(torrents_to_add, rss_link, full_parse) + new_data = self.torrents_to_data(torrents_to_add, rss, full_parse) if new_data: + # Add to database + engine.bangumi.add_all(new_data) return new_data else: return [] - def link_to_data(self, link: str) -> BangumiData: - torrents = self.get_rss_torrents(link, False) + def link_to_data(self, rss: RSSItem) -> Bangumi | ResponseModel: + torrents = self.get_rss_torrents(rss.url, False) for torrent in torrents: - data = self.torrent_to_data(torrent, link) + data = self.torrent_to_data(torrent, rss) if data: return data + else: + return ResponseModel( + status=False, + status_code=406, + msg_en="No new title has been found.", + msg_zh="没有找到新的番剧。", + ) diff --git a/backend/src/module/rss/engine.py b/backend/src/module/rss/engine.py new file mode 100644 index 00000000..60f9e397 --- /dev/null +++ b/backend/src/module/rss/engine.py @@ -0,0 +1,147 @@ +import re +import logging + +from typing import Optional + +from module.models import Bangumi, RSSItem, Torrent, ResponseModel +from module.network import RequestContent +from module.downloader import DownloadClient + +from module.database import Database, engine + +logger = logging.getLogger(__name__) + + +class RSSEngine(Database): + def __init__(self, _engine=engine): + super().__init__(_engine) + self._to_refresh = False + + @staticmethod + def _get_torrents(rss: RSSItem) -> list[Torrent]: + with RequestContent() as req: + torrents = req.get_torrents(rss.url) + # Add RSS ID + for torrent in torrents: + torrent.rss_id = rss.id + return torrents + + def get_rss_torrents(self, rss_id: int) -> list[Torrent]: + rss = self.rss.search_id(rss_id) + if rss: + return self.torrent.search_rss(rss_id) + else: + return [] + + def add_rss(self, rss_link: str, name: str | None = None, aggregate: bool = True, parser: str = "mikan"): + if not name: + with RequestContent() as req: + name = req.get_rss_title(rss_link) + if not name: + return ResponseModel( + status=False, + status_code=406, + msg_en="Failed to get RSS title.", + msg_zh="无法获取 RSS 标题。", + ) + rss_data = RSSItem(name=name, url=rss_link, aggregate=aggregate, parser=parser) + if self.rss.add(rss_data): + return ResponseModel( + status=True, + status_code=200, + msg_en="RSS added successfully.", + msg_zh="RSS 添加成功。", + ) + else: + return ResponseModel( + status=False, + status_code=406, + msg_en="RSS added failed.", + msg_zh="RSS 添加失败。", + ) + + def disable_list(self, rss_id_list: list[int]): + for rss_id in rss_id_list: + self.rss.disable(rss_id) + return ResponseModel( + status=True, + status_code=200, + msg_en="Disable RSS successfully.", + msg_zh="禁用 RSS 成功。", + ) + + def enable_list(self, rss_id_list: list[int]): + for rss_id in rss_id_list: + self.rss.enable(rss_id) + return ResponseModel( + status=True, + status_code=200, + msg_en="Enable RSS successfully.", + msg_zh="启用 RSS 成功。", + ) + + def delete_list(self, rss_id_list: list[int]): + for rss_id in rss_id_list: + self.rss.delete(rss_id) + return ResponseModel( + status=True, + status_code=200, + msg_en="Delete RSS successfully.", + msg_zh="删除 RSS 成功。", + ) + + def pull_rss(self, rss_item: RSSItem) -> list[Torrent]: + torrents = self._get_torrents(rss_item) + new_torrents = self.torrent.check_new(torrents) + return new_torrents + + def match_torrent(self, torrent: Torrent) -> Optional[Bangumi]: + matched: Bangumi = self.bangumi.match_torrent(torrent.name) + if matched: + _filter = matched.filter.replace(",", "|") + if not re.search(_filter, torrent.name, re.IGNORECASE): + torrent.bangumi_id = matched.id + return matched + return None + + def refresh_rss(self, client: DownloadClient, rss_id: Optional[int] = None): + # Get All RSS Items + if not rss_id: + rss_items: list[RSSItem] = self.rss.search_active() + else: + rss_item = self.rss.search_id(rss_id) + rss_items = [rss_item] if rss_item else [] + # From RSS Items, get all torrents + logger.debug(f"[Engine] Get {len(rss_items)} RSS items") + for rss_item in rss_items: + new_torrents = self.pull_rss(rss_item) + # Get all enabled bangumi data + for torrent in new_torrents: + matched_data = self.match_torrent(torrent) + if matched_data: + if client.add_torrent(torrent, matched_data): + logger.debug(f"[Engine] Add torrent {torrent.name} to client") + torrent.downloaded = True + # Add all torrents to database + self.torrent.add_all(new_torrents) + + def download_bangumi(self, bangumi: Bangumi): + with RequestContent() as req: + torrents = req.get_torrents(bangumi.rss_link, bangumi.filter.replace(",", "|")) + if torrents: + with DownloadClient() as client: + client.add_torrent(torrents, bangumi) + self.torrent.add_all(torrents) + return ResponseModel( + status=True, + status_code=200, + msg_en=f"[Engine] Download {bangumi.official_title} successfully.", + msg_zh=f"下载 {bangumi.official_title} 成功。", + ) + else: + return ResponseModel( + status=False, + status_code=406, + msg_en=f"[Engine] Download {bangumi.official_title} failed.", + msg_zh=f"[Engine] 下载 {bangumi.official_title} 失败。", + ) diff --git a/backend/src/module/rss/filter.py b/backend/src/module/rss/filter.py deleted file mode 100644 index e70d4602..00000000 --- a/backend/src/module/rss/filter.py +++ /dev/null @@ -1,57 +0,0 @@ -import logging - -from module.conf import settings -from module.database import BangumiDatabase -from module.downloader import DownloadClient -from module.models import BangumiData -from module.network import RequestContent - -logger = logging.getLogger(__name__) - - -def matched(torrent_title: str): - with BangumiDatabase() as db: - return db.match_torrent(torrent_title) - - -def save_path(data: BangumiData): - folder = ( - f"{data.official_title}({data.year})" if data.year else f"{data.official_title}" - ) - season = f"Season {data.season}" - return path.join( - settings.downloader.path, - folder, - season, - ) - - -def add_download(data: BangumiData, torrent: TorrentInfo): - torrent = { - "url": torrent.url, - "save_path": save_path(data), - } - with DownloadClient() as client: - client.add_torrent(torrent) - with TorrentDatabase() as db: - db.add_torrent(torrent) - - -def downloaded(torrent: TorrentInfo): - with TorrentDatabase() as db: - return db.if_downloaded(torrent) - - -def get_downloads(rss_link: str): - with RequestContent() as req: - torrents = req.get_torrents(rss_link) - for torrent in torrents: - if not downloaded(torrent): - data = matched(torrent.title) - if data: - add_download(data, torrent) - logger.info(f"Add {torrent.title} to download list") - else: - logger.debug(f"{torrent.title} not matched") - else: - logger.debug(f"{torrent.title} already downloaded") diff --git a/backend/src/module/rss/poller.py b/backend/src/module/rss/poller.py deleted file mode 100644 index 90a81cb0..00000000 --- a/backend/src/module/rss/poller.py +++ /dev/null @@ -1,26 +0,0 @@ -import re - -from module.database import RSSDatabase -from module.models import BangumiData, RSSTorrents -from module.network import RequestContent, TorrentInfo - - -class RSSPoller(RSSDatabase): - @staticmethod - def polling(rss_link, req: RequestContent) -> list[TorrentInfo]: - return req.get_torrents(rss_link) - - @staticmethod - def filter_torrent(data: BangumiData, torrent: TorrentInfo) -> bool: - if data.title_raw in torrent.name: - _filter = "|".join(data.filter) - if not re.search(_filter, torrent.name): - return True - else: - return False - - def foo(self): - rss_datas: list[RSSTorrents] = self.get_rss_data() - with RequestContent() as req: - for rss_data in rss_datas: - self.polling(rss_data.url, req) diff --git a/backend/src/module/rss/searcher.py b/backend/src/module/rss/searcher.py deleted file mode 100644 index de83ef13..00000000 --- a/backend/src/module/rss/searcher.py +++ /dev/null @@ -1,14 +0,0 @@ -from module.conf import settings -from module.network import RequestContent - - -class RSSSearcher(RequestContent): - def __search_url(self, keywords: str) -> str: - keywords.replace(" ", "+") - url = f"{settings.rss_parser.custom_url}/RSS/Search?keyword={keywords}" - return url - - def search_keywords(self, keywords: str) -> list[dict]: - url = self.__search_url(keywords) - torrents = self.get_torrents(url) - return torrents diff --git a/backend/src/module/searcher/__init__.py b/backend/src/module/searcher/__init__.py index 534573fe..9630e420 100644 --- a/backend/src/module/searcher/__init__.py +++ b/backend/src/module/searcher/__init__.py @@ -1 +1,2 @@ from .searcher import SearchTorrent +from .provider import SEARCH_CONFIG diff --git a/backend/src/module/searcher/plugin/__init__.py b/backend/src/module/searcher/plugin/__init__.py deleted file mode 100644 index 47852a16..00000000 --- a/backend/src/module/searcher/plugin/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .mikan import mikan_url - - -def search_url(site: str, keywords: list[str]): - if site == "mikan": - return mikan_url(keywords) - else: - raise NotImplementedError(f"site {site} is not supported") diff --git a/backend/src/module/searcher/plugin/mikan.py b/backend/src/module/searcher/plugin/mikan.py deleted file mode 100644 index 947066c8..00000000 --- a/backend/src/module/searcher/plugin/mikan.py +++ /dev/null @@ -1,12 +0,0 @@ -import re - -from module.conf import settings - - -def mikan_url(keywords: list[str]): - keyword = "+".join(keywords) - search_str = re.sub(r"[\W_ ]", "+", keyword) - url = f"{settings.rss_parser.custom_url}/RSS/Search?searchstr={search_str}" - if "://" not in url: - url = f"https://{url}" - return url diff --git a/backend/src/module/searcher/provider.py b/backend/src/module/searcher/provider.py new file mode 100644 index 00000000..318a22ea --- /dev/null +++ b/backend/src/module/searcher/provider.py @@ -0,0 +1,20 @@ +import re + +from module.models import RSSItem +from module.conf import SEARCH_CONFIG + + +def search_url(site: str, keywords: list[str]) -> RSSItem: + keyword = "+".join(keywords) + search_str = re.sub(r"[\W_ ]", "+", keyword) + if site in SEARCH_CONFIG.keys(): + url = re.sub(r"%s", search_str, SEARCH_CONFIG[site]) + parser = "mikan" if site == "mikan" else "tmdb" + rss_item = RSSItem( + url=url, + aggregate=False, + parser=parser, + ) + return rss_item + else: + raise ValueError(f"Site {site} is not supported") \ No newline at end of file diff --git a/backend/src/module/searcher/searcher.py b/backend/src/module/searcher/searcher.py index a52e379e..e38b7402 100644 --- a/backend/src/module/searcher/searcher.py +++ b/backend/src/module/searcher/searcher.py @@ -1,6 +1,11 @@ -from module.models import BangumiData, TorrentBase +import json +from typing import TypeAlias + +from module.models import Bangumi, Torrent, RSSItem from module.network import RequestContent -from module.searcher.plugin import search_url +from module.rss import RSSAnalyser + +from .provider import search_url SEARCH_KEY = [ "group_name", @@ -11,32 +16,35 @@ SEARCH_KEY = [ "dpi", ] +BangumiJSON: TypeAlias = str -class SearchTorrent(RequestContent): + +class SearchTorrent(RequestContent, RSSAnalyser): def search_torrents( - self, keywords: list[str], site: str = "mikan" - ) -> list[TorrentBase]: - url = search_url(site, keywords) - # TorrentInfo to TorrentBase - torrents = self.get_torrents(url) + self, rss_item: RSSItem, limit: int = 5 + ) -> list[Torrent]: + torrents = self.get_torrents(rss_item.url, limit=limit) + return torrents - def to_dict(): - for torrent in torrents: - yield { - "name": torrent.name, - "torrent_link": torrent.torrent_link, - "homepage": torrent.homepage, - } + def analyse_keyword(self, keywords: list[str], site: str = "mikan") -> BangumiJSON: + rss_item = search_url(site, keywords) + torrents = self.search_torrents(rss_item) + # yield for EventSourceResponse (Server Send) + exist_list = [] + for torrent in torrents: + bangumi = self.torrent_to_data(torrent=torrent, rss=rss_item) + if bangumi and bangumi not in exist_list: + exist_list.append(bangumi) + bangumi.rss_link = self.special_url(bangumi, site).url + yield json.dumps(bangumi.dict(), separators=(',', ':')) - return [TorrentBase(**d) for d in to_dict()] - - def search_season(self, data: BangumiData): + @staticmethod + def special_url(data: Bangumi, site: str) -> RSSItem: keywords = [getattr(data, key) for key in SEARCH_KEY if getattr(data, key)] - torrents = self.search_torrents(keywords) - return [torrent for torrent in torrents if data.title_raw in torrent.name] + url = search_url(site, keywords) + return url - -if __name__ == "__main__": - with SearchTorrent() as st: - for t in st.search_torrents(["魔法科高校の劣等生"]): - print(t) + def search_season(self, data: Bangumi, site: str = "mikan") -> list[Torrent]: + rss_item = self.special_url(data, site) + torrents = self.search_torrents(rss_item) + return [torrent for torrent in torrents if data.title_raw in torrent.name] \ No newline at end of file diff --git a/backend/src/module/security/__init__.py b/backend/src/module/security/__init__.py index 7ce58e8c..e69de29b 100644 --- a/backend/src/module/security/__init__.py +++ b/backend/src/module/security/__init__.py @@ -1,2 +0,0 @@ -from .api import auth_user, get_current_user, get_token_data, update_user_info -from .jwt import create_access_token diff --git a/backend/src/module/security/api.py b/backend/src/module/security/api.py index 368abb0e..3b8cc510 100644 --- a/backend/src/module/security/api.py +++ b/backend/src/module/security/api.py @@ -1,32 +1,28 @@ -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Cookie from fastapi.security import OAuth2PasswordBearer -from module.database.user import AuthDB -from module.models.user import User +from module.database import Database +from module.models.user import User, UserUpdate from .jwt import verify_token oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") +active_user = [] -async def get_current_user(token: str = Depends(oauth2_scheme)): + +async def get_current_user(token: str = Cookie(None)): if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) + raise UNAUTHORIZED payload = verify_token(token) if not payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" - ) + raise UNAUTHORIZED username = payload.get("sub") - with AuthDB() as user_db: - user = user_db.get_user(username) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid username" - ) - return user + if not username: + raise UNAUTHORIZED + if username not in active_user: + raise UNAUTHORIZED + return username async def get_token_data(token: str = Depends(oauth2_scheme)): @@ -38,15 +34,23 @@ async def get_token_data(token: str = Depends(oauth2_scheme)): return payload -def update_user_info(user_data: User, current_user): +def update_user_info(user_data: UserUpdate, current_user): try: - with AuthDB() as db: - db.update_user(current_user.username, user_data) + with Database() as db: + db.user.update_user(current_user, user_data) return True except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -def auth_user(username, password): - with AuthDB() as db: - db.auth_user(username, password) +def auth_user(user: User): + with Database() as db: + resp = db.user.auth_user(user) + if resp.status: + active_user.append(user.username) + return resp + + +UNAUTHORIZED = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized" +) diff --git a/backend/src/module/security/jwt.py b/backend/src/module/security/jwt.py index 78ca595d..35c832a9 100644 --- a/backend/src/module/security/jwt.py +++ b/backend/src/module/security/jwt.py @@ -3,7 +3,14 @@ from datetime import datetime, timedelta from jose import JWTError, jwt from passlib.context import CryptContext -app_pwd_key = "auto_bangumi" + +def generate_key(): + import secrets + + return secrets.token_urlsafe(32) + + +app_pwd_key = generate_key() app_pwd_algorithm = "HS256" # Hashing 密码 diff --git a/backend/src/module/update/__init__.py b/backend/src/module/update/__init__.py index 0044a5be..ca1f94c5 100644 --- a/backend/src/module/update/__init__.py +++ b/backend/src/module/update/__init__.py @@ -1 +1,4 @@ from .data_migration import data_migration +from .startup import start_up, first_run +from .version_check import version_check +from .cross_version import from_30_to_31 diff --git a/backend/src/module/update/cross_version.py b/backend/src/module/update/cross_version.py new file mode 100644 index 00000000..79f386ff --- /dev/null +++ b/backend/src/module/update/cross_version.py @@ -0,0 +1,26 @@ +import re +from urllib3.util import parse_url + +from module.rss import RSSEngine + + +def from_30_to_31(): + with RSSEngine() as db: + db.migrate() + # Update poster link + bangumis = db.bangumi.search_all() + rss_pool = [] + for bangumi in bangumis: + if bangumi.poster_link: + rss_link = bangumi.rss_link.split(",")[-1] + if rss_link not in rss_pool and not re.search(r"\d+.\d+.\d+.\d+", rss_link): + rss_pool.append(rss_link) + root_path = parse_url(rss_link).host + bangumi.poster_link = f"https://{root_path}{bangumi.poster_link}" + db.bangumi.update_all(bangumis) + for rss in rss_pool: + if "mybangumi" in rss.lower(): + aggregate = True + else: + aggregate = False + db.add_rss(rss_link=rss, aggregate=aggregate) diff --git a/backend/src/module/update/data_migration.py b/backend/src/module/update/data_migration.py index b9ddf8db..f9775579 100644 --- a/backend/src/module/update/data_migration.py +++ b/backend/src/module/update/data_migration.py @@ -1,8 +1,6 @@ -import os - from module.conf import LEGACY_DATA_PATH -from module.database import BangumiDatabase -from module.models import BangumiData +from module.rss import RSSEngine +from module.models import Bangumi from module.utils import json_config @@ -14,9 +12,13 @@ def data_migration(): rss_link = old_data["rss_link"] new_data = [] for info in infos: - new_data.append(BangumiData(**info, rss_link=[rss_link])) - with BangumiDatabase() as database: - database.update_table() - database.insert_list(new_data) - + new_data.append(Bangumi(**info, rss_link=rss_link)) + with RSSEngine() as engine: + engine.bangumi.add_all(new_data) + engine.add_rss(rss_link) LEGACY_DATA_PATH.unlink(missing_ok=True) + + +def database_migration(): + with RSSEngine() as engine: + engine.migrate() diff --git a/backend/src/module/update/rss.py b/backend/src/module/update/rss.py new file mode 100644 index 00000000..59e3e59c --- /dev/null +++ b/backend/src/module/update/rss.py @@ -0,0 +1,6 @@ +from module.rss import RSSEngine + + +def update_main_rss(rss_link: str): + with RSSEngine() as engine: + engine.add_rss(rss_link, "main", True) diff --git a/backend/src/module/update/startup.py b/backend/src/module/update/startup.py new file mode 100644 index 00000000..a80120c0 --- /dev/null +++ b/backend/src/module/update/startup.py @@ -0,0 +1,17 @@ +import logging + +from module.rss import RSSEngine + +logger = logging.getLogger(__name__) + + +def start_up(): + with RSSEngine() as engine: + engine.create_table() + engine.user.add_default_user() + + +def first_run(): + with RSSEngine() as engine: + engine.create_table() + engine.user.add_default_user() diff --git a/backend/src/module/update/version_check.py b/backend/src/module/update/version_check.py new file mode 100644 index 00000000..f3a89197 --- /dev/null +++ b/backend/src/module/update/version_check.py @@ -0,0 +1,27 @@ +import semver + +from module.conf import VERSION, VERSION_PATH + + +def version_check() -> bool: + if VERSION == "DEV_VERSION": + return True + if not VERSION_PATH.exists(): + with open(VERSION_PATH, "w") as f: + f.write(VERSION + "\n") + return False + else: + with open(VERSION_PATH, "r+") as f: + # Read last version + versions = f.readlines() + last_version = versions[-1] + last_ver = semver.VersionInfo.parse(last_version) + now_ver = semver.VersionInfo.parse(VERSION) + if now_ver.minor == last_ver.minor: + return True + else: + if now_ver.minor > last_ver.minor: + f.write(VERSION + "\n") + return False + else: + return True diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index 80493bf4..bb2babf7 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -1,51 +1,78 @@ -from module.database import BangumiDatabase -from module.models import BangumiData +from sqlmodel import create_engine, SQLModel +from sqlmodel.pool import StaticPool + +from module.database.combine import Database +from module.models import Bangumi, Torrent, RSSItem -def test_database(): - TEST_PATH = "test/test.db" - test_data = BangumiData( - id=1, - official_title="test", +# sqlite mock engine +engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool +) + + +def test_bangumi_database(): + test_data = Bangumi( + official_title="无职转生,到了异世界就拿出真本事", year="2021", - title_raw="test", + title_raw="Mushoku Tensei", season=1, - season_raw="第一季", - group_name="test", - dpi="720p", - source="test", - subtitle="test", + season_raw="", + group_name="Lilith-Raws", + dpi="1080p", + source="Baha", + subtitle="CHT", eps_collect=False, offset=0, - filter=["720p", "\\d+-\\d+"], - rss_link=["test"], + filter="720p,\\d+-\\d+", + rss_link="test", poster_link="/test/test.jpg", added=False, rule_name=None, - save_path=None, + save_path="downloads/无职转生,到了异世界就拿出真本事/Season 1", deleted=False, ) - with BangumiDatabase(database=TEST_PATH) as database: - # create table - database.update_table() - with BangumiDatabase(database=TEST_PATH) as database: + with Database(engine) as db: + db.create_table() # insert - database.insert_one(test_data) - assert database.search_id(1) == test_data + db.bangumi.add(test_data) + assert db.bangumi.search_id(1) == test_data # update - test_data.official_title = "test2" - database.update_one(test_data) - assert database.search_id(1) == test_data + test_data.official_title = "无职转生,到了异世界就拿出真本事II" + db.bangumi.update(test_data) + assert db.bangumi.search_id(1) == test_data # search poster - assert database.match_poster("test") == "/test/test.jpg" + assert db.bangumi.match_poster("无职转生,到了异世界就拿出真本事II (2021)") == "/test/test.jpg" + + # match torrent + result = db.bangumi.match_torrent("[Lilith-Raws] 无职转生,到了异世界就拿出真本事 / Mushoku Tensei - 11 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]") + assert result.official_title == "无职转生,到了异世界就拿出真本事II" # delete - database.delete_one(1) - assert database.search_id(1) is None + db.bangumi.delete_one(1) + assert db.bangumi.search_id(1) is None - # Delete test database - import os - os.remove(TEST_PATH) +def test_torrent_database(): + test_data = Torrent( + name="[Sub Group]test S02 01 [720p].mkv", + url="https://test.com/test.mkv", + ) + with Database(engine) as db: + # insert + db.torrent.add(test_data) + assert db.torrent.search(1) == test_data + + # update + test_data.downloaded = True + db.torrent.update(test_data) + assert db.torrent.search(1) == test_data + + +def test_rss_database(): + rss_url = "https://test.com/test.xml" + + with Database(engine) as db: + db.rss.add(RSSItem(url=rss_url)) diff --git a/backend/src/test/test_rss_engine.py b/backend/src/test/test_rss_engine.py new file mode 100644 index 00000000..cda69f6e --- /dev/null +++ b/backend/src/test/test_rss_engine.py @@ -0,0 +1,18 @@ +from module.rss.engine import RSSEngine + +from .test_database import engine as e + + +def test_rss_engine(): + with RSSEngine(e) as engine: + rss_link = "https://mikanani.me/RSS/Bangumi?bangumiId=2353&subgroupid=552" + + engine.add_rss(rss_link, aggregate=False) + + result = engine.rss.search_active() + assert result[1].name == "Mikan Project - 无职转生~到了异世界就拿出真本事~" + + new_torrents = engine.pull_rss(result[1]) + torrent = new_torrents[0] + assert torrent.name == "[Lilith-Raws] 无职转生,到了异世界就拿出真本事 / Mushoku Tensei - 11 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]" + diff --git a/docker/etc/s6-overlay/s6-rc.d/init-fixuser/dependencies.d/init-old-compatible b/docker/etc/s6-overlay/s6-rc.d/init-fixuser/dependencies.d/init-old-compatible deleted file mode 100644 index e69de29b..00000000 diff --git a/docker/etc/s6-overlay/s6-rc.d/init-fixuser/run b/docker/etc/s6-overlay/s6-rc.d/init-fixuser/run deleted file mode 100644 index be9eb81c..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/init-fixuser/run +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/with-contenv bash -# shellcheck shell=bash - -groupmod -o -g "${PGID}" ab -usermod -o -u "${PUID}" ab - -chown ab:ab -R /app /ab diff --git a/docker/etc/s6-overlay/s6-rc.d/init-fixuser/type b/docker/etc/s6-overlay/s6-rc.d/init-fixuser/type deleted file mode 100644 index 3d92b15f..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/init-fixuser/type +++ /dev/null @@ -1 +0,0 @@ -oneshot \ No newline at end of file diff --git a/docker/etc/s6-overlay/s6-rc.d/init-fixuser/up b/docker/etc/s6-overlay/s6-rc.d/init-fixuser/up deleted file mode 100644 index f1ee2c7c..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/init-fixuser/up +++ /dev/null @@ -1 +0,0 @@ -/etc/s6-overlay/s6-rc.d/init-fixuser/run \ No newline at end of file diff --git a/docker/etc/s6-overlay/s6-rc.d/init-old-compatible/run b/docker/etc/s6-overlay/s6-rc.d/init-old-compatible/run deleted file mode 100644 index d3fe1184..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/init-old-compatible/run +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/with-contenv bash -# shellcheck shell=bash - -umask ${UMASK} - -if [ -f /config/bangumi.json ]; then - mv /config/bangumi.json /app/data/bangumi.json -fi diff --git a/docker/etc/s6-overlay/s6-rc.d/init-old-compatible/type b/docker/etc/s6-overlay/s6-rc.d/init-old-compatible/type deleted file mode 100644 index 3d92b15f..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/init-old-compatible/type +++ /dev/null @@ -1 +0,0 @@ -oneshot \ No newline at end of file diff --git a/docker/etc/s6-overlay/s6-rc.d/init-old-compatible/up b/docker/etc/s6-overlay/s6-rc.d/init-old-compatible/up deleted file mode 100644 index 593601f1..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/init-old-compatible/up +++ /dev/null @@ -1 +0,0 @@ -/etc/s6-overlay/s6-rc.d/init-old-compatible/run \ No newline at end of file diff --git a/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/dependencies.d/init-fixuser b/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/dependencies.d/init-fixuser deleted file mode 100644 index e69de29b..00000000 diff --git a/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/finish b/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/finish deleted file mode 100644 index b1ccd97a..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/finish +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/with-contenv bash -# shellcheck shell=bash - -pkill -f 'python3 main.py' \ No newline at end of file diff --git a/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/notification-fd b/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/notification-fd deleted file mode 100644 index e440e5c8..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/notification-fd +++ /dev/null @@ -1 +0,0 @@ -3 \ No newline at end of file diff --git a/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/run b/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/run deleted file mode 100644 index ea0d1347..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/run +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/with-contenv bash -# shellcheck shell=bash - -umask ${UMASK} - -if [ -f /app/config/config.json ]; then - AB_PORT=$(jq '.program.webui_port' /app/config/config.json) -elif [ -f /app/config/config_dev.json ]; then - AB_PORT=$(jq '.program.webui_port' /app/config/config_dev.json) -else - AB_PORT=7892 -fi - -exec \ - s6-notifyoncheck -d -n 300 -w 1000 -c "nc -z localhost ${AB_PORT}" \ - cd /app s6-setuidgid ab python3 main.py \ No newline at end of file diff --git a/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/type b/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/type deleted file mode 100644 index 1780f9f4..00000000 --- a/docker/etc/s6-overlay/s6-rc.d/svc-autobangumi/type +++ /dev/null @@ -1 +0,0 @@ -longrun \ No newline at end of file diff --git a/docker/etc/s6-overlay/s6-rc.d/user/contents.d/init-fixuser b/docker/etc/s6-overlay/s6-rc.d/user/contents.d/init-fixuser deleted file mode 100644 index e69de29b..00000000 diff --git a/docker/etc/s6-overlay/s6-rc.d/user/contents.d/init-old-compatible b/docker/etc/s6-overlay/s6-rc.d/user/contents.d/init-old-compatible deleted file mode 100644 index e69de29b..00000000 diff --git a/docker/etc/s6-overlay/s6-rc.d/user/contents.d/svc-autobangumi b/docker/etc/s6-overlay/s6-rc.d/user/contents.d/svc-autobangumi deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/deploy/quick-start.md b/docs/deploy/quick-start.md index eeebdeee..03f70a6d 100644 --- a/docs/deploy/quick-start.md +++ b/docs/deploy/quick-start.md @@ -24,7 +24,7 @@ docker run -d \ -v AutoBangumi_data:/app/data \ -p 7892:7892 \ --network=bridge \ - --dns=8.8.8.8 + --dns=8.8.8.8 \ --restart unless-stopped \ estrellaxd/auto_bangumi:latest @@ -32,7 +32,7 @@ docker run -d \ ### 选项2: 使用 Docker-compose 部署 -复制以下内容到 `docker-compose.yml` 文件中,然后运行 `docker-compose up -d` 即可。 +复制以下内容到 `docker-compose.yml` 文件中。 ```yaml version: "3.8" @@ -58,6 +58,12 @@ volumes: name: AutoBangumi_data ``` +运行以下命令启动容器。 + +```shell +docker compose up -d +``` + ## 安装 qBittorrent 如果你没有安装 qBittorrent,请先安装 qBittorrent。 diff --git a/docs/faq/index.md b/docs/faq/index.md index 347885f5..1f4ba560 100644 --- a/docs/faq/index.md +++ b/docs/faq/index.md @@ -25,6 +25,10 @@ 新版 WebUI 右上角有一个小圆点,绿色表示正常运行,红色表示出现错误,程序暂停。 +### 海报墙没有显示图片 + +AB 默认使用 `mikanani.me` 的地址作为海报图片的源地址,如果没有显示图片说明你访问 AB 主页的主机网络环境不能访问这些图片。 + ## 3.0 是如何管理番剧的 升级到 3.0 之后 AB 可以在 WebUI 中一键管理番剧种子和下载规则。所以依赖的是种子的下载路径和规则名称。 diff --git a/docs/home/index.md b/docs/home/index.md index 284e5140..34550861 100644 --- a/docs/home/index.md +++ b/docs/home/index.md @@ -83,6 +83,17 @@ title: 项目说明 +## 传播声明 + +由于 AutoBangumi 为非正规版权渠道获取番剧,因此: + +- **请勿**将 AutoBangumi 用于商业用途。 +- **请勿**将 AutoBangumi 制作为视频内容,于境内视频网站(版权利益方)传播。 +- **请勿**将 AutoBangumi 用于任何违反法律法规的行为。 + + +AutoBangumi 仅供学习交流使用。 + ## Licence [MIT licence](https://github.com/EstrellaXD/Auto_Bangumi/blob/main/LICENSE) diff --git a/docs/image/preview/window.png b/docs/image/preview/window.png index 54b39a8a..1dbd6263 100644 Binary files a/docs/image/preview/window.png and b/docs/image/preview/window.png differ diff --git a/docs/index.md b/docs/index.md index 143414ce..cd6699a1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -65,6 +65,13 @@ Thanks to ![](https://contrib.rocks/image?repo=EstrellaXD/Auto_Bangumi){class=contributors-avatar} ](https://github.com/EstrellaXD/Auto_Bangumi/graphs/contributors) +## 传播声明 + +由于 AutoBangumi 为非正规版权渠道获取番剧,因此: + +- **请勿**将 AutoBangumi 用于商业用途。 +- **请勿**将 AutoBangumi 制作为视频内容,于境内视频网站(版权利益方)传播。 +- **请勿**将 AutoBangumi 用于任何违反法律法规的行为。 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..080c6b21 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# shellcheck shell=bash + +umask ${UMASK} + +if [ -f /config/bangumi.json ]; then + mv /config/bangumi.json /app/data/bangumi.json +fi + +groupmod -o -g "${PGID}" ab +usermod -o -u "${PUID}" ab + +chown ab:ab -R /app /home/ab + +exec su-exec "${PUID}:${PGID}" python3 main.py \ No newline at end of file diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000..4521b494 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,8 @@ +{ + "pythonPath": "/opt/homebrew/Caskroom/miniforge/base/envs/auto_bangumi/bin/python", + "root": "backend/src", + "venvPath": "/opt/homebrew/Caskroom/miniforge/base/envs", + "venv": "auto_bangumi", + "typeCheckingMode": "basic", + "reportMissingImports": true +} diff --git a/webui/.neoconf.json b/webui/.neoconf.json new file mode 100644 index 00000000..b019fc2a --- /dev/null +++ b/webui/.neoconf.json @@ -0,0 +1,3 @@ +{ + "volar": { "enable": true } +} diff --git a/webui/.npmrc b/webui/.npmrc index 13e14bcc..dbed7938 100644 --- a/webui/.npmrc +++ b/webui/.npmrc @@ -1 +1,3 @@ public-hoist-pattern[]=@vue/runtime-core +public-hoist-pattern[]=*eslint* +public-hoist-pattern[]=*prettier* diff --git a/webui/index.html b/webui/index.html index 58c6b7b2..356c7f49 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2,10 +2,13 @@ - - Auto_Bangumi + + + + + AutoBangumi

diff --git a/webui/package.json b/webui/package.json index b28cbbe6..f423c373 100644 --- a/webui/package.json +++ b/webui/package.json @@ -14,17 +14,21 @@ "preview": "vite preview", "test": "vitest", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "generate-pwa-assets": "pwa-assets-generator --preset minimal public/images/logo.svg" }, "dependencies": { "@headlessui/vue": "^1.7.13", + "@vueuse/components": "^10.4.1", "@vueuse/core": "^8.9.4", "axios": "^0.27.2", "lodash": "^4.17.21", "naive-ui": "^2.34.4", "pinia": "^2.1.3", + "rxjs": "^7.8.1", "vue": "^3.3.4", "vue-i18n": "^9.2.2", + "vue-inline-svg": "^3.1.2", "vue-router": "^4.2.1" }, "devDependencies": { @@ -40,9 +44,11 @@ "@storybook/vue3-vite": "^7.0.12", "@types/lodash": "^4.14.194", "@types/node": "^18.16.14", + "@unocss/preset-attributify": "^0.55.3", "@unocss/preset-rem-to-px": "^0.51.13", "@unocss/reset": "^0.51.13", "@vitejs/plugin-vue": "^4.2.0", + "@vue/runtime-dom": "^3.3.4", "eslint": "^8.41.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-storybook": "^0.6.12", @@ -58,6 +64,7 @@ "unplugin-vue-components": "^0.24.1", "unplugin-vue-router": "^0.6.4", "vite": "^4.3.5", + "vite-plugin-pwa": "^0.16.4", "vitest": "^0.30.1", "vue-tsc": "^1.6.4" } diff --git a/webui/pnpm-lock.yaml b/webui/pnpm-lock.yaml index c8904dd0..21ca7efd 100644 --- a/webui/pnpm-lock.yaml +++ b/webui/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -8,6 +8,9 @@ dependencies: '@headlessui/vue': specifier: ^1.7.13 version: 1.7.13(vue@3.3.4) + '@vueuse/components': + specifier: ^10.4.1 + version: 10.4.1(vue@3.3.4) '@vueuse/core': specifier: ^8.9.4 version: 8.9.4(vue@3.3.4) @@ -23,12 +26,18 @@ dependencies: pinia: specifier: ^2.1.3 version: 2.1.3(typescript@4.9.5)(vue@3.3.4) + rxjs: + specifier: ^7.8.1 + version: 7.8.1 vue: specifier: ^3.3.4 version: 3.3.4 vue-i18n: specifier: ^9.2.2 version: 9.2.2(vue@3.3.4) + vue-inline-svg: + specifier: ^3.1.2 + version: 3.1.2(vue@3.3.4) vue-router: specifier: ^4.2.1 version: 4.2.1(vue@3.3.4) @@ -42,7 +51,7 @@ devDependencies: version: 1.4.2(vue@3.3.4) '@intlify/unplugin-vue-i18n': specifier: ^0.11.0 - version: 0.11.0(vue-i18n@9.2.2) + version: 0.11.0(rollup@2.79.1)(vue-i18n@9.2.2) '@storybook/addon-essentials': specifier: ^7.0.12 version: 7.0.12(react-dom@18.2.0)(react@18.2.0) @@ -70,6 +79,9 @@ devDependencies: '@types/node': specifier: ^18.16.14 version: 18.16.14 + '@unocss/preset-attributify': + specifier: ^0.55.3 + version: 0.55.3 '@unocss/preset-rem-to-px': specifier: ^0.51.13 version: 0.51.13 @@ -79,6 +91,9 @@ devDependencies: '@vitejs/plugin-vue': specifier: ^4.2.0 version: 4.2.3(vite@4.3.5)(vue@3.3.4) + '@vue/runtime-dom': + specifier: ^3.3.4 + version: 3.3.4 eslint: specifier: ^8.41.0 version: 8.41.0 @@ -111,19 +126,22 @@ devDependencies: version: 4.9.5 unocss: specifier: ^0.51.13 - version: 0.51.13(postcss@8.4.23)(vite@4.3.5) + version: 0.51.13(postcss@8.4.23)(rollup@2.79.1)(vite@4.3.5) unplugin-auto-import: specifier: ^0.10.3 - version: 0.10.3(@vueuse/core@8.9.4)(esbuild@0.17.19)(vite@4.3.5) + version: 0.10.3(@vueuse/core@8.9.4)(esbuild@0.17.19)(rollup@2.79.1)(vite@4.3.5) unplugin-vue-components: specifier: ^0.24.1 - version: 0.24.1(vue@3.3.4) + version: 0.24.1(rollup@2.79.1)(vue@3.3.4) unplugin-vue-router: specifier: ^0.6.4 - version: 0.6.4(vue-router@4.2.1)(vue@3.3.4) + version: 0.6.4(rollup@2.79.1)(vue-router@4.2.1)(vue@3.3.4) vite: specifier: ^4.3.5 version: 4.3.5(@types/node@18.16.14)(sass@1.62.1) + vite-plugin-pwa: + specifier: ^0.16.4 + version: 0.16.4(vite@4.3.5)(workbox-build@7.0.0)(workbox-window@7.0.0) vitest: specifier: ^0.30.1 version: 0.30.1(sass@1.62.1) @@ -252,6 +270,18 @@ packages: resolution: {integrity: sha512-vy9fM3pIxZmX07dL+VX1aZe7ynZ+YyB0jY+jE6r3hOK6GNY2t6W8rzpFC4tgpbXUYABkFQwgJq2XYXlxbXAI0g==} dev: true + /@apideck/better-ajv-errors@0.3.6(ajv@8.12.0): + resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} + engines: {node: '>=10'} + peerDependencies: + ajv: '>=8' + dependencies: + ajv: 8.12.0 + json-schema: 0.4.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + dev: true + /@aw-web-design/x-default-browser@1.4.88: resolution: {integrity: sha512-AkEmF0wcwYC2QkhK703Y83fxWARttIWXDmQN8+cof8FmFZ5BRhnNXGymeb1S73bOCLfWjYELxtujL56idCN/XA==} hasBin: true @@ -1914,7 +1944,7 @@ packages: engines: {node: '>= 14'} dev: true - /@intlify/unplugin-vue-i18n@0.11.0(vue-i18n@9.2.2): + /@intlify/unplugin-vue-i18n@0.11.0(rollup@2.79.1)(vue-i18n@9.2.2): resolution: {integrity: sha512-ivcLZo08fvepHWV8o5lcKfhcKFSWqhwrqIAU6pUIbvq2ICo9fnXnIPYIZj7FeuHDLW1G3ADm44ZhQC3nYmvDlg==} engines: {node: '>= 14.16'} peerDependencies: @@ -1931,7 +1961,7 @@ packages: dependencies: '@intlify/bundle-utils': 6.0.1(vue-i18n@9.2.2) '@intlify/shared': 9.3.0-beta.17 - '@rollup/pluginutils': 5.0.2 + '@rollup/pluginutils': 5.0.2(rollup@2.79.1) '@vue/compiler-sfc': 3.3.4 debug: 4.3.4 fast-glob: 3.2.12 @@ -2042,6 +2072,13 @@ packages: engines: {node: '>=6.0.0'} dev: true + /@jridgewell/source-map@0.3.5: + resolution: {integrity: sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==} + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 + dev: true + /@jridgewell/sourcemap-codec@1.4.14: resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} dev: true @@ -2102,6 +2139,60 @@ packages: resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} dev: true + /@rollup/plugin-babel@5.3.1(@babel/core@7.21.8)(rollup@2.79.1): + resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} + engines: {node: '>= 10.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + dependencies: + '@babel/core': 7.21.8 + '@babel/helper-module-imports': 7.21.4 + '@rollup/pluginutils': 3.1.0(rollup@2.79.1) + rollup: 2.79.1 + dev: true + + /@rollup/plugin-node-resolve@11.2.1(rollup@2.79.1): + resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==} + engines: {node: '>= 10.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 + dependencies: + '@rollup/pluginutils': 3.1.0(rollup@2.79.1) + '@types/resolve': 1.17.1 + builtin-modules: 3.3.0 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.2 + rollup: 2.79.1 + dev: true + + /@rollup/plugin-replace@2.4.2(rollup@2.79.1): + resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + dependencies: + '@rollup/pluginutils': 3.1.0(rollup@2.79.1) + magic-string: 0.25.9 + rollup: 2.79.1 + dev: true + + /@rollup/pluginutils@3.1.0(rollup@2.79.1): + resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 + dependencies: + '@types/estree': 0.0.39 + estree-walker: 1.0.1 + picomatch: 2.3.1 + rollup: 2.79.1 + dev: true + /@rollup/pluginutils@4.2.1: resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} engines: {node: '>= 8.0.0'} @@ -2110,7 +2201,7 @@ packages: picomatch: 2.3.1 dev: true - /@rollup/pluginutils@5.0.2: + /@rollup/pluginutils@5.0.2(rollup@2.79.1): resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -2122,6 +2213,7 @@ packages: '@types/estree': 1.0.1 estree-walker: 2.0.2 picomatch: 2.3.1 + rollup: 2.79.1 dev: true /@sinclair/typebox@0.25.24: @@ -2985,6 +3077,15 @@ packages: - supports-color dev: true + /@surma/rollup-plugin-off-main-thread@2.2.3: + resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} + dependencies: + ejs: 3.1.9 + json5: 2.2.3 + magic-string: 0.25.9 + string.prototype.matchall: 4.0.8 + dev: true + /@testing-library/dom@8.20.0: resolution: {integrity: sha512-d9ULIT+a4EXLX3UU8FBjauG9NnsZHkHztXoIcTsOKoOw030fyjheN9svkTULjJxtYag9DZz5Jz5qkWZDPxTFwA==} engines: {node: '>=12'} @@ -3077,6 +3178,10 @@ packages: resolution: {integrity: sha512-ZmiaE3wglXVWBM9fyVC17aGPkLo/UgaOjEiI2FXQfyczrCefORPxIe+2dVmnmk3zkVIbizjrlQzmPGhSYGXG5g==} dev: true + /@types/estree@0.0.39: + resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} + dev: true + /@types/estree@1.0.1: resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} dev: true @@ -3226,6 +3331,12 @@ packages: csstype: 3.1.2 dev: true + /@types/resolve@1.17.1: + resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} + dependencies: + '@types/node': 18.16.14 + dev: true + /@types/scheduler@0.16.3: resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} dev: true @@ -3248,6 +3359,10 @@ packages: '@types/node': 18.16.14 dev: true + /@types/trusted-types@2.0.3: + resolution: {integrity: sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==} + dev: true + /@types/unist@2.0.6: resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} dev: true @@ -3255,6 +3370,10 @@ packages: /@types/web-bluetooth@0.0.14: resolution: {integrity: sha512-5d2RhCard1nQUC3aHcq/gHzWYO6K0WJmAbjO7mQJgCQKtZpgXxv1rOM6O/dBDhDYYVutk1sciOgNSe+5YyfM8A==} + /@types/web-bluetooth@0.0.17: + resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==} + dev: false + /@types/yargs-parser@21.0.0: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true @@ -3401,24 +3520,24 @@ packages: eslint-visitor-keys: 3.4.1 dev: true - /@unocss/astro@0.51.13(vite@4.3.5): + /@unocss/astro@0.51.13(rollup@2.79.1)(vite@4.3.5): resolution: {integrity: sha512-Dul0ZJNwseGBxngBMfghfTsf0quf4HcQcqJuIDzA1T+ueavpwf4QScwbDuS0BqFO4ZiIVSItA7f6eLe31PHUmw==} dependencies: '@unocss/core': 0.51.13 '@unocss/reset': 0.51.13 - '@unocss/vite': 0.51.13(vite@4.3.5) + '@unocss/vite': 0.51.13(rollup@2.79.1)(vite@4.3.5) transitivePeerDependencies: - rollup - vite dev: true - /@unocss/cli@0.51.13: + /@unocss/cli@0.51.13(rollup@2.79.1): resolution: {integrity: sha512-g5CmSVyMFIgw/uStVlABldw+EYsrCyGjHd9jQMMTSZbV9IWuM0Tf+ILAZ+B4iXs62ctnrxPYH3Mha6IIuuZXZg==} engines: {node: '>=14'} hasBin: true dependencies: '@ampproject/remapping': 2.2.1 - '@rollup/pluginutils': 5.0.2 + '@rollup/pluginutils': 5.0.2(rollup@2.79.1) '@unocss/config': 0.51.13 '@unocss/core': 0.51.13 '@unocss/preset-uno': 0.51.13 @@ -3446,6 +3565,10 @@ packages: resolution: {integrity: sha512-SclWkqY2c+p5+PiqrbQkhJNEExPdeo71/aGFye10tpBkgPJWd5xC7dhg5F8M4VPNBtuNCrvBWyqNnunMyuz/WQ==} dev: true + /@unocss/core@0.55.3: + resolution: {integrity: sha512-2hV9QlE/iOM4DHQ7i6L8sMC1t5/OVAz6AfGHjetTXcgbNfDCsHWqE8jhLZ1y2DeUvKwJvj2A09sYbYQ8E27+Gg==} + dev: true + /@unocss/extractor-arbitrary-variants@0.51.13: resolution: {integrity: sha512-lF7p0ea/MeNf4IsjzNhRNYP8u+f1h5JjhTzcvFpQo/vpBvuM5ZCyqp4mkXxYnLNLFfTLsc+MxXaU34IXxpw1QA==} dependencies: @@ -3479,6 +3602,12 @@ packages: '@unocss/core': 0.51.13 dev: true + /@unocss/preset-attributify@0.55.3: + resolution: {integrity: sha512-h3t6hPIk8pll3LubIIIsgRigvJivK3PX308Pi9Q0IUdw0vFq4S80iLQ1N0kRchQtgOaAIGffo9ux+TCbyunP3A==} + dependencies: + '@unocss/core': 0.55.3 + dev: true + /@unocss/preset-icons@0.51.13: resolution: {integrity: sha512-iL9s1NUVeWe3WSh5LHn7vy+veCAag9AFA50IfNlHuAARhuI8JtrMQA8dOXrWrzM0zWBMB+BVIkVaMVrF257n+Q==} dependencies: @@ -3576,13 +3705,13 @@ packages: '@unocss/core': 0.51.13 dev: true - /@unocss/vite@0.51.13(vite@4.3.5): + /@unocss/vite@0.51.13(rollup@2.79.1)(vite@4.3.5): resolution: {integrity: sha512-WwyaPnu1XfRiFy4uxXwBuWaL7J1Rcaetsw5lJQUIUdSBTblsd6W7sW+MYTsLfAlA9FUxWDK4ESdI51Xgq4glxw==} peerDependencies: vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 dependencies: '@ampproject/remapping': 2.2.1 - '@rollup/pluginutils': 5.0.2 + '@rollup/pluginutils': 5.0.2(rollup@2.79.1) '@unocss/config': 0.51.13 '@unocss/core': 0.51.13 '@unocss/inspector': 0.51.13 @@ -3691,7 +3820,7 @@ packages: typescript: 4.9.5 dev: true - /@vue-macros/common@1.3.1(vue@3.3.4): + /@vue-macros/common@1.3.1(rollup@2.79.1)(vue@3.3.4): resolution: {integrity: sha512-Lc5aP/8HNJD1XrnvpeNuWcCf82bZdR3auN/chA1b/1rKZgSnmQkH9f33tKO9qLwXSy+u4hpCi8Rw+oUuF1KCeg==} engines: {node: '>=14.19.0'} peerDependencies: @@ -3701,7 +3830,7 @@ packages: optional: true dependencies: '@babel/types': 7.21.5 - '@rollup/pluginutils': 5.0.2 + '@rollup/pluginutils': 5.0.2(rollup@2.79.1) '@vue/compiler-sfc': 3.3.4 local-pkg: 0.4.3 magic-string-ast: 0.1.2 @@ -3786,6 +3915,29 @@ packages: /@vue/shared@3.3.4: resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==} + /@vueuse/components@10.4.1(vue@3.3.4): + resolution: {integrity: sha512-hEWeumCfH394fkEYc/hng6T5VcjVkdqx7b75Sd6z4Uw3anjeo93Zp9qqtzFOv5bAmHls3Zy04Kowo1glrxDFRQ==} + dependencies: + '@vueuse/core': 10.4.1(vue@3.3.4) + '@vueuse/shared': 10.4.1(vue@3.3.4) + vue-demi: 0.14.5(vue@3.3.4) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: false + + /@vueuse/core@10.4.1(vue@3.3.4): + resolution: {integrity: sha512-DkHIfMIoSIBjMgRRvdIvxsyboRZQmImofLyOHADqiVbQVilP8VVHDhBX2ZqoItOgu7dWa8oXiNnScOdPLhdEXg==} + dependencies: + '@types/web-bluetooth': 0.0.17 + '@vueuse/metadata': 10.4.1 + '@vueuse/shared': 10.4.1(vue@3.3.4) + vue-demi: 0.14.5(vue@3.3.4) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: false + /@vueuse/core@8.9.4(vue@3.3.4): resolution: {integrity: sha512-B/Mdj9TK1peFyWaPof+Zf/mP9XuGAngaJZBwPaXBvU3aCTZlx3ltlrFFFyMV4iGBwsjSCeUCgZrtkEj9dS2Y3Q==} peerDependencies: @@ -3803,9 +3955,22 @@ packages: vue: 3.3.4 vue-demi: 0.14.5(vue@3.3.4) + /@vueuse/metadata@10.4.1: + resolution: {integrity: sha512-2Sc8X+iVzeuMGHr6O2j4gv/zxvQGGOYETYXEc41h0iZXIRnRbJZGmY/QP8dvzqUelf8vg0p/yEA5VpCEu+WpZg==} + dev: false + /@vueuse/metadata@8.9.4: resolution: {integrity: sha512-IwSfzH80bnJMzqhaapqJl9JRIiyQU0zsRGEgnxN6jhq7992cPUJIRfV+JHRIZXjYqbwt07E1gTEp0R0zPJ1aqw==} + /@vueuse/shared@10.4.1(vue@3.3.4): + resolution: {integrity: sha512-vz5hbAM4qA0lDKmcr2y3pPdU+2EVw/yzfRsBdu+6+USGa4PxqSQRYIUC9/NcT06y+ZgaTsyURw2I9qOFaaXHAg==} + dependencies: + vue-demi: 0.14.5(vue@3.3.4) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + dev: false + /@vueuse/shared@8.9.4(vue@3.3.4): resolution: {integrity: sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==} peerDependencies: @@ -3865,6 +4030,12 @@ packages: hasBin: true dev: true + /acorn@8.10.0: + resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /acorn@8.8.2: resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} engines: {node: '>=0.4.0'} @@ -3907,6 +4078,15 @@ packages: uri-js: 4.4.1 dev: true + /ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: true + /ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} dependencies: @@ -4082,6 +4262,11 @@ packages: /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + /at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + dev: true + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} @@ -4508,11 +4693,20 @@ packages: dependencies: delayed-stream: 1.0.0 + /commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + dev: true + /commander@6.2.1: resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} engines: {node: '>= 6'} dev: true + /common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + dev: true + /commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} dev: true @@ -4757,6 +4951,11 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dev: true + /default-browser-id@3.0.0: resolution: {integrity: sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==} engines: {node: '>=12'} @@ -5573,6 +5772,10 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker@1.0.1: + resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} + dev: true + /estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -5866,6 +6069,16 @@ packages: universalify: 2.0.0 dev: true + /fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: true + /fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} @@ -5941,6 +6154,10 @@ packages: engines: {node: '>=12.17'} dev: true + /get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + dev: true + /get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -6238,6 +6455,10 @@ packages: safer-buffer: 2.1.2 dev: true + /idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: true @@ -6443,6 +6664,10 @@ packages: resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} dev: true + /is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + dev: true + /is-nan@1.3.2: resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} engines: {node: '>= 0.4'} @@ -6468,6 +6693,11 @@ packages: engines: {node: '>=0.12.0'} dev: true + /is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + dev: true + /is-path-cwd@2.2.0: resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} engines: {node: '>=6'} @@ -6497,6 +6727,11 @@ packages: has-tostringtag: 1.0.0 dev: true + /is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + dev: true + /is-set@2.0.2: resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} dev: true @@ -6660,6 +6895,15 @@ packages: picomatch: 2.3.1 dev: true + /jest-worker@26.6.2: + resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/node': 18.16.14 + merge-stream: 2.0.0 + supports-color: 7.2.0 + dev: true + /jest-worker@29.5.0: resolution: {integrity: sha512-NcrQnevGoSp4b5kg+akIpthoAFHxPBcb5P6mYPY0fUNT+sSvmtu6jlkEle3anczUKIKEbMxFimk9oTP/tpIPgA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6758,6 +7002,14 @@ packages: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: true + + /json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + dev: true + /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true @@ -6808,6 +7060,11 @@ packages: graceful-fs: 4.2.11 dev: true + /jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + dev: true + /jstransformer@1.0.0: resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} dependencies: @@ -6902,6 +7159,10 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + dev: true + /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -6948,6 +7209,12 @@ packages: magic-string: 0.30.0 dev: true + /magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + /magic-string@0.26.7: resolution: {integrity: sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==} engines: {node: '>=12'} @@ -7724,6 +7991,16 @@ packages: hasBin: true dev: true + /pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + dev: true + + /pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + dev: true + /pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -7946,6 +8223,12 @@ packages: resolution: {integrity: sha512-BBea6L67bYLtdbOqfp8f58fPMqEwx0doL+pAi8TZyp2YWz8R9G8z9x75CZI8W+ftqhFHCpEX2cRnUUXK130iKA==} dev: true + /randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + dependencies: + safe-buffer: 5.2.1 + dev: true + /range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -8155,6 +8438,11 @@ packages: unist-util-visit: 2.0.3 dev: true + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: true + /requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -8205,6 +8493,27 @@ packages: glob: 7.2.3 dev: true + /rollup-plugin-terser@7.0.2(rollup@2.79.1): + resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==} + deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser + peerDependencies: + rollup: ^2.0.0 + dependencies: + '@babel/code-frame': 7.21.4 + jest-worker: 26.6.2 + rollup: 2.79.1 + serialize-javascript: 4.0.0 + terser: 5.19.2 + dev: true + + /rollup@2.79.1: + resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + /rollup@3.23.0: resolution: {integrity: sha512-h31UlwEi7FHihLe1zbk+3Q7z1k/84rb9BSwmBSr/XjOCEaBJ2YyedQDuM0t/kfOS0IxM+vk1/zI9XxYj9V+NJQ==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -8219,6 +8528,12 @@ packages: queue-microtask: 1.2.3 dev: true + /rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + dependencies: + tslib: 2.5.2 + dev: false + /safe-buffer@5.1.1: resolution: {integrity: sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==} dev: true @@ -8321,6 +8636,12 @@ packages: - supports-color dev: true + /serialize-javascript@4.0.0: + resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==} + dependencies: + randombytes: 2.1.0 + dev: true + /serve-favicon@2.5.0: resolution: {integrity: sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==} engines: {node: '>= 0.8.0'} @@ -8437,6 +8758,13 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + /source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + dependencies: + whatwg-url: 7.1.0 + dev: true + /sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} deprecated: Please use @jridgewell/sourcemap-codec instead @@ -8521,6 +8849,19 @@ packages: strip-ansi: 6.0.1 dev: true + /string.prototype.matchall@4.0.8: + resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + internal-slot: 1.0.5 + regexp.prototype.flags: 1.5.0 + side-channel: 1.0.4 + dev: true + /string.prototype.trim@1.2.7: resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} engines: {node: '>= 0.4'} @@ -8558,6 +8899,15 @@ packages: safe-buffer: 5.2.1 dev: true + /stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + dev: true + /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -8570,6 +8920,11 @@ packages: engines: {node: '>=4'} dev: true + /strip-comments@2.0.1: + resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} + engines: {node: '>=10'} + dev: true + /strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -8679,6 +9034,16 @@ packages: rimraf: 2.6.3 dev: true + /tempy@0.6.0: + resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} + engines: {node: '>=10'} + dependencies: + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + dev: true + /tempy@1.0.1: resolution: {integrity: sha512-biM9brNqxSc04Ee71hzFbryD11nX7VPhQQY32AdDmjFvodsRFz/3ufeoTZ6uYkRFfGo188tENcASNs3vTdsM0w==} engines: {node: '>=10'} @@ -8690,6 +9055,17 @@ packages: unique-string: 2.0.0 dev: true + /terser@5.19.2: + resolution: {integrity: sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + '@jridgewell/source-map': 0.3.5 + acorn: 8.10.0 + commander: 2.20.3 + source-map-support: 0.5.21 + dev: true + /test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -8762,6 +9138,12 @@ packages: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: true + /tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + dependencies: + punycode: 2.3.0 + dev: true + /treemate@0.3.11: resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==} dev: false @@ -8790,7 +9172,6 @@ packages: /tslib@2.5.2: resolution: {integrity: sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==} - dev: true /tsutils@3.21.0(typescript@4.9.5): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} @@ -8979,7 +9360,7 @@ packages: engines: {node: '>= 10.0.0'} dev: true - /unocss@0.51.13(postcss@8.4.23)(vite@4.3.5): + /unocss@0.51.13(postcss@8.4.23)(rollup@2.79.1)(vite@4.3.5): resolution: {integrity: sha512-EAhuQ97D7E+EsTdlCL+xoWEsvz46Se9ZAtHhJ+1W+DzMky9qrDLRyR8Caf2TPbz8dw/z0qYhoPr6/aJARG4r0g==} engines: {node: '>=14'} peerDependencies: @@ -8988,8 +9369,8 @@ packages: '@unocss/webpack': optional: true dependencies: - '@unocss/astro': 0.51.13(vite@4.3.5) - '@unocss/cli': 0.51.13 + '@unocss/astro': 0.51.13(rollup@2.79.1)(vite@4.3.5) + '@unocss/cli': 0.51.13(rollup@2.79.1) '@unocss/core': 0.51.13 '@unocss/extractor-arbitrary-variants': 0.51.13 '@unocss/postcss': 0.51.13(postcss@8.4.23) @@ -9007,7 +9388,7 @@ packages: '@unocss/transformer-compile-class': 0.51.13 '@unocss/transformer-directives': 0.51.13 '@unocss/transformer-variant-group': 0.51.13 - '@unocss/vite': 0.51.13(vite@4.3.5) + '@unocss/vite': 0.51.13(rollup@2.79.1)(vite@4.3.5) transitivePeerDependencies: - postcss - rollup @@ -9020,7 +9401,7 @@ packages: engines: {node: '>= 0.8'} dev: true - /unplugin-auto-import@0.10.3(@vueuse/core@8.9.4)(esbuild@0.17.19)(vite@4.3.5): + /unplugin-auto-import@0.10.3(@vueuse/core@8.9.4)(esbuild@0.17.19)(rollup@2.79.1)(vite@4.3.5): resolution: {integrity: sha512-tODQr7ZBnsBZ9lKaz2mqszKVi/4ALuLtS4gc1xwpcsBav5TCAl0HFSMuai1qL4AkYEwD2HPqK04LocCyK+D0KQ==} engines: {node: '>=14'} peerDependencies: @@ -9035,7 +9416,7 @@ packages: local-pkg: 0.4.3 magic-string: 0.26.7 unimport: 0.6.8 - unplugin: 0.8.1(esbuild@0.17.19)(vite@4.3.5) + unplugin: 0.8.1(esbuild@0.17.19)(rollup@2.79.1)(vite@4.3.5) transitivePeerDependencies: - esbuild - rollup @@ -9043,7 +9424,7 @@ packages: - webpack dev: true - /unplugin-vue-components@0.24.1(vue@3.3.4): + /unplugin-vue-components@0.24.1(rollup@2.79.1)(vue@3.3.4): resolution: {integrity: sha512-T3A8HkZoIE1Cja95xNqolwza0yD5IVlgZZ1PVAGvVCx8xthmjsv38xWRCtHtwl+rvZyL9uif42SRkDGw9aCfMA==} engines: {node: '>=14'} peerDependencies: @@ -9057,7 +9438,7 @@ packages: optional: true dependencies: '@antfu/utils': 0.7.2 - '@rollup/pluginutils': 5.0.2 + '@rollup/pluginutils': 5.0.2(rollup@2.79.1) chokidar: 3.5.3 debug: 4.3.4 fast-glob: 3.2.12 @@ -9072,7 +9453,7 @@ packages: - supports-color dev: true - /unplugin-vue-router@0.6.4(vue-router@4.2.1)(vue@3.3.4): + /unplugin-vue-router@0.6.4(rollup@2.79.1)(vue-router@4.2.1)(vue@3.3.4): resolution: {integrity: sha512-9THVhhtbVFxbsIibjK59oPwMI1UCxRWRPX7azSkTUABsxovlOXJys5SJx0kd/0oKIqNJuYgkRfAgPuO77SqCOg==} peerDependencies: vue-router: ^4.1.0 @@ -9081,8 +9462,8 @@ packages: optional: true dependencies: '@babel/types': 7.21.5 - '@rollup/pluginutils': 5.0.2 - '@vue-macros/common': 1.3.1(vue@3.3.4) + '@rollup/pluginutils': 5.0.2(rollup@2.79.1) + '@vue-macros/common': 1.3.1(rollup@2.79.1)(vue@3.3.4) ast-walker-scope: 0.4.1 chokidar: 3.5.3 fast-glob: 3.2.12 @@ -9108,7 +9489,7 @@ packages: webpack-virtual-modules: 0.4.6 dev: true - /unplugin@0.8.1(esbuild@0.17.19)(vite@4.3.5): + /unplugin@0.8.1(esbuild@0.17.19)(rollup@2.79.1)(vite@4.3.5): resolution: {integrity: sha512-o7rUZoPLG1fH4LKinWgb77gDtTE6mw/iry0Pq0Z5UPvZ9+HZ1/4+7fic7t58s8/CGkPrDpGq+RltO+DmswcR4g==} peerDependencies: esbuild: '>=0.13' @@ -9128,6 +9509,7 @@ packages: acorn: 8.8.2 chokidar: 3.5.3 esbuild: 0.17.19 + rollup: 2.79.1 vite: 4.3.5(@types/node@18.16.14)(sass@1.62.1) webpack-sources: 3.2.3 webpack-virtual-modules: 0.4.6 @@ -9156,6 +9538,11 @@ packages: engines: {node: '>=8'} dev: true + /upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + dev: true + /update-browserslist-db@1.0.11(browserslist@4.21.5): resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} hasBin: true @@ -9250,6 +9637,24 @@ packages: - terser dev: true + /vite-plugin-pwa@0.16.4(vite@4.3.5)(workbox-build@7.0.0)(workbox-window@7.0.0): + resolution: {integrity: sha512-lmwHFIs9zI2H9bXJld/zVTbCqCQHZ9WrpyDMqosICDV0FVnCJwniX1NMDB79HGTIZzOQkY4gSZaVTJTw6maz/Q==} + engines: {node: '>=16.0.0'} + peerDependencies: + vite: ^3.1.0 || ^4.0.0 + workbox-build: ^7.0.0 + workbox-window: ^7.0.0 + dependencies: + debug: 4.3.4 + fast-glob: 3.2.12 + pretty-bytes: 6.1.1 + vite: 4.3.5(@types/node@18.16.14)(sass@1.62.1) + workbox-build: 7.0.0 + workbox-window: 7.0.0 + transitivePeerDependencies: + - supports-color + dev: true + /vite@4.3.5(@types/node@18.16.14)(sass@1.62.1): resolution: {integrity: sha512-0gEnL9wiRFxgz40o/i/eTBwm+NEbpUeTWhzKrZDSdKm6nplj+z4lKz8ANDgildxHm47Vg8EUia0aicKbawUVVA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -9434,6 +9839,14 @@ packages: vue: 3.3.4 dev: true + /vue-inline-svg@3.1.2(vue@3.3.4): + resolution: {integrity: sha512-K01sLANBnjosObee4JrBu/igXpYIFhQfy4EcEyVWxEWf6nmrxp7Isz6pmeRCsWx6XGrGWfrQH3uNwt4nOmrFdA==} + peerDependencies: + vue: ^3 + dependencies: + vue: 3.3.4 + dev: false + /vue-router@4.2.1(vue@3.3.4): resolution: {integrity: sha512-nW28EeifEp8Abc5AfmAShy5ZKGsGzjcnZ3L1yc2DYUo+MqbBClrRP9yda3dIekM4I50/KnEwo1wkBLf7kHH5Cw==} peerDependencies: @@ -9503,6 +9916,10 @@ packages: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: true + /webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + dev: true + /webpack-sources@3.2.3: resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} engines: {node: '>=10.13.0'} @@ -9528,6 +9945,14 @@ packages: webidl-conversions: 3.0.1 dev: true + /whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + dev: true + /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} dependencies: @@ -9608,6 +10033,152 @@ packages: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} dev: true + /workbox-background-sync@7.0.0: + resolution: {integrity: sha512-S+m1+84gjdueM+jIKZ+I0Lx0BDHkk5Nu6a3kTVxP4fdj3gKouRNmhO8H290ybnJTOPfBDtTMXSQA/QLTvr7PeA==} + dependencies: + idb: 7.1.1 + workbox-core: 7.0.0 + dev: true + + /workbox-broadcast-update@7.0.0: + resolution: {integrity: sha512-oUuh4jzZrLySOo0tC0WoKiSg90bVAcnE98uW7F8GFiSOXnhogfNDGZelPJa+6KpGBO5+Qelv04Hqx2UD+BJqNQ==} + dependencies: + workbox-core: 7.0.0 + dev: true + + /workbox-build@7.0.0: + resolution: {integrity: sha512-CttE7WCYW9sZC+nUYhQg3WzzGPr4IHmrPnjKiu3AMXsiNQKx+l4hHl63WTrnicLmKEKHScWDH8xsGBdrYgtBzg==} + engines: {node: '>=16.0.0'} + dependencies: + '@apideck/better-ajv-errors': 0.3.6(ajv@8.12.0) + '@babel/core': 7.21.8 + '@babel/preset-env': 7.21.5(@babel/core@7.21.8) + '@babel/runtime': 7.21.5 + '@rollup/plugin-babel': 5.3.1(@babel/core@7.21.8)(rollup@2.79.1) + '@rollup/plugin-node-resolve': 11.2.1(rollup@2.79.1) + '@rollup/plugin-replace': 2.4.2(rollup@2.79.1) + '@surma/rollup-plugin-off-main-thread': 2.2.3 + ajv: 8.12.0 + common-tags: 1.8.2 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 7.2.3 + lodash: 4.17.21 + pretty-bytes: 5.6.0 + rollup: 2.79.1 + rollup-plugin-terser: 7.0.2(rollup@2.79.1) + source-map: 0.8.0-beta.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.0.0 + workbox-broadcast-update: 7.0.0 + workbox-cacheable-response: 7.0.0 + workbox-core: 7.0.0 + workbox-expiration: 7.0.0 + workbox-google-analytics: 7.0.0 + workbox-navigation-preload: 7.0.0 + workbox-precaching: 7.0.0 + workbox-range-requests: 7.0.0 + workbox-recipes: 7.0.0 + workbox-routing: 7.0.0 + workbox-strategies: 7.0.0 + workbox-streams: 7.0.0 + workbox-sw: 7.0.0 + workbox-window: 7.0.0 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + dev: true + + /workbox-cacheable-response@7.0.0: + resolution: {integrity: sha512-0lrtyGHn/LH8kKAJVOQfSu3/80WDc9Ma8ng0p2i/5HuUndGttH+mGMSvOskjOdFImLs2XZIimErp7tSOPmu/6g==} + dependencies: + workbox-core: 7.0.0 + dev: true + + /workbox-core@7.0.0: + resolution: {integrity: sha512-81JkAAZtfVP8darBpfRTovHg8DGAVrKFgHpOArZbdFd78VqHr5Iw65f2guwjE2NlCFbPFDoez3D3/6ZvhI/rwQ==} + dev: true + + /workbox-expiration@7.0.0: + resolution: {integrity: sha512-MLK+fogW+pC3IWU9SFE+FRStvDVutwJMR5if1g7oBJx3qwmO69BNoJQVaMXq41R0gg3MzxVfwOGKx3i9P6sOLQ==} + dependencies: + idb: 7.1.1 + workbox-core: 7.0.0 + dev: true + + /workbox-google-analytics@7.0.0: + resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + dependencies: + workbox-background-sync: 7.0.0 + workbox-core: 7.0.0 + workbox-routing: 7.0.0 + workbox-strategies: 7.0.0 + dev: true + + /workbox-navigation-preload@7.0.0: + resolution: {integrity: sha512-juWCSrxo/fiMz3RsvDspeSLGmbgC0U9tKqcUPZBCf35s64wlaLXyn2KdHHXVQrb2cqF7I0Hc9siQalainmnXJA==} + dependencies: + workbox-core: 7.0.0 + dev: true + + /workbox-precaching@7.0.0: + resolution: {integrity: sha512-EC0vol623LJqTJo1mkhD9DZmMP604vHqni3EohhQVwhJlTgyKyOkMrZNy5/QHfOby+39xqC01gv4LjOm4HSfnA==} + dependencies: + workbox-core: 7.0.0 + workbox-routing: 7.0.0 + workbox-strategies: 7.0.0 + dev: true + + /workbox-range-requests@7.0.0: + resolution: {integrity: sha512-SxAzoVl9j/zRU9OT5+IQs7pbJBOUOlriB8Gn9YMvi38BNZRbM+RvkujHMo8FOe9IWrqqwYgDFBfv6sk76I1yaQ==} + dependencies: + workbox-core: 7.0.0 + dev: true + + /workbox-recipes@7.0.0: + resolution: {integrity: sha512-DntcK9wuG3rYQOONWC0PejxYYIDHyWWZB/ueTbOUDQgefaeIj1kJ7pdP3LZV2lfrj8XXXBWt+JDRSw1lLLOnww==} + dependencies: + workbox-cacheable-response: 7.0.0 + workbox-core: 7.0.0 + workbox-expiration: 7.0.0 + workbox-precaching: 7.0.0 + workbox-routing: 7.0.0 + workbox-strategies: 7.0.0 + dev: true + + /workbox-routing@7.0.0: + resolution: {integrity: sha512-8YxLr3xvqidnbVeGyRGkaV4YdlKkn5qZ1LfEePW3dq+ydE73hUUJJuLmGEykW3fMX8x8mNdL0XrWgotcuZjIvA==} + dependencies: + workbox-core: 7.0.0 + dev: true + + /workbox-strategies@7.0.0: + resolution: {integrity: sha512-dg3qJU7tR/Gcd/XXOOo7x9QoCI9nk74JopaJaYAQ+ugLi57gPsXycVdBnYbayVj34m6Y8ppPwIuecrzkpBVwbA==} + dependencies: + workbox-core: 7.0.0 + dev: true + + /workbox-streams@7.0.0: + resolution: {integrity: sha512-moVsh+5to//l6IERWceYKGiftc+prNnqOp2sgALJJFbnNVpTXzKISlTIsrWY+ogMqt+x1oMazIdHj25kBSq/HQ==} + dependencies: + workbox-core: 7.0.0 + workbox-routing: 7.0.0 + dev: true + + /workbox-sw@7.0.0: + resolution: {integrity: sha512-SWfEouQfjRiZ7GNABzHUKUyj8pCoe+RwjfOIajcx6J5mtgKkN+t8UToHnpaJL5UVVOf5YhJh+OHhbVNIHe+LVA==} + dev: true + + /workbox-window@7.0.0: + resolution: {integrity: sha512-j7P/bsAWE/a7sxqTzXo3P2ALb1reTfZdvVp6OJ/uLr/C2kZAMvjeWGm8V4htQhor7DOvYg0sSbFN2+flT5U0qA==} + dependencies: + '@types/trusted-types': 2.0.3 + workbox-core: 7.0.0 + dev: true + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} diff --git a/webui/public/images/AutoBangumi-dark.svg b/webui/public/images/AutoBangumi-dark.svg new file mode 100644 index 00000000..a0cc6a19 --- /dev/null +++ b/webui/public/images/AutoBangumi-dark.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webui/public/AutoBangumi.svg b/webui/public/images/AutoBangumi.svg similarity index 100% rename from webui/public/AutoBangumi.svg rename to webui/public/images/AutoBangumi.svg diff --git a/webui/public/images/RSS.svg b/webui/public/images/RSS.svg new file mode 100644 index 00000000..fb0e4a39 --- /dev/null +++ b/webui/public/images/RSS.svg @@ -0,0 +1,3 @@ + + + diff --git a/webui/public/images/apple-touch-icon-180x180.png b/webui/public/images/apple-touch-icon-180x180.png new file mode 100644 index 00000000..9464d94f Binary files /dev/null and b/webui/public/images/apple-touch-icon-180x180.png differ diff --git a/webui/public/favicon-light.svg b/webui/public/images/logo-light.svg similarity index 100% rename from webui/public/favicon-light.svg rename to webui/public/images/logo-light.svg diff --git a/webui/public/favicon.svg b/webui/public/images/logo.svg similarity index 100% rename from webui/public/favicon.svg rename to webui/public/images/logo.svg diff --git a/webui/public/images/pwa-192.png b/webui/public/images/pwa-192.png new file mode 100644 index 00000000..1c97fdd0 Binary files /dev/null and b/webui/public/images/pwa-192.png differ diff --git a/webui/public/images/pwa-512.png b/webui/public/images/pwa-512.png new file mode 100644 index 00000000..3235fada Binary files /dev/null and b/webui/public/images/pwa-512.png differ diff --git a/webui/src/App.vue b/webui/src/App.vue index af1f79a4..373f747d 100644 --- a/webui/src/App.vue +++ b/webui/src/App.vue @@ -5,6 +5,7 @@ import { NMessageProvider, } from 'naive-ui'; + const theme: GlobalThemeOverrides = { Spin: { color: '#fff', diff --git a/webui/src/api/bangumi.ts b/webui/src/api/bangumi.ts index 2ae3268a..5f0d0c21 100644 --- a/webui/src/api/bangumi.ts +++ b/webui/src/api/bangumi.ts @@ -1,15 +1,22 @@ -import type { BangumiRule } from '#/bangumi'; +import type { BangumiAPI, BangumiRule } from '#/bangumi'; import type { ApiSuccess } from '#/api'; + export const apiBangumi = { /** * 获取所有 bangumi 数据 * @returns 所有 bangumi 数据 */ async getAll() { - const { data } = await axios.get('api/v1/bangumi/getAll'); - - return data; + const { data } = await axios.get('api/v1/bangumi/get/all'); + const result: BangumiRule[] = data.map((bangumi) => ( + { + ...bangumi, + filter: bangumi.filter.split(','), + rss_link: bangumi.rss_link.split(','), + } + )); + return result; }, /** @@ -18,22 +25,33 @@ export const apiBangumi = { * @returns 指定 bangumi 的规则 */ async getRule(bangumiId: number) { - const { data } = await axios.get( - `api/v1/bangumi/getRule/${bangumiId}` + const { data } = await axios.get( + `api/v1/bangumi/get/${bangumiId}` ); - - return data; + const result: BangumiRule = { + ...data, + filter: data.filter.split(','), + rss_link: data.rss_link.split(','), + } + return result; }, /** * 更新指定 bangumiId 的规则 - * @param bangumiData - 需要更新的规则 + * @param bangumiId - 需要更新的 bangumi 的 id + * @param bangumiRule * @returns axios 请求返回的数据 */ - async updateRule(bangumiRule: BangumiRule) { - const { data } = await axios.post( - 'api/v1/bangumi/updateRule', - bangumiRule + async updateRule(bangumiId: number, bangumiRule: BangumiRule) { + const rule: BangumiAPI = { + ...bangumiRule, + filter: bangumiRule.filter.join(','), + rss_link: bangumiRule.rss_link.join(','), + } + const post = omit(rule, ['id']) + const { data } = await axios.patch< ApiSuccess >( + `api/v1/bangumi/update/${bangumiId}`, + post ); return data; }, @@ -44,15 +62,23 @@ export const apiBangumi = { * @param file - 是否同时删除关联文件。 * @returns axios 请求返回的数据 */ - async deleteRule(bangumiId: number, file: boolean) { - const { data } = await axios.delete( - `api/v1/bangumi/deleteRule/${bangumiId}`, - { - params: { - file, - }, - } - ); + async deleteRule(bangumiId: number | number[], file: boolean) { + let url = 'api/v1/bangumi/delete'; + let ids: undefined | number[]; + + if (typeof bangumiId === 'number') { + url = `${url}/${bangumiId}`; + } else { + url = `${url}/many`; + ids = bangumiId; + } + + const { data } = await axios.delete< ApiSuccess >(url, { + data: ids, + params: { + file, + }, + }); return data; }, @@ -62,15 +88,23 @@ export const apiBangumi = { * @param file - 是否同时删除关联文件。 * @returns axios 请求返回的数据 */ - async disableRule(bangumiId: number, file: boolean) { - const { data } = await axios.delete( - `api/v1/bangumi/disableRule/${bangumiId}`, - { - params: { - file, - }, - } - ); + async disableRule(bangumiId: number | number[], file: boolean) { + let url = 'api/v1/bangumi/disable'; + let ids: undefined | number[]; + + if (typeof bangumiId === 'number') { + url = `${url}/${bangumiId}`; + } else { + url = `${url}/many`; + ids = bangumiId; + } + + const { data } = await axios.delete< ApiSuccess >(url, { + data: ids, + params: { + file, + }, + }); return data; }, @@ -79,8 +113,8 @@ export const apiBangumi = { * @param bangumiId - 需要启用的 bangumi 的 id */ async enableRule(bangumiId: number) { - const { data } = await axios.get( - `api/v1/bangumi/enableRule/${bangumiId}` + const { data } = await axios.get< ApiSuccess >( + `api/v1/bangumi/enable/${bangumiId}` ); return data; }, @@ -89,9 +123,7 @@ export const apiBangumi = { * 重置所有 bangumi 数据 */ async resetAll() { - const { data } = await axios.get<{ - message: 'OK'; - }>('api/v1/bangumi/resetAll'); + const { data } = await axios.get< ApiSuccess >('api/v1/bangumi/reset/all'); return data; }, }; diff --git a/webui/src/api/check.ts b/webui/src/api/check.ts index 9d3289ef..a59a3a73 100644 --- a/webui/src/api/check.ts +++ b/webui/src/api/check.ts @@ -3,23 +3,8 @@ export const apiCheck = { * 检测下载器 */ async downloader() { - const { data } = await axios.get('api/v1/check/downloader'); + const { data } = await axios.get('api/v1/check/downloader'); return data; }, - /** - * 检测 RSS - */ - async rss() { - const { data } = await axios.get('api/v1/check/rss'); - return data; - }, - - /** - * 检测所有 - */ - async all() { - const { data } = await axios.get('api/v1/check'); - return data; - }, -}; +} \ No newline at end of file diff --git a/webui/src/api/config.ts b/webui/src/api/config.ts index 77e892f4..4fa5d6b1 100644 --- a/webui/src/api/config.ts +++ b/webui/src/api/config.ts @@ -1,11 +1,12 @@ import type { Config } from '#/config'; +import type { ApiSuccess } from '#/api'; export const apiConfig = { /** * 获取 config 数据 */ async getConfig() { - const { data } = await axios.get('api/v1/getConfig'); + const { data } = await axios.get('api/v1/config/get'); return data; }, @@ -14,10 +15,10 @@ export const apiConfig = { * @param newConfig - 需要更新的 config */ async updateConfig(newConfig: Config) { - const { data } = await axios.post<{ - message: 'Success' | 'Failed to update config'; - }>('api/v1/updateConfig', newConfig); - - return data.message === 'Success'; + const { data } = await axios.patch( + 'api/v1/config/update', + newConfig + ); + return data; }, }; diff --git a/webui/src/api/download.ts b/webui/src/api/download.ts index 51b7028b..29594559 100644 --- a/webui/src/api/download.ts +++ b/webui/src/api/download.ts @@ -1,38 +1,24 @@ -import type { BangumiRule } from '#/bangumi'; - -interface Status { - status: 'Success'; -} - -interface AnalysisError { - status: 'Failed to parse link'; -} +import type { BangumiAPI, BangumiRule } from '#/bangumi'; +import type { RSS } from '#/rss'; +import type { ApiError, ApiSuccess } from '#/api'; export const apiDownload = { /** * 解析 RSS 链接 - * @param rss_link - RSS 链接 + * @param rss_item - RSS 链接 */ - async analysis(rss_link: string) { - const fetchResult = createEventHook(); - const fetchError = createEventHook(); + async analysis(rss_item: RSS) { + const { data } = await axios.post( + 'api/v1/rss/analysis', + rss_item + ); - axios - .post('api/v1/download/analysis', { - rss_link, - }) - .then(({ data }) => { - if (data.status) { - fetchError.trigger(data as AnalysisError); - } else { - fetchResult.trigger(data as BangumiRule); - } - }); - - return { - onResult: fetchResult.on, - onError: fetchError.on, - }; + const result: BangumiRule = { + ...data, + filter: data.filter.split(','), + rss_link: data.rss_link.split(','), + } + return result; }, /** @@ -40,11 +26,16 @@ export const apiDownload = { * @param bangumiData - Bangumi 数据 */ async collection(bangumiData: BangumiRule) { - const { data } = await axios.post( - 'api/v1/download/collection', - bangumiData + const postData: BangumiAPI = { + ...bangumiData, + filter: bangumiData.filter.join(','), + rss_link: bangumiData.rss_link.join(','), + } + const { data } = await axios.post( + 'api/v1/rss/collect', + postData ); - return data.status === 'Success'; + return data; }, /** @@ -52,10 +43,15 @@ export const apiDownload = { * @param bangumiData - Bangumi 数据 */ async subscribe(bangumiData: BangumiRule) { - const { data } = await axios.post( - 'api/v1/download/subscribe', - bangumiData + const postData: BangumiAPI = { + ...bangumiData, + filter: bangumiData.filter.join(','), + rss_link: bangumiData.rss_link.join(','), + } + const { data } = await axios.post( + 'api/v1/rss/subscribe', + postData ); - return data.status === 'Success'; + return data; }, }; diff --git a/webui/src/api/log.ts b/webui/src/api/log.ts index 92e3e33e..b7492153 100644 --- a/webui/src/api/log.ts +++ b/webui/src/api/log.ts @@ -1,3 +1,5 @@ +import type { ApiSuccess } from "#/api"; + export const apiLog = { async getLog() { const { data } = await axios.get('api/v1/log'); @@ -5,7 +7,7 @@ export const apiLog = { }, async clearLog() { - const { data } = await axios.get<{ status: 'ok' }>('api/v1/log/clear'); - return data.status === 'ok'; + const { data } = await axios.get('api/v1/log/clear'); + return data; }, }; diff --git a/webui/src/api/program.ts b/webui/src/api/program.ts index 9bd7542b..d8ce5f0b 100644 --- a/webui/src/api/program.ts +++ b/webui/src/api/program.ts @@ -1,47 +1,47 @@ -interface Success { - status: 'ok'; -} +import type { ApiSuccess } from "#/api"; + export const apiProgram = { /** * 重启 */ async restart() { - const { data } = await axios.get('api/v1/restart'); - return data.status === 'ok'; + const { data } = await axios.get('api/v1/restart'); + return data; }, /** * 启动 */ async start() { - const { data } = await axios.get('api/v1/start'); - return data.status === 'ok'; + const { data } = await axios.get('api/v1/start'); + return data; }, /** * 停止 */ async stop() { - const { data } = await axios.get('api/v1/stop'); - return data.status === 'ok'; + const { data } = await axios.get('api/v1/stop'); + return data; }, /** * 状态 */ async status() { - const { data } = await axios.get<{ status: 'running' | 'stop' }>( + const { data } = await axios.get<{ status: boolean; version: string }>( 'api/v1/status' ); - return data.status === 'running'; + + return data!; }, /** * 终止 */ async shutdown() { - const { data } = await axios.get('api/v1/shutdown'); - return data.status === 'ok'; + const { data } = await axios.get('api/v1/shutdown'); + return data; }, }; diff --git a/webui/src/api/rss.ts b/webui/src/api/rss.ts new file mode 100644 index 00000000..c92b0889 --- /dev/null +++ b/webui/src/api/rss.ts @@ -0,0 +1,60 @@ +import type { RSS } from '#/rss'; +import type { Torrent } from '#/torrent'; +import type { ApiSuccess } from '#/api'; + +export const apiRSS = { + async get() { + const { data } = await axios.get('api/v1/rss'); + return data!; + }, + + async add(rss: RSS) { + const { data } = await axios.post('api/v1/rss/add', rss); + return data; + }, + + async delete(rss_id: number) { + const { data } = await axios.delete(`api/v1/rss/delete/${rss_id}`); + return data!; + }, + + async deleteMany(rss_list: number[]) { + const { data } = await axios.post(`api/v1/rss/delete/many`, rss_list); + return data!; + }, + + async disable(rss_id: number) { + const { data } = await axios.patch(`api/v1/rss/disable/${rss_id}`); + return data!; + }, + + async disableMany(rss_list: number[]) { + const { data } = await axios.post(`api/v1/rss/disable/many`, rss_list); + return data!; + }, + + async update(rss_id: number, rss: RSS) { + const { data } = await axios.patch(`api/v1/rss/update/${rss_id}`, rss); + return data!; + }, + + async enableMany(rss_list: number[]) { + const { data } = await axios.post(`api/v1/rss/enable/many`, rss_list); + return data!; + }, + + async refreshAll() { + const { data } = await axios.get('api/v1/rss/refresh/all'); + return data!; + }, + + async refresh(rss_id: number) { + const { data } = await axios.get(`api/v1/rss/refresh/${rss_id}`); + return data!; + }, + + async getTorrent(rss_id: number) { + const { data } = await axios.get(`api/v1/rss/torrent/${rss_id}`); + return data!; + }, +}; diff --git a/webui/src/api/search.ts b/webui/src/api/search.ts new file mode 100644 index 00000000..61f26591 --- /dev/null +++ b/webui/src/api/search.ts @@ -0,0 +1,52 @@ +import { + Observable, +} from 'rxjs'; + +import type { BangumiRule, BangumiAPI } from '#/bangumi'; + +export const apiSearch = { + /** + * 番剧搜索接口是 Server Send 流式数据,每条是一个 Bangumi JSON 字符串, + * 使用接口方式是监听连接消息后,转为 Observable 配合外层调用时 switchMap 订阅使用 + */ + get(keyword: string, site = 'mikan'): Observable { + const bangumiInfo$ = new Observable(observer => { + const eventSource = new EventSource( + `api/v1/search/bangumi?site=${site}&keywords=${encodeURIComponent(keyword)}`, + { withCredentials: true }, + ); + + eventSource.onmessage = ev => { + try { + const apiData: BangumiAPI = JSON.parse(ev.data); + const data: BangumiRule = { + ...apiData, + filter: apiData.filter.split(','), + rss_link: apiData.rss_link.split(','), + } + observer.next(data); + } catch (error) { + console.error('[/search/bangumi] Parse Error |', { keyword }, 'response:', ev.data) + } + }; + + eventSource.onerror = ev => { + console.error('[/search/bangumi] Server Error |', { keyword }, 'error:', ev) + // 目前后端搜索完成关闭连接时会触发 error 事件,前端手动调用 close 不再自动重连 + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }); + + return bangumiInfo$; + }, + + async getProvider() { + const { data } = await axios.get('api/v1/search/provider'); + return data; + } +}; + diff --git a/webui/src/components/ab-add-bangumi.vue b/webui/src/components/ab-add-bangumi.vue deleted file mode 100644 index 77a73ebb..00000000 --- a/webui/src/components/ab-add-bangumi.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - diff --git a/webui/src/components/ab-add-rss.vue b/webui/src/components/ab-add-rss.vue new file mode 100644 index 00000000..67925e87 --- /dev/null +++ b/webui/src/components/ab-add-rss.vue @@ -0,0 +1,171 @@ + + + diff --git a/webui/src/components/ab-bangumi-card.vue b/webui/src/components/ab-bangumi-card.vue index d11f593f..fd5a0344 100644 --- a/webui/src/components/ab-bangumi-card.vue +++ b/webui/src/components/ab-bangumi-card.vue @@ -1,65 +1,134 @@