diff --git a/.dockerignore b/.dockerignore index a1784928..94a64c67 100644 --- a/.dockerignore +++ b/.dockerignore @@ -33,6 +33,8 @@ config/bangumi.json/config/bangumi.json dist.zip data config +/src/config +/src/data .pytest_cache test .env diff --git a/.github/workflows/dev-latest.yml b/.github/workflows/dev-latest.yml index 0981c7ff..eed5d029 100644 --- a/.github/workflows/dev-latest.yml +++ b/.github/workflows/dev-latest.yml @@ -24,6 +24,7 @@ jobs: - name: Test working-directory: ./src run: | + mkdir -p config pytest dev-latest: @@ -43,6 +44,17 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 + - name: Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: | + estrellaxd/auto_bangumi + ghcr.io/${{ github.repository }} + tags: | + type=raw,value=${{ github.ref_name }} + type=raw,value=dev-latest + - name: Login to Docker Hub uses: docker/login-action@v2 with: @@ -53,7 +65,7 @@ jobs: uses: docker/login-action@v2 with: registry: ghcr.io - username: ${{ secrets.GITHUB_USERNAME }} + username: ${{ github.actor }} password: ${{ secrets.ACCESS_TOKEN }} - name: Build and push @@ -61,11 +73,7 @@ jobs: with: context: . push: true - tags: | - estrellaxd/auto_bangumi:dev-latest - estrellaxd/auto_bangumi:${{ github.ref_name }} - ghcr.io/estrellaxd/auto_bangumi:dev-latest - ghcr.io/estrellaxd/auto_bangumi:${{ github.ref_name }} + tags: ${{ steps.meta.outputs.tags }} file: Dockerfile generate_release: @@ -73,7 +81,7 @@ jobs: needs: [test] steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Generate Release uses: softprops/action-gh-release@v1 with: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 839d01ce..4719f2e8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -23,6 +23,7 @@ jobs: - name: Test working-directory: ./src run: | + mkdir -p config pytest @@ -50,6 +51,7 @@ jobs: with: images: | estrellaxd/auto_bangumi + ghcr.io/${{ github.repository }} tags: | type=semver,pattern={{version}} type=raw,value=latest @@ -74,11 +76,9 @@ jobs: with: context: . builder: ${{ steps.buildx.output.name }} - platforms: linux/amd64,linux/arm64,linux/arm + platforms: linux/amd64,linux/arm64,linux/arm/v7 push: ${{ github.event_name == 'push' }} - tags: | - docker.io/estrellaxd/auto_bangumi:${{ steps.meta.outputs.tags }} - ghcr.io/estrellaxd/auto_bangumi:${{ steps.meta.outputs.tags }} + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha, scope=${{ github.workflow }} cache-to: type=gha, scope=${{ github.workflow }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 00000000..afc1b051 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,32 @@ +name: Create Pull Request + +on: + push: + tags: + - '\d+\.\d+\.\d+' + - '\d+\.\d+' + +jobs: + create-pull-request: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Generate pull request body + id: pr + run: | + echo "docs/changelog/${{ github.ref }}.md" + - name: Create Pull Request + uses: peter-evans/create-pull-request@v3 + with: + token: ${{ secrets.ACCESS_TOKEN }} + commit-message: 'chore: release ${{ github.ref }}' + title: 'chore: release ${{ github.ref }}' + body: | + ${{ steps.pr.outputs.body }} + branch: release/${{ github.ref }} + base: main + labels: release + draft: false + branch-suffix: timestamp + delete-branch: false \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e6a50f78..7f58121f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM python:3.11-alpine AS APP +FROM alpine:3.18 AS APP ENV S6_SERVICES_GRACETIME=30000 \ S6_KILL_GRACETIME=60000 \ @@ -18,12 +18,21 @@ WORKDIR /app COPY requirements.txt . RUN apk add --no-cache \ + bash \ + ca-certificates \ + coreutils \ curl \ jq \ - shadow \ + netcat-openbsd \ + procps-ng \ + python3 \ + py3-bcrypt \ + py3-pip \ s6-overlay \ - bash && \ + shadow \ + tzdata && \ python3 -m pip install --upgrade pip && \ + sed -i '/bcrypt/d' requirements.txt && \ pip install --no-cache-dir -r requirements.txt && \ # Download WebUI curl -sL "https://github.com/Rewrite0/Auto_Bangumi_WebUI/releases/latest/download/dist.zip" | busybox unzip -q -d /app - && \ diff --git a/README.md b/README.md index f566dc13..d3e9861c 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,32 @@

- - + +

-

- # 项目说明

- - +

本项目是基于 [Mikan Project](https://mikanani.me)、[qBittorrent](https://qbittorrent.org) 的全自动追番整理下载工具。只需要在 [Mikan Project](https://mikanani.me) 上订阅番剧,就可以全自动追番。并且整理完成的名称和目录可以直接被 [Plex]()、[Jellyfin]() 等媒体库软件识别,无需二次刮削。 -基于 [infuse](https://firecore.com/infuse) 与 [Plex](https://plex.tv) 的效果如下: - - - [主项目地址](https://www.github.com/EstrellaXD/Auto_Bangumi) -/ [WebUI 仓库](https://github.com/Rewrite0/Auto_Bangumi_WebUI) +/ [WebUI 仓库](https://github.com/Rewrite0/Auto_Bangumi_WebUI) / [Wiki 说明](https://www.github.com/EstrellaXD/Auto_Bangumi/wiki) - ## AutoBangumi 功能说明 - 简易单次配置就能持续使用 - 无需介入的 `RSS` 解析器,解析番组信息并且自动生成下载规则。 - 番剧文件整理: + ``` Bangumi ├── bangumi_A_title @@ -50,12 +43,15 @@ ├── bangumi_B_title │   └─── Season 1 ``` -- 全自动重命名,重命名后 95% 以上的番剧可以直接被媒体库软件直接刮削 + +- 全自动重命名,重命名后 99% 以上的番剧可以直接被媒体库软件直接刮削 + ``` [Lilith-Raws] Kakkou no Iinazuke - 07 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4 >> Kakkou no Iinazuke S01E07.mp4 ``` + - 自定义重命名,可以根据上级文件夹对所有子文件重命名。 - 季中追番可以补全当季遗漏的所有剧集 - 高度可自定义的功能选项,可以针对不同媒体库软件微调 @@ -77,6 +73,7 @@ ## Roadmap ***开发中的功能:*** + - Web UI #57 ✅ - 文件统一整理,对单个规则或者文件微调文件夹可以自动调整所有对应的文件。 - 通知功能,可以通过 IFTTT 等方式通知用户番剧更新进度。✅ @@ -84,14 +81,16 @@ - 各类 API 接口。 ***计划开发的功能:*** + - 对其他站点种子的解析归类。 - 本地化番剧订阅方式。 - Transmission & Aria2 的支持。 - 更完善的 WebUI。 - # 声明 + ## 致谢 + 感谢 [Sean](https://github.com/findix) 提供的大量帮助 感谢 [Rewrite0](https://github.com/Rewrite0) 开发的 WebUI @@ -100,6 +99,5 @@ [![Star History Chart](https://api.star-history.com/svg?repos=EstrellaXD/Auto_Bangumi&type=Date)](https://star-history.com/#EstrellaXD/Auto_Bangumi) ## Licence + [MIT licence](https://github.com/EstrellaXD/Auto_Bangumi/blob/main/LICENSE) - - diff --git a/dev.sh b/dev.sh new file mode 100644 index 00000000..323fc3c2 --- /dev/null +++ b/dev.sh @@ -0,0 +1,24 @@ +#!/bin/bash + + +# This script is used to run the development environment. + +python3 -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple install -r requirements.txt + +cd src || exit + +CONFIG_DIR="config" + +if [ ! -d "$CONFIG_DIR" ]; then + echo "The directory '$CONFIG_DIR' is missing." + mkdir config +fi + +VERSION_FILE="module/__version__.py" + +if [ ! -f "$VERSION_FILE" ]; then + echo "The file '$VERSION_FILE' is missing." + echo "VERSION='DEV_VERSION'" >> "$VERSION_FILE" +fi + +python3 main.py \ No newline at end of file diff --git a/docs/changelog/3.0.md b/docs/changelog/3.0.md new file mode 100644 index 00000000..f1c29d83 --- /dev/null +++ b/docs/changelog/3.0.md @@ -0,0 +1,46 @@ +## New + +### 全新的 WebUI + +- 登陆功能,现在 AB 可以设定用户名和密码进行登陆。部分操作需要在登陆后才可以进行。 +- 全新的海报墙 +- 番剧管理功能 + - 可以对番剧的季度信息名称进行修改,修改完成之后会自动修改**自动下载规则**/**已经下载完成的文件路径**, 并重命名。 + - 全新的链接解析器,解析完成链接之后可以手动更改下载信息,选择下载季度或者增加自动下载规则。 + - 删除番剧,可以一键删除番剧以及其种子文件。 + - 可以对单独番剧自定义下载规则,此规则与全局规则不冲突。 +- 全新的配置界面,可以更方便的配置应用程序规则。 +- 新增初始化启动页面,在第一次启动 AB 时会引导填入相关配置。 +- 下载器链接检查器,可以检查与 qBittorrent 的连通性 +- RSS 地址检查器,可以检查 RSS 是否有效。 +- 新增程序管理按钮,可以在 WebUI 中启动停止程序。且可以重启容器。 + +### 解析器 + +- 全新的解析器,可以选择不同类型来源获取官方标题和海报地址。 +- 支持更换 RSS 订阅源而不用重新生成数据库。 + +### 通知模块 + +- 新增 `Bark` 通知模块. +- 全新的通知方式,现在可以给 Telegram 推送海报,番剧名称,更新集数。 + +### 数据迁移 + +- 现在从旧版本升级可以自动迁移数据。 +- 从旧版本迁移的数据也可以自动匹配海报。 + +## Fix + +- 针对 Windows 路径可能出现的重命名 Bug。 + +## Change + +- 从 `json` 迁移至 `sqlite` 存储数据 +- 从多进程迁移至多线程 + - 重构主程序 + - 加速启动关闭时间 +- 重构解析器模块 +- 重构重命名模块 + - 暂时移除 `normal` 模式 +- 增加 `ghcr.io` 镜像 \ No newline at end of file diff --git a/docs/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml b/docs/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml index ee7509e7..992c5e1d 100644 --- a/docs/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml +++ b/docs/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml @@ -28,8 +28,6 @@ services: - PGID=${GID} - PUID=${UID} - AB_DOWNLOADER_HOST=qbittorrent:${QB_PORT} - - AB_RSS=${AB_RSS} - - AB_DOWNLOAD_PATH=/downloads/Bangumi volumes: - ./config:/app/config - ./data:/app/data diff --git a/docs/image/auto_bangumi_icon_v2-dark.png b/docs/image/auto_bangumi_icon_v2-dark.png deleted file mode 100644 index 6cb995bc..00000000 Binary files a/docs/image/auto_bangumi_icon_v2-dark.png and /dev/null differ diff --git a/docs/image/auto_bangumi_v2.png b/docs/image/auto_bangumi_v2.png deleted file mode 100644 index e9e774b6..00000000 Binary files a/docs/image/auto_bangumi_v2.png and /dev/null differ diff --git a/docs/image/dark-icon.png b/docs/image/dark-icon.png new file mode 100644 index 00000000..6186ffd0 Binary files /dev/null and b/docs/image/dark-icon.png differ diff --git a/docs/image/light-icon.png b/docs/image/light-icon.png new file mode 100644 index 00000000..a264ebf2 Binary files /dev/null and b/docs/image/light-icon.png differ diff --git a/docs/image/window.png b/docs/image/window.png new file mode 100644 index 00000000..f2b61272 Binary files /dev/null and b/docs/image/window.png differ diff --git a/docs/wiki b/docs/wiki index 9dc9f4f4..df8c30d2 160000 --- a/docs/wiki +++ b/docs/wiki @@ -1 +1 @@ -Subproject commit 9dc9f4f4e3bb2250fe2073c73f17653a0a6b7ce7 +Subproject commit df8c30d2c3bc1b532c85e85ae443b94f8155b458 diff --git a/requirements.txt b/requirements.txt index 1c2ff2be..2781feb3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ anyio -beautifulsoup4 +bs4 certifi charset-normalizer click @@ -19,3 +19,7 @@ uvicorn attrdict jinja2 python-dotenv +python-jose +passlib +bcrypt +python-multipart diff --git a/src/main.py b/src/main.py index bcb2b773..c0229948 100644 --- a/src/main.py +++ b/src/main.py @@ -1,102 +1,29 @@ -import os -import signal import logging import uvicorn -import multiprocessing -from fastapi import Request -from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates - -from module import app from module.api import router -from module.conf import VERSION, settings - +from module.conf import settings, setup_logger +setup_logger(reset=True) logger = logging.getLogger(__name__) - -main_process = multiprocessing.Process(target=app.run, args=(settings,)) - - -@router.on_event("startup") -async def startup(): - global main_process - main_process.start() - - -@router.on_event("shutdown") -async def shutdown(): - global main_process - if main_process.is_alive(): - os.kill(main_process.pid, signal.SIGTERM) - - -@router.get("/api/v1/restart", tags=["program"]) -async def restart(): - global main_process - if main_process.is_alive(): - os.kill(main_process.pid, signal.SIGTERM) - logger.info("Restarting...") - else: - logger.info("Starting...") - settings.reload() - main_process = multiprocessing.Process(target=app.run, args=(settings,)) - main_process.start() - logger.info("Restarted") - return {"status": "success"} - - -@router.get("/api/v1/stop", tags=["program"]) -async def stop(): - global main_process - if not main_process.is_alive(): - return {"status": "failed", "reason": "Already stopped"} - logger.info("Stopping...") - os.kill(main_process.pid, signal.SIGTERM) - logger.info("Stopped") - return {"status": "success"} - - -@router.get("/api/v1/start", tags=["program"]) -async def start(): - global main_process - if main_process.is_alive(): - return {"status": "failed", "reason": "Already started"} - logger.info("Starting...") - settings.reload() - main_process = multiprocessing.Process(target=app.run, args=(settings,)) - main_process.start() - logger.info("Started") - return {"status": "success"} - - -@router.get("/api/v1/status", tags=["program"]) -async def status(): - global main_process - if main_process.is_alive(): - return True - else: - return False - - -if VERSION != "DEV_VERSION": - router.mount("/assets", StaticFiles(directory="templates/assets"), name="assets") - templates = Jinja2Templates(directory="templates") - - # HTML Response - @router.get("/{full_path:path}", response_class=HTMLResponse, tags=["html"]) - def index(request: Request): - context = {"request": request} - return templates.TemplateResponse("index.html", context) -else: - @router.get("/", status_code=302, tags=["html"]) - def index(): - return RedirectResponse("/docs") - +uvicorn_logging_config = { + "version": 1, + "disable_existing_loggers": False, + "handlers": logger.handlers, + "loggers": { + "uvicorn": { + "level": logger.level, + }, + "uvicorn.access": { + "level": "WARNING", + }, + }, +} if __name__ == "__main__": - log_config = uvicorn.config.LOGGING_CONFIG - log_config["formatters"]["default"]["fmt"] = "[%(asctime)s] %(levelname)-8s %(message)s" - uvicorn.run(router, host="0.0.0.0", port=settings.program.webui_port, log_config=log_config) - + uvicorn.run( + router, + host="0.0.0.0", + port=settings.program.webui_port, + log_config=uvicorn_logging_config, + ) diff --git a/src/module/ab_decorator/__init__.py b/src/module/ab_decorator/__init__.py index 78a18e93..f469894c 100644 --- a/src/module/ab_decorator/__init__.py +++ b/src/module/ab_decorator/__init__.py @@ -16,6 +16,7 @@ def qb_connect_failed_wait(func): logger.warning("Cannot connect to qBittorrent. Wait 5 min and retry...") time.sleep(300) times += 1 + return wrapper @@ -27,4 +28,5 @@ def api_failed(func): logger.debug(f"URL: {args[0]}") logger.warning("Wrong API response.") logger.debug(e) + return wrapper diff --git a/src/module/api.py b/src/module/api.py deleted file mode 100644 index 9d80015c..00000000 --- a/src/module/api.py +++ /dev/null @@ -1,114 +0,0 @@ -import logging -import os - -from fastapi import FastAPI -from fastapi.responses import FileResponse, Response - -from module.core import APIProcess -from module.conf import DATA_PATH, LOG_PATH, settings -from module.utils import json_config -from module.models.api import * -from module.models import Config - - -router = FastAPI() -api_func = APIProcess(settings) - - -@router.get("/api/v1/data", tags=["info"]) -async def get_data(): - try: - data = json_config.load(DATA_PATH) - return data - except FileNotFoundError: - return { - "rss_link": "", - "data_version": settings.data_version, - "bangumi_info": [], - } - - -@router.get("/api/v1/log", tags=["info"]) -async def get_log(): - if os.path.isfile(LOG_PATH): - return FileResponse(LOG_PATH) - else: - return Response("Log file not found", status_code=404) - - -@router.get("/api/v1/resetRule") -def reset_rule(): - return api_func.reset_rule() - - -@router.get("api/v1/removeRule/{bangumi_id}") -def remove_rule(bangumi_id: str): - bangumi_id = int(bangumi_id) - return api_func.remove_rule(bangumi_id) - - -@router.post("/api/v1/collection", tags=["download"]) -async def collection(link: RssLink): - response = api_func.download_collection(link.rss_link) - if response: - return response.dict() - else: - return {"status": "Failed to parse link"} - - -@router.post("/api/v1/subscribe", tags=["download"]) -async def subscribe(link: RssLink): - response = api_func.add_subscribe(link.rss_link) - if response: - return response.dict() - else: - return {"status": "Failed to parse link"} - - -@router.post("/api/v1/addRule", tags=["download"]) -async def add_rule(info: AddRule): - return api_func.add_rule(info.title, info.season) - - -@router.get("/api/v1/getConfig", tags=["config"]) -async def get_config(): - return api_func.get_config() - - -@router.post("/api/v1/updateConfig", tags=["config"]) -async def update_config(config: Config): - return api_func.update_config(config) - - -@router.get("/RSS/MyBangumi", tags=["proxy"]) -async def get_my_bangumi(token: str): - full_path = "MyBangumi?token=" + token - content = api_func.get_rss(full_path) - return Response(content, media_type="application/xml") - - -@router.get("/RSS/Search", tags=["proxy"]) -async def get_search_result(searchstr: str): - full_path = "Search?searchstr=" + searchstr - content = api_func.get_rss(full_path) - return Response(content, media_type="application/xml") - - -@router.get("/RSS/Bangumi", tags=["proxy"]) -async def get_bangumi(bangumiId: str, groupid: str): - full_path = "Bangumi?bangumiId=" + bangumiId + "&groupid=" + groupid - content = api_func.get_rss(full_path) - return Response(content, media_type="application/xml") - - -@router.get("/RSS/{full_path:path}", tags=["proxy"]) -async def get_rss(full_path: str): - content = api_func.get_rss(full_path) - return Response(content, media_type="application/xml") - - -@router.get("/Download/{full_path:path}", tags=["proxy"]) -async def download(full_path: str): - torrent = api_func.get_torrent(full_path) - return Response(torrent, media_type="application/x-bittorrent") - diff --git a/src/module/api/__init__.py b/src/module/api/__init__.py new file mode 100644 index 00000000..e90516af --- /dev/null +++ b/src/module/api/__init__.py @@ -0,0 +1 @@ +from .web import router diff --git a/src/module/api/auth.py b/src/module/api/auth.py new file mode 100644 index 00000000..bbea2090 --- /dev/null +++ b/src/module/api/auth.py @@ -0,0 +1,63 @@ +from datetime import timedelta + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm + +from module.security import ( + create_access_token, + get_current_user, + update_user_info, + auth_user, +) +from module.models.user import User + +from .program import router + + +@router.post("/api/v1/auth/login", response_model=dict, tags=["auth"]) +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("/api/v1/auth/refresh_token", response_model=dict, tags=["auth"]) +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" + ) + token = create_access_token( + data = {"sub": current_user.username} + + ) + return {"access_token": token, "token_type": "bearer", "expire": 86400} + + +@router.get("/api/v1/auth/logout", response_model=dict, tags=["auth"]) +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" + ) + return {"message": "logout success"} + + +@router.post("/api/v1/auth/update", response_model=dict, tags=["auth"]) +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, + } diff --git a/src/module/api/bangumi.py b/src/module/api/bangumi.py new file mode 100644 index 00000000..e2c700e1 --- /dev/null +++ b/src/module/api/bangumi.py @@ -0,0 +1,85 @@ +from fastapi import Depends, HTTPException, status + +from .log import router + +from module.models import BangumiData +from module.database import BangumiDatabase +from module.manager import TorrentManager +from module.security import get_current_user + + +@router.get( + "/api/v1/bangumi/getAll", tags=["bangumi"], 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() + + +@router.get( + "/api/v1/bangumi/getData/{bangumi_id}", tags=["bangumi"], 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.post("/api/v1/bangumi/updateRule", tags=["bangumi"]) +async def update_data(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.delete("/api/v1/bangumi/deleteRule/{bangumi_id}", tags=["bangumi"]) +async def delete_data(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.delete_rule(bangumi_id, file) + + +@router.delete("/api/v1/bangumi/disableRule/{bangumi_id}", tags=["bangumi"]) +async def delete_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("/api/v1/bangumi/enableRule/{bangumi_id}", tags=["bangumi"]) +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("/api/v1/bangumi/resetAll", tags=["bangumi"]) +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 BangumiDatabase() as database: + database.delete_all() + return {"status": "ok"} diff --git a/src/module/api/config.py b/src/module/api/config.py new file mode 100644 index 00000000..b034d9d0 --- /dev/null +++ b/src/module/api/config.py @@ -0,0 +1,36 @@ +import logging + +from fastapi import Depends, HTTPException, status + +from .bangumi import router + +from module.conf import settings +from module.models import Config +from module.security import get_current_user + +logger = logging.getLogger(__name__) + + +@router.get("/api/v1/getConfig", tags=["config"], 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 + + +@router.post("/api/v1/updateConfig", tags=["config"]) +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" + ) + try: + settings.save(config_dict=config.dict()) + settings.load() + logger.info("Config updated") + return {"message": "Success"} + except Exception as e: + logger.warning(e) + return {"message": "Failed to update config"} diff --git a/src/module/api/download.py b/src/module/api/download.py new file mode 100644 index 00000000..ed3bc9df --- /dev/null +++ b/src/module/api/download.py @@ -0,0 +1,52 @@ +from fastapi import Depends, HTTPException, status + +from .config import router + +from module.models.api import * +from module.models import BangumiData +from module.manager import SeasonCollector +from module.rss import analyser +from module.security import get_current_user + + +@router.post("/api/v1/download/analysis", tags=["download"]) +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("/api/v1/download/collection", tags=["download"]) +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: + collector.collect_season(data, data.rss_link[0]) + return {"status": "Success"} + else: + return {"status": "Failed to parse link"} + + +@router.post("/api/v1/download/subscribe", tags=["download"]) +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/src/module/api/log.py b/src/module/api/log.py new file mode 100644 index 00000000..24c04432 --- /dev/null +++ b/src/module/api/log.py @@ -0,0 +1,34 @@ +import os +from fastapi import Response, HTTPException, Depends, status + +from .auth import router + +from module.conf import LOG_PATH +from module.security import get_current_user + + +@router.get("/api/v1/log", tags=["log"]) +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" + ) + if os.path.isfile(LOG_PATH): + with open(LOG_PATH, "r") as f: + return Response(f.read(), media_type="text/plain") + else: + return Response("Log file not found", status_code=404) + + +@router.get("/api/v1/log/clear", tags=["log"]) +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" + ) + if os.path.isfile(LOG_PATH): + with open(LOG_PATH, "w") as f: + f.write("") + return {"status": "ok"} + else: + return Response("Log file not found", status_code=404) diff --git a/src/module/api/program.py b/src/module/api/program.py new file mode 100644 index 00000000..d8cf46c4 --- /dev/null +++ b/src/module/api/program.py @@ -0,0 +1,104 @@ +import signal +import logging +import os + +from fastapi import HTTPException, status, Depends +from fastapi import FastAPI + +from module.core import Program +from module.security import get_current_user + +logger = logging.getLogger(__name__) +program = Program() +router = FastAPI() + + +@router.on_event("startup") +async def startup(): + program.startup() + + +@router.on_event("shutdown") +async def shutdown(): + program.stop() + + +@router.get("/api/v1/restart", tags=["program"]) +async def restart(current_user=Depends(get_current_user)): + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + ) + try: + program.restart() + return {"status": "ok"} + 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("/api/v1/start", tags=["program"]) +async def start(current_user=Depends(get_current_user)): + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + ) + try: + return program.start() + 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("/api/v1/stop", tags=["program"]) +async def stop(current_user=Depends(get_current_user)): + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + ) + return program.stop() + + +@router.get("/api/v1/status", tags=["program"]) +async def status(current_user=Depends(get_current_user)): + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + ) + if not program.is_running: + return {"status": "stop"} + else: + return {"status": "running"} + + +@router.get("/api/v1/shutdown", tags=["program"]) +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" + ) + program.stop() + logger.info("Shutting down program...") + os.kill(os.getpid(), signal.SIGINT) + return {"status": "ok"} + + +# Check status +@router.get("/api/v1/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" + ) + return program.check_downloader() + + +@router.get("/api/v1/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/src/module/api/proxy.py b/src/module/api/proxy.py new file mode 100644 index 00000000..d06052fc --- /dev/null +++ b/src/module/api/proxy.py @@ -0,0 +1,84 @@ +import re +import logging + +from fastapi.responses import Response +from fastapi.exceptions import HTTPException + +from .download import router + +from module.conf import settings +from module.network import RequestContent + + +logger = logging.getLogger(__name__) + + +def get_rss_content(full_path): + url = f"https://mikanani.me/RSS/{full_path}" + custom_url = settings.rss_parser.custom_url + if "://" not in custom_url: + custom_url = f"https://{custom_url}" + try: + with RequestContent() as request: + content = request.get_html(url) + return re.sub(r"https://mikanani.me", custom_url, content) + except Exception as e: + logger.debug(e) + logger.warning("Failed to get RSS content") + raise HTTPException(status_code=500, detail="Failed to get RSS content") + + +def get_torrent(full_path): + url = f"https://mikanani.me/Download/{full_path}" + try: + with RequestContent() as request: + return request.get_content(url) + except Exception as e: + logger.debug(e) + logger.warning("Failed to get torrent") + raise HTTPException(status_code=500, detail="Failed to get torrent") + + +@router.get("/RSS/MyBangumi", tags=["proxy"]) +async def get_my_bangumi(token: str): + full_path = "MyBangumi?token=" + token + content = get_rss_content(full_path) + return Response(content, media_type="application/xml") + + +@router.get("/RSS/Search", tags=["proxy"]) +async def get_search_result(searchstr: str): + full_path = "Search?searchstr=" + searchstr + content = get_rss_content(full_path) + return Response(content, media_type="application/xml") + + +@router.get("/RSS/Bangumi", tags=["proxy"]) +async def get_bangumi(bangumiId: str, groupid: str): + full_path = "Bangumi?bangumiId=" + bangumiId + "&groupid=" + groupid + content = get_rss_content(full_path) + return Response(content, media_type="application/xml") + + +@router.get("/RSS/{full_path:path}", tags=["proxy"]) +async def get_rss(full_path: str): + content = get_rss_content(full_path) + return Response(content, media_type="application/xml") + + +@router.get("/Download/{full_path:path}", tags=["proxy"]) +async def download(full_path: str): + torrent = get_torrent(full_path) + return Response(torrent, media_type="application/x-bittorrent") + + +@router.get("/Home/Episode/{full_path:path}", tags=["proxy"]) +async def get_ep_info(full_path: str): + url = f"https://mikanani.me/Home/Episode/{full_path}" + try: + with RequestContent() as request: + return Response(request.get_html(url), media_type="text/html") + except Exception as e: + logger.debug(e) + logger.warning("Failed to get ep info") + raise HTTPException(status_code=500, detail="Failed to get ep info") diff --git a/src/module/api/web.py b/src/module/api/web.py new file mode 100644 index 00000000..37e6afcf --- /dev/null +++ b/src/module/api/web.py @@ -0,0 +1,38 @@ +from fastapi import Request +from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from .proxy import router + +from module.conf import VERSION + + +if VERSION != "DEV_VERSION": + router.mount("/assets", StaticFiles(directory="templates/assets"), name="assets") + templates = Jinja2Templates(directory="templates") + + # Resource + @router.get("/favicon.svg", tags=["html"]) + def favicon(): + return FileResponse("templates/favicon.svg") + + @router.get("/AutoBangumi.svg", tags=["html"]) + def logo(): + return FileResponse("templates/AutoBangumi.svg") + + @router.get("/favicon-light.svg", tags=["html"]) + def favicon_light(): + return FileResponse("templates/favicon-light.svg") + + # HTML Response + @router.get("/{full_path:path}", response_class=HTMLResponse, tags=["html"]) + def index(request: Request): + context = {"request": request} + return templates.TemplateResponse("index.html", context) + +else: + + @router.get("/", status_code=302, tags=["html"]) + def index(): + return RedirectResponse("/docs") diff --git a/src/module/app.py b/src/module/app.py deleted file mode 100644 index 34540dd9..00000000 --- a/src/module/app.py +++ /dev/null @@ -1,82 +0,0 @@ -import os -import time -import logging - -from module.conf import setup_logger, LOG_PATH, DATA_PATH, RSSLink, VERSION -from module.utils import load_program_data, save_program_data - -from module.core import DownloadClient -from module.manager import Renamer, FullSeasonGet -from module.rss import RSSAnalyser -from module.models import ProgramData, Config - - -logger = logging.getLogger(__name__) - - -def reset_log(): - if os.path.exists(LOG_PATH): - os.remove(LOG_PATH) - - -def load_data_file(rss_link: str, data_version) -> ProgramData: - empty_data = ProgramData( - rss_link=rss_link, - data_version=data_version, - ) - if not os.path.exists(DATA_PATH): - program_data = empty_data - save_program_data(DATA_PATH, program_data) - logger.info("Building data information...") - else: - program_data = load_program_data(DATA_PATH) - if program_data.rss_link != rss_link or program_data.data_version != data_version: - program_data = empty_data - logger.info("Rebuilding data information...") - return program_data - - -def main_process(program_data: ProgramData, download_client: DownloadClient, _settings: Config): - rename = Renamer(download_client, _settings) - rss_analyser = RSSAnalyser(_settings) - while True: - times = 0 - if _settings.rss_parser.enable: - rss_analyser.run(program_data.bangumi_info, program_data.rss_link) - download_client.add_rules(program_data.bangumi_info, program_data.rss_link) - if _settings.bangumi_manage.eps_complete and program_data.bangumi_info != []: - FullSeasonGet(settings=_settings).eps_complete(program_data.bangumi_info, download_client) - logger.info("Running....") - save_program_data(DATA_PATH, program_data) - while times < _settings.program.rename_times: - if _settings.bangumi_manage.enable: - rename.rename() - times += 1 - time.sleep(_settings.program.sleep_time / _settings.program.rename_times) - - -def show_info(): - with open("icon", "r") as f: - for line in f.readlines(): - logger.info(line.strip("\n")) - logger.info(f"Version {VERSION} Author: EstrellaXD Twitter: https://twitter.com/Estrella_Pan") - logger.info("GitHub: https://github.com/EstrellaXD/Auto_Bangumi/") - logger.info("Starting AutoBangumi...") - - -def run(settings: Config): - # 初始化 - rss_link = RSSLink() - reset_log() - setup_logger() - show_info() - if settings.rss_parser.token in ["", "token", None]: - logger.error("Please set your RSS token in config file.") - exit(1) - download_client = DownloadClient(settings) - download_client.auth() - download_client.init_downloader() - download_client.rss_feed(rss_link) - bangumi_data = load_data_file(rss_link, settings.data_version) - # 主程序循环 - main_process(bangumi_data, download_client, settings) diff --git a/src/module/checker/__init__.py b/src/module/checker/__init__.py new file mode 100644 index 00000000..243f0286 --- /dev/null +++ b/src/module/checker/__init__.py @@ -0,0 +1 @@ +from .checker import Checker diff --git a/src/module/checker/checker.py b/src/module/checker/checker.py new file mode 100644 index 00000000..aba46030 --- /dev/null +++ b/src/module/checker/checker.py @@ -0,0 +1,51 @@ +import os.path + +from module.downloader import DownloadClient +from module.network import RequestContent +from module.conf import settings, DATA_PATH + + +class Checker: + def __init__(self): + pass + + def check_renamer(self) -> bool: + if self.check_downloader() and settings.bangumi_manage.enable: + return True + else: + return False + + def check_analyser(self) -> bool: + if self.check_downloader() and settings.rss_parser.enable: + return True + 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: + token_exist = False if settings.rss_parser.token in ["", "token"] else True + if token_exist: + return False + else: + return True diff --git a/src/module/conf/__init__.py b/src/module/conf/__init__.py index 813d23eb..04b72fd9 100644 --- a/src/module/conf/__init__.py +++ b/src/module/conf/__init__.py @@ -1,17 +1,8 @@ from .log import setup_logger, LOG_PATH -from .config import settings, VERSION +from .config import VERSION, settings TMDB_API = "32b19d6a05b512190a056fa4e747cbbc" -DATA_PATH = "data/data.json" - - -class RSSLink(str): - def __new__(cls): - if "://" not in settings.rss_parser.custom_url: - return f"https://{settings.rss_parser.custom_url}/RSS/MyBangumi?token={settings.rss_parser.token}" - return f"{settings.rss_parser.custom_url}/RSS/MyBangumi?token={settings.rss_parser.token}" - +DATA_PATH = "data/data.db" PLATFORM = "Windows" if "\\" in settings.downloader.path else "Unix" -MIKANANI_URL = "mikanani.me" diff --git a/src/module/conf/config.py b/src/module/conf/config.py index 7fe0e841..24a3d39a 100644 --- a/src/module/conf/config.py +++ b/src/module/conf/config.py @@ -3,80 +3,85 @@ import os import logging from dotenv import load_dotenv -from module.conf.const import ENV_TO_ATTR +from .const import ENV_TO_ATTR from module.models.config import Config logger = logging.getLogger(__name__) try: - from ..__version__ import VERSION + from module.__version__ import VERSION + + if VERSION == "DEV_VERSION": + logger.info("Can't find version info, use DEV_VERSION instead") + CONFIG_PATH = "config/config_dev.json" + else: + CONFIG_PATH = f"config/config.json" except ImportError: logger.info("Can't find version info, use DEV_VERSION instead") VERSION = "DEV_VERSION" - - -class Setting(Config): - @staticmethod - def reload(): - load_config_from_file(CONFIG_PATH) - - def save(self): - save_config_to_file(self, CONFIG_PATH) - - -def save_config_to_file(config: Config, path: str): - config_dict = config.dict() - with open(path, "w", encoding="utf-8") as f: - json.dump(config_dict, f, indent=4) - logger.info(f"Config saved") - - -def load_config_from_file(path: str) -> Config: - with open(path, "r", encoding="utf-8") as f: - config = json.load(f) - return Setting(**config) - - -def _val_from_env(env: str, attr: tuple): - if isinstance(attr, tuple): - conv_func = attr[1] - return conv_func(os.environ[env]) - else: - return os.environ[env] - - -def env_to_config() -> Setting: - _settings = Setting().dict() - for key, section in ENV_TO_ATTR.items(): - for env, attr in section.items(): - if env in os.environ: - if isinstance(attr, list): - for _attr in attr: - attr_name = _attr[0] if isinstance(_attr, tuple) else _attr - _settings[key][attr_name] = _val_from_env(env, _attr) - else: - attr_name = attr[0] if isinstance(attr, tuple) else attr - _settings[key][attr_name] = _val_from_env(env, attr) - return Setting(**_settings) - - -if os.path.isdir("config") and VERSION == "DEV_VERSION": CONFIG_PATH = "config/config_dev.json" - if os.path.isfile(CONFIG_PATH): - settings = load_config_from_file(CONFIG_PATH) - else: + + +class Settings(Config): + def __init__(self): + super().__init__() + if os.path.exists(CONFIG_PATH): + self.load() + self.save() + else: + self.init() + + def load(self): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + config = json.load(f) + config_obj = Config.parse_obj(config) + self.__dict__.update(config_obj.__dict__) + logger.info(f"Config loaded") + + def save(self, config_dict: dict | None = None): + if not config_dict: + config_dict = self.dict() + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(config_dict, f, indent=4) + + def init(self): load_dotenv(".env") - settings = env_to_config() - save_config_to_file(settings, CONFIG_PATH) -elif os.path.isdir("config") and VERSION != "DEV_VERSION": - CONFIG_PATH = "config/config.json" - if os.path.isfile(CONFIG_PATH): - settings = load_config_from_file(CONFIG_PATH) - else: - settings = env_to_config() - save_config_to_file(settings, CONFIG_PATH) -else: - settings = Setting() - + 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(): + for env, attr in section.items(): + if env in os.environ: + if isinstance(attr, list): + for _attr in attr: + attr_name = _attr[0] if isinstance(_attr, tuple) else _attr + config_dict[key][attr_name] = self.__val_from_env( + env, _attr + ) + else: + attr_name = attr[0] if isinstance(attr, tuple) else attr + config_dict[key][attr_name] = self.__val_from_env(env, attr) + config_obj = Config.parse_obj(config_dict) + self.__dict__.update(config_obj.__dict__) + logger.info(f"Config loaded from env") + + @staticmethod + def __val_from_env(env: str, attr: tuple): + if isinstance(attr, tuple): + conv_func = attr[1] + return conv_func(os.environ[env]) + else: + return os.environ[env] +settings = Settings() diff --git a/src/module/conf/const.py b/src/module/conf/const.py index 21cf330c..e8b5634f 100644 --- a/src/module/conf/const.py +++ b/src/module/conf/const.py @@ -7,7 +7,7 @@ DEFAULT_SETTINGS = { "sleep_time": 7200, "times": 20, "webui_port": 7892, - "data_version": 4.0 + "data_version": 4.0, }, "downloader": { "type": "qbittorrent", @@ -15,7 +15,7 @@ DEFAULT_SETTINGS = { "username": "admin", "password": "adminadmin", "path": "/downloads/Bangumi", - "ssl": False + "ssl": False, }, "rss_parser": { "enable": True, @@ -24,14 +24,14 @@ DEFAULT_SETTINGS = { "token": "", "enable_tmdb": False, "filter": ["720", "\\d+-\\d+"], - "language": "zh" + "language": "zh", }, "bangumi_manage": { "enable": True, "eps_complete": False, "rename_method": "pn", "group_tag": False, - "remove_bad_torrent": False + "remove_bad_torrent": False, }, "log": { "debug_enable": False, @@ -42,14 +42,9 @@ DEFAULT_SETTINGS = { "host": "", "port": 1080, "username": "", - "password": "" + "password": "", }, - "notification": { - "enable": False, - "type": "telegram", - "token": "", - "chat_id": "" - } + "notification": {"enable": False, "type": "telegram", "token": "", "chat_id": ""}, } @@ -80,7 +75,10 @@ ENV_TO_ATTR = { "AB_METHOD": ("rename_method", lambda e: e.lower()), "AB_GROUP_TAG": ("group_tag", lambda e: e.lower() in ("true", "1", "t")), "AB_EP_COMPLETE": ("eps_complete", lambda e: e.lower() in ("true", "1", "t")), - "AB_REMOVE_BAD_BT": ("remove_bad_torrent", lambda e: e.lower() in ("true", "1", "t")), + "AB_REMOVE_BAD_BT": ( + "remove_bad_torrent", + lambda e: e.lower() in ("true", "1", "t"), + ), }, "log": { "AB_DEBUG_MODE": ("debug_enable", lambda e: e.lower() in ("true", "1", "t")), diff --git a/src/module/conf/log.py b/src/module/conf/log.py index 475b475c..8ba45ce1 100644 --- a/src/module/conf/log.py +++ b/src/module/conf/log.py @@ -1,3 +1,4 @@ +import os import logging from .config import settings @@ -5,18 +6,24 @@ from .config import settings LOG_PATH = "data/log.txt" -def setup_logger(): - level = logging.DEBUG if settings.log.debug_enable else logging.INFO - logging.addLevelName(logging.DEBUG, 'DEBUG:') - logging.addLevelName(logging.INFO, 'INFO:') - logging.addLevelName(logging.WARNING, 'WARNING:') +def setup_logger(level: int = logging.INFO, reset: bool = False): + level = logging.DEBUG if settings.log.debug_enable else level + if not os.path.isdir("data"): + os.mkdir("data") + if reset and os.path.isfile(LOG_PATH): + os.remove(LOG_PATH) + logging.addLevelName(logging.DEBUG, "DEBUG:") + logging.addLevelName(logging.INFO, "INFO:") + logging.addLevelName(logging.WARNING, "WARNING:") LOGGING_FORMAT = "[%(asctime)s] %(levelname)-8s %(message)s" + TIME_FORMAT = "%Y-%m-%d %H:%M:%S" logging.basicConfig( level=level, format=LOGGING_FORMAT, + datefmt=TIME_FORMAT, encoding="utf-8", handlers=[ logging.FileHandler(LOG_PATH, encoding="utf-8"), logging.StreamHandler(), - ] + ], ) diff --git a/src/module/conf/logging.conf b/src/module/conf/logging.conf deleted file mode 100644 index 047be4fb..00000000 --- a/src/module/conf/logging.conf +++ /dev/null @@ -1,72 +0,0 @@ -[loggers] -keys=root, gunicorn.error, gunicorn.access,uvicorn.error,uvicorn.access - -[handlers] -keys=console, error_file, access_file, accesscustom - -[formatters] -keys=generic, access, AccessFormatter - -[logger_root] -level=INFO -handlers=console -propagate=1 - -[logger_gunicorn.error] -level=INFO -handlers=error_file -propagate=0 -qualname=gunicorn.error - -[logger_gunicorn.access] -level=INFO -handlers=accesscustom -propagate=0 -qualname=gunicorn.access - -[logger_uvicorn.error] -level=INFO -handlers=error_file -propagate=0 -qualname=uvicorn.error - -[logger_uvicorn.access] -level=INFO -handlers=accesscustom -propagate=0 -qualname=uvicorn.access - -[handler_console] -class=StreamHandler -formatter=generic -args=(sys.stdout, ) - -[handler_error_file] -class=StreamHandler -formatter=generic -args=(sys.stdout, ) - -[handler_access_file] -class=StreamHandler -formatter=access -args=(sys.stdout, ) - -[handler_accesscustom] -class=StreamHandler -formatter=AccessFormatter -args=(sys.stdout, ) - -[formatter_generic] -format=%(levelname)s: %(message)s -datefmt=%Y-%m-%dT%H:%M:%S -class=uvicorn.logging.DefaultFormatter - -[formatter_access] -format=%(levelname)s: %(message)s -datefmt=%Y-%m-%dT%H:%M:%S -class=customlogger.CustomFormatter - -[formatter_AccessFormatter] -format={"event":"access_log","ip":"%(h)s","status":"%(s)s","method":"%(m)s","path":"%(U)s","referer":"%(f)s","x_session_id":"%(x-session-id)s","x_google_id":"%(x-google-id)s","x_server_time":"%(x-server-time)s","agent":"%(a)s"} -datefmt=%Y-%m-%dT%H:%M:%S -class=customlogger.CustomFormatter \ No newline at end of file diff --git a/src/module/conf/parse.py b/src/module/conf/parse.py index 8d87730c..a917bf4b 100644 --- a/src/module/conf/parse.py +++ b/src/module/conf/parse.py @@ -12,4 +12,4 @@ def parse(): ) parser.add_argument("-d", "--debug", action="store_true", help="debug mode") - return parser.parse_args() \ No newline at end of file + return parser.parse_args() diff --git a/src/module/conf/uvicorn_logging.py b/src/module/conf/uvicorn_logging.py new file mode 100644 index 00000000..b2e30882 --- /dev/null +++ b/src/module/conf/uvicorn_logging.py @@ -0,0 +1,38 @@ +from .log import LOG_PATH + +logging_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "[%(asctime)s] %(levelname)-8s %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + } + }, + "handlers": { + "file": { + "class": "logging.FileHandler", + "filename": LOG_PATH, + "formatter": "default", + "encoding": "utf-8", + }, + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + }, + }, + "loggers": { + "uvicorn": { + "handlers": ["console"], + "level": "INFO", + }, + "uvicorn.error": { + "level": "WARNING", + }, + "uvicorn.access": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + }, +} diff --git a/src/module/core/__init__.py b/src/module/core/__init__.py index 596ca235..84633ad6 100644 --- a/src/module/core/__init__.py +++ b/src/module/core/__init__.py @@ -1,2 +1 @@ -from .download_client import DownloadClient -from .api_func import APIProcess +from .program import Program diff --git a/src/module/core/api_func.py b/src/module/core/api_func.py deleted file mode 100644 index 69bc482a..00000000 --- a/src/module/core/api_func.py +++ /dev/null @@ -1,101 +0,0 @@ -import re -import logging - -from module.core import DownloadClient -from module.manager import FullSeasonGet -from module.rss import RSSAnalyser -from module.utils import json_config -from module.conf import DATA_PATH -from module.conf.config import save_config_to_file, CONFIG_PATH -from module.models import Config -from module.network import RequestContent - -from module.ab_decorator import api_failed - -logger = logging.getLogger(__name__) - - -class APIProcess: - def __init__(self, settings: Config): - self._rss_analyser = RSSAnalyser(settings) - self._client = DownloadClient(settings) - self._full_season_get = FullSeasonGet(settings) - self._custom_url = settings.rss_parser.custom_url - - def link_process(self, link): - return self._rss_analyser.rss_to_data(link, _filter=False) - - @api_failed - def download_collection(self, link): - if not self._client.authed: - self._client.auth() - data = self.link_process(link) - self._full_season_get.download_collection(data, link, self._client) - return data - - @api_failed - def add_subscribe(self, link): - if not self._client.authed: - self._client.auth() - data = self.link_process(link) - self._client.add_rss_feed(link, data.official_title) - self._client.set_rule(data, link) - return data - - @staticmethod - def reset_rule(): - data = json_config.load(DATA_PATH) - data["bangumi_info"] = [] - json_config.save(DATA_PATH, data) - return "Success" - - @staticmethod - def remove_rule(_id: int): - datas = json_config.load(DATA_PATH)["bangumi_info"] - for data in datas: - if data["id"] == _id: - datas.remove(data) - break - json_config.save(DATA_PATH, datas) - return "Success" - - @staticmethod - def add_rule(title, season): - data = json_config.load(DATA_PATH) - extra_data = { - "official_title": title, - "title_raw": title, - "season": season, - "season_raw": "", - "dpi": "", - "group": "", - "eps_complete": False, - "added": False, - } - data["bangumi_info"].append(extra_data) - json_config.save(DATA_PATH, data) - return "Success" - - @staticmethod - def update_config(config: Config): - save_config_to_file(config, CONFIG_PATH) - return {"message": "Success"} - - @staticmethod - def get_config() -> dict: - return json_config.load(CONFIG_PATH) - - def get_rss(self, full_path: str): - url = f"https://mikanani.me/RSS/{full_path}" - custom_url = self._custom_url - if "://" not in custom_url: - custom_url = f"https://{custom_url}" - with RequestContent() as request: - content = request.get_html(url) - return re.sub(r"https://mikanani.me", custom_url, content) - - @staticmethod - def get_torrent(full_path): - url = f"https://mikanani.me/Download/{full_path}" - with RequestContent() as request: - return request.get_content(url) diff --git a/src/module/core/download_client.py b/src/module/core/download_client.py deleted file mode 100644 index 2d05a111..00000000 --- a/src/module/core/download_client.py +++ /dev/null @@ -1,125 +0,0 @@ -import re -import logging -import os - -from module.downloader import getClient -from module.models import BangumiData, Config - - -logger = logging.getLogger(__name__) - - -class DownloadClient: - def __init__(self, settings: Config): - self.client = getClient(settings) - self.authed = False - self.download_path = settings.downloader.path - self.group_tag = settings.bangumi_manage.group_tag - - def auth(self): - self.client.auth() - self.authed = True - - def init_downloader(self): - prefs = { - "rss_auto_downloading_enabled": True, - "rss_max_articles_per_feed": 500, - "rss_processing_enabled": True, - "rss_refresh_interval": 30, - } - self.client.prefs_init(prefs=prefs) - try: - self.client.add_category("BangumiCollection") - except Exception as e: - logger.warning("Cannot add new category, maybe already exists.") - logger.debug(e) - if self.download_path == "": - prefs = self.client.get_app_prefs() - self.download_path = os.path.join(prefs["save_path"], "Bangumi") - - def set_rule(self, info: BangumiData, rss_link): - official_name, raw_name, season, group = info.official_title, info.title_raw, info.season, info.group - rule = { - "enable": True, - "mustContain": raw_name, - "mustNotContain": "|".join(info.filter), - "useRegex": True, - "episodeFilter": "", - "smartFilter": False, - "previouslyMatchedEpisodes": [], - "affectedFeeds": [rss_link], - "ignoreDays": 0, - "lastMatch": "", - "addPaused": False, - "assignedCategory": "Bangumi", - "savePath": str( - os.path.join( - self.download_path, - re.sub(r"[:/.]", " ", official_name).strip(), - f"Season {season}", - ) - ), - } - rule_name = f"[{group}] {official_name}" if self.group_tag else official_name - self.client.rss_set_rule(rule_name=f"{rule_name} S{season}", rule_def=rule) - logger.info(f"Add {official_name} Season {season}") - - def rss_feed(self, rss_link, item_path="Mikan_RSS"): - # TODO: 定时刷新 RSS - self.client.rss_add_feed(url=rss_link, item_path=item_path) - - def add_collection_feed(self, rss_link, item_path): - self.client.rss_add_feed(url=rss_link, item_path=item_path) - logger.info("Add Collection RSS Feed successfully.") - - def add_rules(self, bangumi_info: list[BangumiData], rss_link: str): - logger.debug("Start adding rules.") - for info in bangumi_info: - if not info.added: - self.set_rule(info, rss_link) - info.added = True - logger.debug("Finished.") - - def get_torrent_info(self, category="Bangumi"): - return self.client.torrents_info( - status_filter="completed", category=category - ) - - def rename_torrent_file(self, _hash, old_path, new_path): - self.client.torrents_rename_file( - torrent_hash=_hash, old_path=old_path, new_path=new_path - ) - logger.info(f"{old_path} >> {new_path}") - - def delete_torrent(self, hashes): - self.client.torrents_delete( - hashes - ) - logger.info(f"Remove bad torrents.") - - def add_torrent(self, torrent: dict): - self.client.torrents_add( - urls=torrent["url"], - save_path=torrent["save_path"], - category="Bangumi" - ) - - def move_torrent(self, hashes, location): - self.client.move_torrent( - hashes=hashes, - new_location=location - ) - - def add_rss_feed(self, rss_link, item_path): - self.client.rss_add_feed(url=rss_link, item_path=item_path) - logger.info("Add RSS Feed successfully.") - - def get_download_rules(self): - return self.client.get_download_rule() - - def get_torrent_path(self, hashes): - return self.client.get_torrent_path(hashes) - - def set_category(self, hashes, category): - self.client.set_category(hashes, category) - diff --git a/src/module/core/download_fliter.py b/src/module/core/download_fliter.py deleted file mode 100644 index f779898b..00000000 --- a/src/module/core/download_fliter.py +++ /dev/null @@ -1,25 +0,0 @@ -import re -import logging -import xml.etree.ElementTree -from typing import Tuple - -from module.conf import settings -from module.utils import json_config - -logger = logging.getLogger(__name__) - - -class RSSFilter: - def __init__(self): - self.filter_rule = json_config.load(settings.filter_rule) - - def filter(self, item: xml.etree.ElementTree.Element) -> Tuple[bool, str]: - title = item.find('title').text - torrent = item.find("enclosure").attrib['url'] - download = False - for rule in self.filter_rule: - if re.search(rule["include"], title): - if not re.search(rule["exclude"], title): - download = True - logger.debug(f"{title} added") - return download, torrent diff --git a/src/module/core/program.py b/src/module/core/program.py new file mode 100644 index 00000000..21e37bf4 --- /dev/null +++ b/src/module/core/program.py @@ -0,0 +1,61 @@ +import logging + +from .sub_thread import RenameThread, RSSThread +from .rss_feed import add_rss_feed + +from module.conf import settings, VERSION +from module.update import data_migration + +logger = logging.getLogger(__name__) + + +class Program(RenameThread, RSSThread): + @staticmethod + def __start_info(): + with open("icon", "r") as f: + for line in f.readlines(): + logger.info(line.strip("\n")) + logger.info( + f"Version {VERSION} Author: EstrellaXD Twitter: https://twitter.com/Estrella_Pan" + ) + logger.info("GitHub: https://github.com/EstrellaXD/Auto_Bangumi/") + logger.info("Starting AutoBangumi...") + + def startup(self): + self.__start_info() + if self.first_run: + logger.info("First run detected, please configure the program in webui.") + return {"status": "First run detected."} + if self.legacy_data: + logger.info( + "Legacy data detected, starting data migration, please wait patiently." + ) + data_migration() + add_rss_feed() + self.start() + + def start(self): + if self.first_run: + return {"status": "Not ready to start."} + self.stop_event.clear() + settings.load() + if self.enable_renamer: + self.rename_start() + if self.enable_rss: + self.rss_start() + logger.info("Program running.") + return {"status": "Program started."} + + def stop(self): + if self.is_running: + self.stop_event.set() + self.rename_stop() + self.rss_stop() + return {"status": "Program stopped."} + else: + return {"status": "Program is not running."} + + def restart(self): + self.stop() + self.start() + return {"status": "Program restarted."} diff --git a/src/module/core/rss_feed.py b/src/module/core/rss_feed.py new file mode 100644 index 00000000..e1f14653 --- /dev/null +++ b/src/module/core/rss_feed.py @@ -0,0 +1,34 @@ +import logging + +from module.downloader import DownloadClient +from module.conf import settings + +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/src/module/core/status.py b/src/module/core/status.py new file mode 100644 index 00000000..de42599b --- /dev/null +++ b/src/module/core/status.py @@ -0,0 +1,54 @@ +import os.path +import threading +import asyncio + +from module.checker import Checker + + +class ProgramStatus(Checker): + def __init__(self): + super().__init__() + self.stop_event = threading.Event() + self.lock = threading.Lock() + self._downloader_status = False + self._torrents_status = False + self.event = asyncio.Event() + + @property + def is_running(self): + if self.stop_event.is_set() or self.check_first_run(): + return False + else: + return True + + @property + def is_stopped(self): + return self.stop_event.is_set() + + @property + def downloader_status(self): + if not self._downloader_status: + 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() + + @property + def enable_renamer(self): + return self.check_renamer() + + @property + def first_run(self): + return self.check_first_run() + + @property + def legacy_data(self): + return os.path.exists("data/data.json") diff --git a/src/module/core/sub_thread.py b/src/module/core/sub_thread.py new file mode 100644 index 00000000..33f9d1c5 --- /dev/null +++ b/src/module/core/sub_thread.py @@ -0,0 +1,84 @@ +import threading +import time + +from .status import ProgramStatus + +from module.rss import analyser +from module.downloader import DownloadClient +from module.manager import Renamer, eps_complete +from module.notification import PostNotification +from module.database import BangumiDatabase +from module.conf import settings + + +class RSSThread(ProgramStatus): + def __init__(self): + super().__init__() + self._rss_thread = threading.Thread( + target=self.rss_loop, + ) + + def rss_loop(self): + 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) + if settings.bangumi_manage.eps_complete: + eps_complete() + self.stop_event.wait(settings.program.rss_time) + + def rss_start(self): + self.rss_thread.start() + + def rss_stop(self): + if self._rss_thread.is_alive(): + self._rss_thread.join() + + @property + def rss_thread(self): + if not self._rss_thread.is_alive(): + self._rss_thread = threading.Thread( + target=self.rss_loop, + ) + return self._rss_thread + + +class RenameThread(ProgramStatus): + def __init__(self): + super().__init__() + self._rename_thread = threading.Thread( + target=self.rename_loop, + ) + + def rename_loop(self): + while not self.stop_event.is_set(): + with Renamer() as renamer: + renamed_info = renamer.rename() + if settings.notification.enable: + with PostNotification() as notifier: + for info in renamed_info: + notifier.send_msg(info) + time.sleep(2) + self.stop_event.wait(settings.program.rename_time) + + def rename_start(self): + self.rename_thread.start() + + def rename_stop(self): + if self._rename_thread.is_alive(): + self._rename_thread.join() + + @property + def rename_thread(self): + if not self._rename_thread.is_alive(): + self._rename_thread = threading.Thread( + target=self.rename_loop, + ) + return self._rename_thread diff --git a/src/module/database/__init__.py b/src/module/database/__init__.py new file mode 100644 index 00000000..c5d59b08 --- /dev/null +++ b/src/module/database/__init__.py @@ -0,0 +1 @@ +from .bangumi import BangumiDatabase diff --git a/src/module/database/bangumi.py b/src/module/database/bangumi.py new file mode 100644 index 00000000..74e7fec5 --- /dev/null +++ b/src/module/database/bangumi.py @@ -0,0 +1,235 @@ +import logging + +from module.database.connector import DataConnector +from module.models import BangumiData + +logger = logging.getLogger(__name__) + + +class BangumiDatabase(DataConnector): + def __init__(self): + super().__init__() + self.__table_name = "bangumi" + + def update_table(self): + db_data = self.__data_to_db(BangumiData()) + self._update_table(self.__table_name, db_data) + + @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 __fetch_data(self) -> list[BangumiData]: + values = self._cursor.fetchall() + if values is None: + return [] + keys = [x[0] for x in self._cursor.description] + dict_data = [dict(zip(keys, value)) for value in values] + return [self.__db_to_data(x) for x in dict_data] + + def insert(self, data: BangumiData): + 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]): + _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 update_one(self, data: BangumiData) -> bool: + db_data = self.__data_to_db(data) + return self._update(db_data=db_data, table_name=self.__table_name) + + def update_list(self, data: list[BangumiData]): + data_list = [self.__data_to_db(x) for x in data] + self._update_list(data_list=data_list, table_name=self.__table_name) + + def update_rss(self, title_raw, rss_set: str): + # Update rss and added + self._cursor.execute( + """ + UPDATE bangumi + SET rss_link = :rss_link, added = 0 + WHERE title_raw = :title_raw + """, + {"rss_link": rss_set, "title_raw": title_raw}, + ) + self._conn.commit() + logger.debug(f"[Database] Update {title_raw} rss_link to {rss_set}.") + + def update_poster(self, title_raw, poster_link: str): + self._cursor.execute( + """ + UPDATE bangumi + SET poster_link = :poster_link + WHERE title_raw = :title_raw + """, + {"poster_link": poster_link, "title_raw": title_raw}, + ) + self._conn.commit() + logger.debug(f"[Database] Update {title_raw} poster_link to {poster_link}.") + + def delete_one(self, _id: int) -> bool: + self._cursor.execute( + """ + DELETE FROM bangumi WHERE id = :id + """, + {"id": _id}, + ) + self._conn.commit() + logger.debug(f"[Database] Delete bangumi id: {_id}.") + return self._cursor.rowcount == 1 + + def delete_all(self): + self._delete_all(self.__table_name) + + def search_all(self) -> list[BangumiData]: + self._cursor.execute( + """ + SELECT * FROM bangumi + """ + ) + return self.__fetch_data() + + def search_id(self, _id: int) -> BangumiData | None: + self._cursor.execute( + """ + SELECT * FROM bangumi WHERE id = :id + """, + {"id": _id}, + ) + values = self._cursor.fetchone() + if values is None: + return None + keys = [x[0] for x in self._cursor.description] + dict_data = dict(zip(keys, values)) + return self.__db_to_data(dict_data) + + def search_official_title(self, official_title: str) -> BangumiData | None: + self._cursor.execute( + """ + SELECT * FROM bangumi WHERE official_title = :official_title + """, + {"official_title": official_title}, + ) + values = self._cursor.fetchone() + if values is None: + return None + keys = [x[0] for x in self._cursor.description] + dict_data = dict(zip(keys, values)) + return self.__db_to_data(dict_data) + + def match_poster(self, bangumi_name: str) -> str: + self._cursor.execute( + """ + SELECT official_title, poster_link + FROM bangumi + WHERE INSTR(:bangumi_name, official_title) > 0 + """, + {"bangumi_name": bangumi_name}, + ) + data = self._cursor.fetchone() + if not data: + return "" + official_title, poster_link = data + if not poster_link: + return "" + return poster_link + + def match_list(self, torrent_list: list, rss_link: str) -> list: + # Match title_raw in database + self._cursor.execute( + """ + SELECT title_raw, rss_link, poster_link FROM bangumi + """ + ) + data = self._cursor.fetchall() + if not data: + return torrent_list + # Match title + i = 0 + while i < len(torrent_list): + torrent = torrent_list[i] + for title_raw, rss_set, poster_link in data: + if title_raw in torrent.name: + if rss_link not in rss_set: + rss_set += "," + rss_link + self.update_rss(title_raw, rss_set) + if not poster_link: + self.update_poster(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 + self._cursor.execute( + """ + SELECT * FROM bangumi WHERE eps_collect = 0 + """ + ) + return self.__fetch_data() + + def not_added(self) -> list[BangumiData]: + self._cursor.execute( + """ + SELECT * FROM bangumi + WHERE added = 0 OR rule_name IS NULL OR save_path IS NULL + """ + ) + return self.__fetch_data() + + def gen_id(self) -> int: + self._cursor.execute( + """ + SELECT id FROM bangumi ORDER BY id DESC LIMIT 1 + """ + ) + data = self._cursor.fetchone() + if data is None: + return 1 + return data[0] + 1 + + def __check_exist(self, data: BangumiData): + self._cursor.execute( + """ + SELECT * FROM bangumi WHERE official_title = :official_title + """, + {"official_title": data.official_title}, + ) + values = self._cursor.fetchone() + if values is None: + return False + return True + + def __check_list_exist(self, data_list: list[BangumiData]): + for data in data_list: + if self.__check_exist(data): + return True + return False diff --git a/src/module/database/connector.py b/src/module/database/connector.py new file mode 100644 index 00000000..d74bb159 --- /dev/null +++ b/src/module/database/connector.py @@ -0,0 +1,128 @@ +import os +import sqlite3 +import logging + +from module.conf import DATA_PATH + +logger = logging.getLogger(__name__) + + +class DataConnector: + def __init__(self): + # Create folder if not exists + if not os.path.exists(os.path.dirname(DATA_PATH)): + os.makedirs(os.path.dirname(DATA_PATH)) + 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 _table_exists(self, table_name: str) -> bool: + self._cursor.execute( + f"SELECT name FROM sqlite_master WHERE type='table' AND name=?;", + (table_name,), + ) + return len(self._cursor.fetchall()) == 1 + + @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/src/module/database/torrent.py b/src/module/database/torrent.py new file mode 100644 index 00000000..bbdf0f8c --- /dev/null +++ b/src/module/database/torrent.py @@ -0,0 +1,47 @@ +import logging + +from .connector import DataConnector + +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) + + 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 __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 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 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() diff --git a/src/module/database/user.py b/src/module/database/user.py new file mode 100644 index 00000000..22a97020 --- /dev/null +++ b/src/module/database/user.py @@ -0,0 +1,74 @@ +import logging + +from fastapi import HTTPException + +from module.database.connector import DataConnector + +from module.security.jwt import get_password_hash, verify_password +from module.models.user import User + +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) + + def get_user(self, username): + self._cursor.execute( + f"SELECT * FROM {self.__table_name} WHERE username=?", (username,) + ) + result = self._cursor.fetchone() + 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) + + 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() + if not result: + raise HTTPException(status_code=404, detail="User not found") + if not verify_password(password, result[1]): + raise HTTPException(status_code=401, detail="Password error") + return True + + def update_user(self, username, update_user: User): + # 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}' + """ + ) + self._conn.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")) diff --git a/src/module/downloader/__init__.py b/src/module/downloader/__init__.py index b7b33989..0f5784c1 100644 --- a/src/module/downloader/__init__.py +++ b/src/module/downloader/__init__.py @@ -1,15 +1 @@ -from module.models import Config - - -def getClient(settings: Config): - # TODO 多下载器支持 - type = settings.downloader.type - host = settings.downloader.host - username = settings.downloader.username - password = settings.downloader.password - ssl = settings.downloader.ssl - if type == "qbittorrent": - from .qb_downloader import QbDownloader - return QbDownloader(host, username, password, ssl) - else: - raise Exception(f"Unsupported downloader type: {type}") +from .download_client import DownloadClient diff --git a/src/module/downloader/tr_downloader.py b/src/module/downloader/client/__init__.py similarity index 100% rename from src/module/downloader/tr_downloader.py rename to src/module/downloader/client/__init__.py diff --git a/src/module/downloader/aria2_downloader.py b/src/module/downloader/client/aria2_downloader.py similarity index 74% rename from src/module/downloader/aria2_downloader.py rename to src/module/downloader/client/aria2_downloader.py index 95400334..8cfb5aea 100644 --- a/src/module/downloader/aria2_downloader.py +++ b/src/module/downloader/client/aria2_downloader.py @@ -6,7 +6,7 @@ from aria2p import Client, ClientException, API from module.conf import settings -from .exceptions import ConflictError +from module.downloader.exceptions import ConflictError logger = logging.getLogger(__name__) @@ -15,13 +15,7 @@ class QbDownloader: def __init__(self, host, username, password): while True: try: - self._client = API( - Client( - host=host, - port=6800, - secret=password - ) - ) + self._client = API(Client(host=host, port=6800, secret=password)) break except ClientException: logger.warning( @@ -35,4 +29,4 @@ class QbDownloader: torrent_file_path=urls, save_path=save_path, category=category, - ) \ No newline at end of file + ) diff --git a/src/module/downloader/qb_downloader.py b/src/module/downloader/client/qb_downloader.py similarity index 55% rename from src/module/downloader/qb_downloader.py rename to src/module/downloader/client/qb_downloader.py index ecb12693..b10c2386 100644 --- a/src/module/downloader/qb_downloader.py +++ b/src/module/downloader/client/qb_downloader.py @@ -2,7 +2,11 @@ import logging import time from qbittorrentapi import Client, LoginFailed -from qbittorrentapi.exceptions import Conflict409Error +from qbittorrentapi.exceptions import ( + Conflict409Error, + Forbidden403Error, + APIConnectionError, +) from module.ab_decorator import qb_connect_failed_wait from module.downloader.exceptions import ConflictError @@ -16,22 +20,40 @@ class QbDownloader: host=host, username=username, password=password, - VERIFY_WEBUI_CERTIFICATE=ssl + VERIFY_WEBUI_CERTIFICATE=ssl, + DISABLE_LOGGING_DEBUG_OUTPUT=True, + REQUESTS_ARGS={"timeout": (3.1, 10)}, ) self.host = host self.username = username - @qb_connect_failed_wait def auth(self): - while True: + times = 0 + while times < 3: try: self._client.auth_log_in() - break + return True except LoginFailed: logger.error( f"Can't login qBittorrent Server {self.host} by {self.username}, retry in {5} seconds." ) - time.sleep(5) + time.sleep(5) + times += 1 + except Forbidden403Error: + logger.error(f"Login refused by qBittorrent Server") + logger.info(f"Please release the IP in qBittorrent Server") + break + except APIConnectionError: + logger.error(f"Cannot connect to qBittorrent Server") + logger.info(f"Please check the IP and port in WebUI settings") + time.sleep(30) + except Exception as e: + logger.error(f"Unknown error: {e}") + break + return False + + def logout(self): + self._client.auth_log_out() @qb_connect_failed_wait def prefs_init(self, prefs): @@ -45,8 +67,8 @@ class QbDownloader: return self._client.torrents_createCategory(name=category) @qb_connect_failed_wait - def torrents_info(self, status_filter, category): - return self._client.torrents_info(status_filter, category) + def torrents_info(self, status_filter, category, tag=None): + return self._client.torrents_info(status_filter=status_filter, category=category, tag=tag) def torrents_add(self, urls, save_path, category): return self._client.torrents_add( @@ -54,46 +76,24 @@ class QbDownloader: urls=urls, save_path=save_path, category=category, + use_auto_torrent_management=False ) def torrents_delete(self, hash): - return self._client.torrents_delete( - delete_files=True, - torrent_hashes=hash - ) + return self._client.torrents_delete(delete_files=True, torrent_hashes=hash) - def torrents_rename_file(self, torrent_hash, old_path, new_path): - self._client.torrents_rename_file(torrent_hash=torrent_hash, old_path=old_path, new_path=new_path) - - def check_rss(self, url, item_path) -> tuple[str | None, bool]: - items = self._client.rss_items() - for key, value in items.items(): - rss_url = value.get("url") - if key == item_path: - if rss_url != url: - return key, False - return None, True - else: - if rss_url == url: - return key, True - return None, False + def torrents_rename_file(self, torrent_hash, old_path, new_path) -> bool: + try: + self._client.torrents_rename_file( + torrent_hash=torrent_hash, old_path=old_path, new_path=new_path + ) + return True + except Conflict409Error: + logger.debug(f"Conflict409Error: {old_path} >> {new_path}") + return False def rss_add_feed(self, url, item_path): - path, added = self.check_rss(url, item_path) - if path: - if not added: - logger.info("RSS Exist, Update URL.") - self._client.rss_remove_item(path) - self._client.rss_add_feed(url, item_path) - else: - logger.info("RSS Exist.") - else: - if added: - logger.info("RSS Exist.") - else: - logger.info("Add new RSS") - self._client.rss_add_feed(url, item_path) - logger.info("Successfully added RSS") + self._client.rss_add_feed(url, item_path) def rss_remove_item(self, item_path): try: @@ -103,6 +103,9 @@ class QbDownloader: logger.info("Add new RSS") raise ConflictError() + def rss_get_feeds(self): + return self._client.rss_items() + def rss_set_rule(self, rule_name, rule_def): self._client.rss_set_rule(rule_name, rule_def) @@ -120,3 +123,9 @@ class QbDownloader: def check_connection(self): return self._client.app_version() + + def remove_rule(self, rule_name): + self._client.rss_remove_rule(rule_name) + + def add_tag(self, _hash, tag): + self._client.torrents_add_tags(tags=tag, hashes=_hash) diff --git a/src/module/downloader/client/tr_downloader.py b/src/module/downloader/client/tr_downloader.py new file mode 100644 index 00000000..e69de29b diff --git a/src/module/downloader/download_client.py b/src/module/downloader/download_client.py new file mode 100644 index 00000000..a3fa6d98 --- /dev/null +++ b/src/module/downloader/download_client.py @@ -0,0 +1,135 @@ +import logging + +from .path import TorrentPath + +from module.models import BangumiData +from module.conf import settings + +logger = logging.getLogger(__name__) + + +class DownloadClient(TorrentPath): + def __init__(self): + super().__init__() + self.client = self.__getClient() + self.authed = False + + @staticmethod + def __getClient(): + # TODO 多下载器支持 + type = settings.downloader.type + host = settings.downloader.host + username = settings.downloader.username + password = settings.downloader.password + ssl = settings.downloader.ssl + if type == "qbittorrent": + from .client.qb_downloader import QbDownloader + + return QbDownloader(host, username, password, ssl) + else: + logger.error(f"[Downloader] Unsupported downloader type: {type}") + raise Exception(f"Unsupported downloader type: {type}") + + def __enter__(self): + if not self.authed: + self.auth() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.authed: + self.client.logout() + self.authed = False + + def auth(self): + self.authed = self.client.auth() + logger.debug("Authed.") + + def init_downloader(self): + prefs = { + "rss_auto_downloading_enabled": True, + "rss_max_articles_per_feed": 500, + "rss_processing_enabled": True, + "rss_refresh_interval": 30, + } + self.client.prefs_init(prefs=prefs) + try: + self.client.add_category("BangumiCollection") + except Exception as e: + logger.debug("[Downloader] Cannot add new category, maybe already exists.") + if settings.downloader.path == "": + prefs = self.client.get_app_prefs() + settings.downloader.path = self._join_path(prefs["save_path"], "Bangumi") + + def set_rule(self, data: BangumiData): + data.rule_name = self._rule_name(data) + data.save_path = self._gen_save_path(data) + rule = { + "enable": True, + "mustContain": data.title_raw, + "mustNotContain": "|".join(data.filter), + "useRegex": True, + "episodeFilter": "", + "smartFilter": False, + "previouslyMatchedEpisodes": [], + "affectedFeeds": data.rss_link, + "ignoreDays": 0, + "lastMatch": "", + "addPaused": False, + "assignedCategory": "Bangumi", + "savePath": data.save_path, + } + self.client.rss_set_rule(rule_name=data.rule_name, rule_def=rule) + data.added = True + logger.info( + f"[Downloader] Add {data.official_title} Season {data.season} to auto download rules." + ) + + def set_rules(self, bangumi_info: list[BangumiData]): + logger.debug("[Downloader] Start adding rules.") + for info in bangumi_info: + self.set_rule(info) + logger.debug("[Downloader] Finished.") + + def get_torrent_info(self, category="Bangumi", status_filter="completed", tag=None): + return self.client.torrents_info(status_filter=status_filter, category=category, tag=tag) + + def rename_torrent_file(self, _hash, old_path, new_path) -> bool: + logger.info(f"{old_path} >> {new_path}") + return self.client.torrents_rename_file( + torrent_hash=_hash, old_path=old_path, new_path=new_path + ) + + def delete_torrent(self, hashes): + self.client.torrents_delete(hashes) + logger.info(f"[Downloader] Remove torrents.") + + def add_torrent(self, torrent: dict): + self.client.torrents_add( + urls=torrent["url"], save_path=torrent["save_path"], category="Bangumi" + ) + + def move_torrent(self, hashes, location): + self.client.move_torrent(hashes=hashes, new_location=location) + + # RSS Parts + def add_rss_feed(self, rss_link, item_path="Mikan_RSS"): + self.client.rss_add_feed(url=rss_link, item_path=item_path) + + def remove_rss_feed(self, item_path): + self.client.rss_remove_item(item_path=item_path) + + def get_rss_feed(self): + return self.client.rss_get_feeds() + + def get_download_rules(self): + return self.client.get_download_rule() + + def get_torrent_path(self, hashes): + return self.client.get_torrent_path(hashes) + + def set_category(self, hashes, category): + self.client.set_category(hashes, category) + + def remove_rule(self, rule_name): + self.client.remove_rule(rule_name) + logger.info(f"[Downloader] Delete rule: {rule_name}") diff --git a/src/module/downloader/path.py b/src/module/downloader/path.py new file mode 100644 index 00000000..b0e64df4 --- /dev/null +++ b/src/module/downloader/path.py @@ -0,0 +1,71 @@ +import re +import logging + +from module.conf import settings +from module.models import BangumiData + +if ":\\" in settings.downloader.path: + import ntpath as path +else: + import os.path as path + +logger = logging.getLogger(__name__) + + +class TorrentPath: + def __init__(self): + self.download_path = settings.downloader.path + + @staticmethod + def check_files(info): + media_list = [] + subtitle_list = [] + for f in info.files: + file_name = f.name + suffix = path.splitext(file_name)[-1] + if suffix.lower() in [".mp4", ".mkv"]: + media_list.append(file_name) + elif suffix.lower() in [".ass", ".srt"]: + subtitle_list.append(file_name) + return media_list, subtitle_list + + def _path_to_bangumi(self, save_path): + # Split save path and download path + save_parts = save_path.split(path.sep) + download_parts = self.download_path.split(path.sep) + # Get bangumi name and season + bangumi_name = "" + season = 1 + for part in save_parts: + if re.match(r"S\d+|[Ss]eason \d+", part): + season = int(re.findall(r"\d+", part)[0]) + elif part not in download_parts: + bangumi_name = part + return bangumi_name, season + + @staticmethod + def _file_depth(path): + return len(path.split(path.sep)) + + def is_ep(self, path): + return self._file_depth(path) <= 2 + + def _gen_save_path(self, data: BangumiData): + folder = ( + f"{data.official_title} ({data.year})" if data.year else data.official_title + ) + save_path = path.join(self.download_path, folder, f"Season {data.season}") + return save_path + + @staticmethod + def _rule_name(data: BangumiData): + rule_name = ( + f"[{data.group_name}] {data.official_title} S{data.season}" + if settings.bangumi_manage.group_tag + else f"{data.official_title} S{data.season}" + ) + return rule_name + + @staticmethod + def _join_path(*args): + return path.join(*args) diff --git a/src/module/manager/__init__.py b/src/module/manager/__init__.py index 0dc4ebee..43c2ce58 100644 --- a/src/module/manager/__init__.py +++ b/src/module/manager/__init__.py @@ -1,2 +1,3 @@ -from .eps_complete import FullSeasonGet +from .collector import SeasonCollector, eps_complete from .renamer import Renamer +from .torrent import TorrentManager diff --git a/src/module/manager/collector.py b/src/module/manager/collector.py new file mode 100644 index 00000000..dbc3b798 --- /dev/null +++ b/src/module/manager/collector.py @@ -0,0 +1,56 @@ +import logging + +from module.downloader import DownloadClient +from module.models import BangumiData +from module.database import BangumiDatabase +from module.searcher import SearchTorrent + +logger = logging.getLogger(__name__) + + +class SeasonCollector(DownloadClient): + def add_season_torrents(self, data: BangumiData, torrents): + for torrent in torrents: + download_info = { + "url": torrent.torrent_link, + "save_path": self._gen_save_path(data), + } + self.add_torrent(download_info) + + def collect_season(self, data: BangumiData, link: str = None): + logger.info(f"Start collecting {data.official_title} Season {data.season}...") + with SearchTorrent() as st: + if not link: + torrents = st.search_season(data) + else: + torrents = st.get_torrents(link) + self.add_season_torrents(data, torrents) + logger.info("Completed!") + + def subscribe_season(self, data: BangumiData): + with BangumiDatabase() as db: + data.added = True + data.eps_collect = True + db.insert(data) + self.add_rss_feed(data.rss_link[0], item_path=data.official_title) + self.set_rule(data) + + +def eps_complete(): + with BangumiDatabase() as bd: + datas = bd.not_complete() + if datas: + logger.info("Start collecting full season...") + for data in datas: + if not data.eps_collect: + with SeasonCollector() as sc: + sc.collect_season(data) + data.eps_collect = True + bd.update_list(datas) + + +if __name__ == "__main__": + from module.conf import setup_logger + + setup_logger() + eps_complete() diff --git a/src/module/manager/eps_complete.py b/src/module/manager/eps_complete.py deleted file mode 100644 index 1ebb6794..00000000 --- a/src/module/manager/eps_complete.py +++ /dev/null @@ -1,75 +0,0 @@ -import os.path -import re -import logging - -from module.network import RequestContent - -from module.core import DownloadClient -from module.models import BangumiData, Config - -logger = logging.getLogger(__name__) - - -class FullSeasonGet: - def __init__(self, settings: Config): - self.SEARCH_KEY = ["group", "title_raw", "season_raw", "subtitle", "source", "dpi"] - self.CUSTOM_URL = "https://mikanani.me" if settings.rss_parser.custom_url == "" else settings.rss_parser.custom_url - if "://" not in self.CUSTOM_URL: - if re.match(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", self.CUSTOM_URL): - self.CUSTOM_URL = f"http://{self.CUSTOM_URL}" - self.CUSTOM_URL = f"https://{self.CUSTOM_URL}" - self.save_path = settings.downloader.path - - def init_eps_complete_search_str(self, data: BangumiData): - test = [] - for key in self.SEARCH_KEY: - data_dict = data.dict() - if data_dict[key] is not None: - test.append(data_dict[key]) - search_str_pre = "+".join(test) - search_str = re.sub(r"[\W_ ]", "+", search_str_pre) - return search_str - - def get_season_torrents(self, data: BangumiData): - keyword = self.init_eps_complete_search_str(data) - with RequestContent() as req: - torrents = req.get_torrents(f"{self.CUSTOM_URL}/RSS/Search?searchstr={keyword}") - return [torrent for torrent in torrents if data.title_raw in torrent.name] - - def collect_season_torrents(self, data: BangumiData, torrents): - downloads = [] - for torrent in torrents: - download_info = { - "url": torrent.torrent_link, - "save_path": os.path.join( - self.save_path, - data.official_title, - f"Season {data.season}") - } - downloads.append(download_info) - return downloads - - def download_eps(self, data: BangumiData, download_client: DownloadClient): - logger.info(f"Start collecting {data.official_title} Season {data.season}...") - torrents = self.get_season_torrents(data) - downloads = self.collect_season_torrents(data, torrents) - for download in downloads: - download_client.add_torrent(download) - logger.info("Completed!") - data.eps_collect = False - - def eps_complete(self, bangumi_info: list[BangumiData], download_client: DownloadClient): - for data in bangumi_info: - if data.eps_collect: - self.download_eps(data, download_client) - - def download_collection(self, data: BangumiData, link, download_client: DownloadClient): - with RequestContent() as req: - torrents = req.get_torrents(link) - downloads = self.collect_season_torrents(data, torrents) - logger.info(f"Starting download {data.official_title} Season {data.season}...") - for download in downloads: - download_client.add_torrent(download) - logger.info("Completed!") - - diff --git a/src/module/manager/renamer.py b/src/module/manager/renamer.py index cbadeea8..d88de5fb 100644 --- a/src/module/manager/renamer.py +++ b/src/module/manager/renamer.py @@ -1,200 +1,184 @@ import logging -import os.path -import re -from pathlib import PurePath, PureWindowsPath -from module.core.download_client import DownloadClient +from module.downloader import DownloadClient from module.parser import TitleParser -from module.network import PostNotification -from module.models import Config +from module.models import SubtitleFile, EpisodeFile, Notification +from module.conf import settings + logger = logging.getLogger(__name__) -class Renamer: - def __init__(self, download_client: DownloadClient, settings: Config): - self._client = download_client - self._renamer = TitleParser() - self._notification = PostNotification() - self.settings = settings +class Renamer(DownloadClient): + def __init__(self): + super().__init__() + self._parser = TitleParser() @staticmethod def print_result(torrent_count, rename_count): if rename_count != 0: - logger.info(f"Finished checking {torrent_count} files' name, renamed {rename_count} files.") + logger.info( + f"Finished checking {torrent_count} files' name, renamed {rename_count} files." + ) logger.debug(f"Checked {torrent_count} files") - def get_torrent_info(self, category="Bangumi"): - recent_info = self._client.get_torrent_info(category=category) - torrent_count = len(recent_info) - return recent_info, torrent_count - @staticmethod - def check_files(info): - media_list = [] - subtitle_list = [] - for f in info.files: - file_name = f.name - suffix = os.path.splitext(file_name)[-1] - if suffix.lower() in [".mp4", ".mkv"]: - media_list.append(file_name) - elif suffix.lower() in [".ass", ".srt"]: - subtitle_list.append(file_name) - return media_list, subtitle_list - - def rename_file(self, info, media_path: str, method: str, bangumi_name: str, season: int, remove_bad_torrents: bool): - torrent_name = info.name - suffix = os.path.splitext(media_path)[-1] - compare_name = self.get_file_name(media_path) - new_path = self._renamer.torrent_parser( - torrent_name=torrent_name, - bangumi_name=bangumi_name, - season=season, - suffix=suffix, - method=method + def gen_path( + file_info: EpisodeFile | SubtitleFile, bangumi_name: str, method: str + ) -> str: + season = f"0{file_info.season}" if file_info.season < 10 else file_info.season + episode = ( + f"0{file_info.episode}" if file_info.episode < 10 else file_info.episode ) - if compare_name != new_path: - try: - self._client.rename_torrent_file(_hash=info.hash, old_path=media_path, new_path=new_path) - self._notification.send_msg(bangumi_name, f"{new_path}已经更新,已自动重命名。") - except Exception as e: - logger.warning(f"{torrent_name} rename failed") - logger.warning(f"Season name: {bangumi_name}, Season: {season}, Suffix: {suffix}") - logger.debug(e) - # Delete bad torrent - self.delete_bad_torrent(info, remove_bad_torrents) + if method == "none" or method == "subtitle_none": + return file_info.media_path + elif method == "pn": + return f"{file_info.title} S{season}E{episode}{file_info.suffix}" + elif method == "advance": + return f"{bangumi_name} S{season}E{episode}{file_info.suffix}" + elif method == "normal": + logger.warning( + "[Renamer] Normal rename method is deprecated." + ) + return file_info.media_path + elif method == "subtitle_pn": + return f"{file_info.title} S{season}E{episode}.{file_info.language}{file_info.suffix}" + elif method == "subtitle_advance": + return f"{bangumi_name} S{season}E{episode}.{file_info.language}{file_info.suffix}" + else: + logger.error(f"[Renamer] Unknown rename method: {method}") + return file_info.media_path - def rename_collection(self, info, media_list: list[str], bangumi_name: str, season: int, remove_bad_torrents: bool, method: str): - _hash = info.hash - for media_path in media_list: - path_len = len(media_path.split(os.path.sep)) - if path_len <= 2: - suffix = os.path.splitext(media_path)[-1] - torrent_name = self.get_file_name(media_path) - new_name = self._renamer.torrent_parser( - torrent_name=torrent_name, - bangumi_name=bangumi_name, - season=season, - suffix=suffix, - method=method + def rename_file( + self, + torrent_name: str, + media_path: str, + bangumi_name: str, + method: str, + season: int, + _hash: str, + **kwargs, + ): + ep = self._parser.torrent_parser( + torrent_name=torrent_name, + torrent_path=media_path, + season=season, + ) + if ep: + new_path = self.gen_path(ep, bangumi_name, method=method) + if media_path != new_path: + renamed = self.rename_torrent_file( + _hash=_hash, old_path=media_path, new_path=new_path ) - if torrent_name != new_name: - try: - self._client.rename_torrent_file(_hash=_hash, old_path=media_path, new_path=new_name) - except Exception as e: - logger.warning(f"{torrent_name} rename failed") - logger.warning(f"Bangumi name: {bangumi_name}, Season: {season}, Suffix: {suffix}") - logger.debug(e) - # Delete bad torrent. - self.delete_bad_torrent(info, remove_bad_torrents) - self._client.set_category(category="BangumiCollection", hashes=_hash) + if renamed: + n = Notification( + official_title=bangumi_name, + season=ep.season, + episode=ep.episode, + ) + return n + else: + logger.warning(f"[Renamer] {media_path} parse failed") + if settings.bangumi_manage.remove_bad_torrent: + self.delete_torrent(hashes=_hash) + return None + + def rename_collection( + self, + media_list: list[str], + bangumi_name: str, + season: int, + method: str, + _hash: str, + **kwargs, + ): + for media_path in media_list: + if self.is_ep(media_path): + ep = self._parser.torrent_parser( + torrent_path=media_path, + season=season, + ) + if ep: + new_path = self.gen_path(ep, bangumi_name, method=method) + if media_path != new_path: + renamed = self.rename_torrent_file( + _hash=_hash, old_path=media_path, new_path=new_path + ) + if not renamed: + logger.warning(f"[Renamrr] {media_path} rename failed") + # Delete bad torrent. + if settings.bangumi_manage.remove_bad_torrent: + self.delete_torrent(_hash) + break def rename_subtitles( - self, - subtitle_list: list[str], - bangumi_name: str, - season: int, - method: str, - _hash + self, + subtitle_list: list[str], + torrent_name: str, + bangumi_name: str, + season: int, + method: str, + _hash, + **kwargs, ): method = "subtitle_" + method for subtitle_path in subtitle_list: - suffix = os.path.splitext(subtitle_path)[-1] - old_name = self.get_file_name(subtitle_path) - new_name = self._renamer.torrent_parser( - method=method, - torrent_name=old_name, - bangumi_name=bangumi_name, + sub = self._parser.torrent_parser( + torrent_path=subtitle_path, + torrent_name=torrent_name, season=season, - suffix=suffix + file_type="subtitle", ) - if old_name != new_name: - try: - self._client.rename_torrent_file(_hash=_hash, old_path=subtitle_path, new_path=new_name) - except Exception as e: - logger.warning(f"{old_name} rename failed") - logger.warning(f"Suffix: {suffix}") - logger.debug(e) - - def delete_bad_torrent(self, info, remove_bad_torrent: bool): - if remove_bad_torrent: - self._client.delete_torrent(info.hash) - logger.info(f"{info.name} have been deleted.") - - @staticmethod - def get_season_info(save_path: str, download_path: str): - if "\\" in download_path: - import ntpath as path - else: - import posixpath as path - # Split save path and download path - save_parts = save_path.split(path.sep) - download_parts = download_path.split(path.sep) - # Get bangumi name and season - bangumi_name = "" - season = 1 - for part in save_parts: - if re.match(r"S\d+|[Ss]eason \d+", part): - season = int(re.findall(r"\d+", part)[0]) - elif part not in download_parts: - bangumi_name = part - return bangumi_name, season - - @staticmethod - def get_file_name(file_path: str): - # Check windows or linux path - path_parts = PurePath(file_path).parts \ - if PurePath(file_path).name != file_path \ - else PureWindowsPath(file_path).parts - # Get file name - file_name = path_parts[-1] - return file_name + if sub: + new_path = self.gen_path(sub, bangumi_name, method=method) + if subtitle_path != new_path: + renamed = self.rename_torrent_file( + _hash=_hash, old_path=subtitle_path, new_path=new_path + ) + if not renamed: + logger.warning(f"[Renamer] {subtitle_path} rename failed") def rename(self): # Get torrent info - download_path = self.settings.downloader.path - rename_method = self.settings.bangumi_manage.rename_method - remove_bad_torrents = self.settings.bangumi_manage.remove_bad_torrent - recent_info, torrent_count = self.get_torrent_info() - for info in recent_info: + logger.debug("[Renamer] Start rename process.") + rename_method = settings.bangumi_manage.rename_method + torrents_info = self.get_torrent_info() + renamed_info = [] + for info in torrents_info: media_list, subtitle_list = self.check_files(info) - bangumi_name, season = self.get_season_info(info.save_path, download_path) + bangumi_name, season = self._path_to_bangumi(info.save_path) + kwargs = { + "torrent_name": info.name, + "bangumi_name": bangumi_name, + "method": rename_method, + "season": season, + "_hash": info.hash, + } + # Rename single media file if len(media_list) == 1: - self.rename_file( - info=info, - media_path=media_list[0], - method=rename_method, - bangumi_name=bangumi_name, - season=season, - remove_bad_torrents=remove_bad_torrents - ) + notify_info = self.rename_file(media_path=media_list[0], **kwargs) + if notify_info: + renamed_info.append(notify_info) + # Rename subtitle file if len(subtitle_list) > 0: - self.rename_subtitles( - subtitle_list=subtitle_list, - bangumi_name=bangumi_name, - season=season, - method=rename_method, - _hash=info.hash - ) + self.rename_subtitles(subtitle_list=subtitle_list, **kwargs) + # Rename collection elif len(media_list) > 1: - logger.info("Start rename collection") - self.rename_collection( - info=info, - media_list=media_list, - bangumi_name=bangumi_name, - season=season, - remove_bad_torrents=remove_bad_torrents, - method=rename_method - ) + logger.info("[Renamer] Start rename collection") + self.rename_collection(media_list=media_list, **kwargs) if len(subtitle_list) > 0: - self.rename_subtitles( - subtitle_list=subtitle_list, - bangumi_name=bangumi_name, - season=season, - method=rename_method, - _hash=info.hash - ) + self.rename_subtitles(subtitle_list=subtitle_list, **kwargs) + self.set_category(info.hash, "BangumiCollection") else: - logger.warning(f"{info.name} has no media file") + logger.warning(f"[Renamer] {info.name} has no media file") + logger.debug("[Renamer] Rename process finished.") + return renamed_info + +if __name__ == "__main__": + from module.conf import setup_logger + settings.log.debug_enable = True + setup_logger() + with Renamer() as renamer: + renamer.rename() diff --git a/src/module/manager/repath.py b/src/module/manager/repath.py deleted file mode 100644 index b53fd4fd..00000000 --- a/src/module/manager/repath.py +++ /dev/null @@ -1,87 +0,0 @@ -import logging -import re -from dataclasses import dataclass -from pathlib import PurePath, PureWindowsPath - -from module.core import DownloadClient -from module.conf import settings -from module.utils import json_config - -logger = logging.getLogger(__name__) - - -@dataclass -class RuleInfo: - rule_name: str - contain: str - season: int - folder_name: str - new_path: str - - -@dataclass -class RepathInfo: - path: str - hashes: list - - -class RepathTorrents: - def __init__(self, download_client: DownloadClient): - self._client = download_client - self.re_season = re.compile(r"S\d{1,2}") - - @staticmethod - def analyse_path(path: str): - path_parts = PurePath(path).parts - folder_name = path_parts[-2] - season_folder = path_parts[-1] - season = int(re.search(r"\d{1,2}", season_folder).group()) - return season, folder_name - - def get_rule(self) -> [RuleInfo]: - rules = self._client.get_download_rules() - all_rule = [] - for rule in rules: - path = rules.get(rule).savePath - must_contain = rules.get(rule).mustContain - season, folder_name = self.analyse_path(path) - new_path = PurePath(settings.downloader.path, folder_name, f"Season {season}").__str__() - all_rule.append(RuleInfo(rule, must_contain, season, folder_name, new_path)) - return all_rule - - @staticmethod - def get_difference(bangumi_data: list, rules: [RuleInfo]) -> [RuleInfo]: - different_data = [] - for data in bangumi_data: - for rule in rules: - rule_name = re.sub(r"S\d", "", rule.rule_name).strip() - if data.get("official_title") == rule_name: - if data.get("season") != rule.season: - different_data.append(rule) - data["season"] = rule.season - break - return different_data - - def get_matched_torrents_list(self, repath_rules: [RuleInfo]) -> [RePathInfo]: - infos = self._client.get_torrent_info() - repath_list = [] - for rule in repath_rules: - hashes = [] - for info in infos: - if re.search(rule.contain, info.name): - if rule.new_path != info.save_path: - hashes.append(info.hash) - infos.remove(info) - if hashes: - repath_list.append(RepathInfo(rule.new_path, hashes)) - return repath_list - - def re_path(self, repath_info: RepathInfo): - self._client.move_torrent(repath_info.hashes, repath_info.path) - - def run(self): - rules = self.get_rule() - match_list = self.get_matched_torrents_list(rules) - logging.info(f"Starting repath process.") - for list in match_list: - self.re_path(list) diff --git a/src/module/manager/torrent.py b/src/module/manager/torrent.py new file mode 100644 index 00000000..0d698570 --- /dev/null +++ b/src/module/manager/torrent.py @@ -0,0 +1,134 @@ +import logging + +from module.downloader import DownloadClient +from module.models import BangumiData +from module.database import BangumiDatabase + + +logger = logging.getLogger(__name__) + + +class TorrentManager(BangumiDatabase): + @staticmethod + def __match_torrents_list(data: BangumiData) -> 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): + 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 { + "status": "success", + "msg": f"Delete torrents for {data.official_title}", + } + else: + return { + "status": "error", + "msg": f"Can't find torrents for {data.official_title}", + } + + def delete_rule(self, _id: int | str, file: bool = False): + data = self.search_id(int(_id)) + if isinstance(data, BangumiData): + with DownloadClient() as client: + client.remove_rule(data.rule_name) + self.delete_one(int(_id)) + if file: + self.delete_torrents(data, client) + return { + "status": "success", + "msg": f"Delete rule and torrents for {data.official_title}", + } + logger.info(f"[Manager] Delete rule for {data.official_title}") + return { + "status": "success", + "msg": f"Delete rule for {data.official_title}", + } + else: + return {"status": "error", "msg": f"Can't find id {_id}"} + # data = self.search_id(int(_id)) + # if isinstance(data, BangumiData): + # self.delete_one(int(_id)) + # if file: + # self.delete_torrents(data) + # logger.info(f"Delete {data.official_title} and torrents.") + # return { + # "status": "success", + # "msg": f"Delete {data.official_title} and torrents.", + # } + # logger.info(f"Delete {data.official_title}") + # return {"status": "success", "msg": f"Delete {data.official_title}"} + # else: + # return data + + def disable_rule(self, _id: str | int, file: bool = False): + data = self.search_id(int(_id)) + if isinstance(data, BangumiData): + with DownloadClient() as client: + client.remove_rule(data.rule_name) + data.deleted = True + self.update_one(data) + if file: + self.delete_torrents(data, client) + return { + "status": "success", + "msg": f"Disable rule and delete torrents for {data.official_title}", + } + logger.info(f"[Manager] Disable rule for {data.official_title}") + return { + "status": "success", + "msg": f"Disable rule for {data.official_title}", + } + else: + return {"status": "error", "msg": f"Can't find data with id {_id}"} + + def enable_rule(self, _id: str | int): + data = self.search_id(int(_id)) + if isinstance(data, BangumiData): + data.deleted = False + self.update_one(data) + with DownloadClient() as client: + client.set_rule(data) + logger.info(f"[Manager] Enable rule for {data.official_title}") + return { + "status": "success", + "msg": f"Enable rule for {data.official_title}", + } + + 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 id {data.id}") + return {"status": "error", "msg": f"Can't find data with id {data.id}"} + else: + # Set torrent path + match_list = self.__match_torrents_list(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 { + "status": "success", + "msg": f"Set new path for {data.official_title}", + } + + def search_all_bangumi(self): + datas = self.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)) + if not data: + logger.error(f"[Manager] Can't find data with id {_id}") + return {"status": "error", "msg": f"Can't find data with id {_id}"} + else: + return data diff --git a/src/module/models/__init__.py b/src/module/models/__init__.py index 0e595e71..508d68f1 100644 --- a/src/module/models/__init__.py +++ b/src/module/models/__init__.py @@ -1,2 +1,5 @@ from .bangumi import * from .config import Config +from .torrent import EpisodeFile, SubtitleFile +from .user import UserLogin +from .torrent import TorrentBase diff --git a/src/module/models/api.py b/src/module/models/api.py index 6cad39aa..2ff78f8a 100644 --- a/src/module/models/api.py +++ b/src/module/models/api.py @@ -16,4 +16,3 @@ class ChangeConfig(BaseModel): class ChangeRule(BaseModel): rule: dict - diff --git a/src/module/models/bangumi.py b/src/module/models/bangumi.py index f4a607b0..f1c03369 100644 --- a/src/module/models/bangumi.py +++ b/src/module/models/bangumi.py @@ -3,39 +3,31 @@ from dataclasses import dataclass class BangumiData(BaseModel): - id: int | None = Field(None, alias="id", title="番剧ID") - official_title: str = Field(..., alias="official_title", title="番剧中文名") - year: int | None = Field(None, alias="year", title="番剧年份") - title_raw: str = Field(..., alias="title_raw", title="番剧原名") - season: int = Field(..., alias="season", title="番剧季度") + 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: str | None = Field(None, alias="group", 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="字幕") - added: bool = Field(False, alias="added", title="是否已添加") eps_collect: bool = Field(False, alias="eps_collect", title="是否已收集") offset: int = Field(0, alias="offset", title="番剧偏移量") - filter: list[str] = Field(..., alias="filter", 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="番剧保存路径") + deleted: bool = Field(False, alias="deleted", title="是否已删除") -class ProgramData(BaseModel): - rss_link: str = Field(..., alias="rss_link", title="RSS链接") - data_version: float = Field(..., alias="data_version", title="数据版本") - bangumi_info: list[BangumiData] = Field([], alias="bangumi_info", title="番剧信息") - - -@dataclass -class MatchRule: - keyword: str - filter: list - rss_link: str - - -@dataclass -class GroupFilter: - name: str - filter: list +class Notification(BaseModel): + official_title: str = Field(..., alias="official_title", title="番剧名") + season: int = Field(..., alias="season", title="番剧季度") + episode: int = Field(..., alias="episode", title="番剧集数") @dataclass @@ -66,6 +58,3 @@ class SeasonInfo(dict): subtitle: str added: bool eps_collect: bool - - - diff --git a/src/module/models/config.py b/src/module/models/config.py index 1690cafe..6e260c31 100644 --- a/src/module/models/config.py +++ b/src/module/models/config.py @@ -4,8 +4,8 @@ from pydantic import BaseModel, Field class Program(BaseModel): - sleep_time: int = Field(7200, description="Sleep time") - rename_times: int = Field(20, description="Rename times in one loop") + rss_time: int = Field(7200, description="Sleep time") + rename_time: int = Field(60, description="Rename times in one loop") webui_port: int = Field(7892, description="WebUI port") @@ -23,7 +23,7 @@ class RSSParser(BaseModel): type: str = Field("mikan", description="RSS parser type") token: str = Field("token", description="RSS parser token") custom_url: str = Field("mikanani.me", description="Custom RSS host url") - enable_tmdb: bool = Field(False, description="Enable TMDB") + parser_type: str = Field("parser", description="Parser type") filter: list[str] = Field(["720", r"\d+-\d"], description="Filter") language: str = "zh" @@ -57,7 +57,6 @@ class Notification(BaseModel): class Config(BaseModel): - data_version: float = Field(5.0, description="Data version") program: Program = Program() downloader: Downloader = Downloader() rss_parser: RSSParser = RSSParser() diff --git a/src/module/models/torrent.py b/src/module/models/torrent.py index 59ae7bcb..892d66d6 100644 --- a/src/module/models/torrent.py +++ b/src/module/models/torrent.py @@ -1,12 +1,32 @@ from pydantic import BaseModel, Field -class TorrentInfo(BaseModel): +class TorrentBase(BaseModel): name: str = Field(...) - link: str = Field(...) + torrent_link: str = Field(...) + homepage: str | None = Field(None) class FileSet(BaseModel): media_path: str = Field(...) sc_subtitle: str | None = Field(None) - tc_subtitle: str | None = Field(None) \ No newline at end of file + tc_subtitle: str | None = Field(None) + + +class EpisodeFile(BaseModel): + media_path: str = Field(...) + group: str | None = Field(None) + title: str = Field(...) + season: int = Field(...) + episode: int = Field(None) + suffix: str = Field(..., regex=r"\.(mkv|mp4|MKV|MP4)$") + + +class SubtitleFile(BaseModel): + media_path: str = Field(...) + group: str | None = Field(None) + title: str = Field(...) + season: int = Field(...) + episode: int = Field(None) + language: str = Field(..., regex=r"(zh|zh-tw)") + suffix: str = Field(..., regex=r"\.(ass|srt|ASS|SRT)$") diff --git a/src/module/models/user.py b/src/module/models/user.py new file mode 100644 index 00000000..36512642 --- /dev/null +++ b/src/module/models/user.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, Field + + +class User(BaseModel): + 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): + username: str + password: str = Field(..., min_length=8) + + +class Token(BaseModel): + token: str + token_type: str + + +class TokenData(BaseModel): + username: str | None = None diff --git a/src/module/network/__init__.py b/src/module/network/__init__.py index d4589016..aca7b27b 100644 --- a/src/module/network/__init__.py +++ b/src/module/network/__init__.py @@ -1,2 +1 @@ -from .request_contents import RequestContent -from .notification import PostNotification, ServerChanNotification +from .request_contents import RequestContent, TorrentInfo diff --git a/src/module/network/notification.py b/src/module/network/notification.py deleted file mode 100644 index 993c231b..00000000 --- a/src/module/network/notification.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging - -from .request_contents import RequestContent -from module.conf import settings - - -logger = logging.getLogger(__name__) - - -class PostNotification: - def __init__(self): - self.client = self.getClient() - - @staticmethod - def getClient(): - if settings.notification.type.lower() == "telegram": - return TelegramNotification() - elif settings.notification.type.lower() == "server-chan": - return ServerChanNotification() - else: - return None - - def send_msg(self, title: str, desp: str) -> bool: - if not settings.notification.enable: - return False - if self.client is None: - return False - return self.client.send_msg(title, desp) - - -class TelegramNotification: - def __init__(self): - self.token = settings.notification.token - self.chat_id = settings.notification.chat_id - self.notification_url = f"https://api.telegram.org/bot{self.token}/sendMessage" - - def send_msg(self, title: str, desp: str): - data = { - "chat_id": settings.notification.chat_id, - "text": f"{title}\n{desp}", - "disable_notification": True, - } - with RequestContent() as req: - resp = req.post_data(self.notification_url, data) - logger.debug(f"Telegram notification: {resp.status_code}") - return resp.status_code == 200 - - -class ServerChanNotification: - """Server酱推送""" - def __init__(self): - self.token = settings.notification.token - self.notification_url = f"https://sctapi.ftqq.com/{self.token}.send" - - def send_msg(self, title: str, desp: str) -> bool: - data = { - "title": title, - "desp": desp, - } - with RequestContent() as req: - resp = req.post_data(self.notification_url, data) - logger.debug(f"ServerChan notification: {resp.status_code}") - return resp.status_code == 200 diff --git a/src/module/network/request_contents.py b/src/module/network/request_contents.py index 1678390c..b9b2b7af 100644 --- a/src/module/network/request_contents.py +++ b/src/module/network/request_contents.py @@ -2,40 +2,76 @@ import re import xml.etree.ElementTree from dataclasses import dataclass -from .request_url import RequestURL -from module.conf import settings +from bs4 import BeautifulSoup -FILTER = "|".join(settings.rss_parser.filter) +from .request_url import RequestURL +from .site import mikan_parser +from module.conf import settings @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 class RequestContent(RequestURL): # Mikanani RSS - def get_torrents(self, _url: str, _filter: bool = True) -> [TorrentInfo]: - soup = self.get_xml(_url) - torrent_titles = [] - torrent_urls = [] + def get_torrents( + self, + _url: str, + _filter: str = "|".join(settings.rss_parser.filter), + retry: int = 3, + ) -> [TorrentInfo]: + try: + soup = self.get_xml(_url, retry) + torrent_titles, torrent_urls, torrent_homepage = mikan_parser(soup) - for item in soup.findall("./channel/item"): - torrent_titles.append(item.find("title").text) - torrent_urls.append(item.find("enclosure").attrib['url']) + torrents = [] + 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)) + return torrents + except ConnectionError: + return [] - torrents = [] - for _title, torrent_url in zip(torrent_titles, torrent_urls): - if _filter: - if re.search(FILTER, _title) is None: - torrents.append(TorrentInfo(_title, torrent_url)) - else: - torrents.append(TorrentInfo(_title, torrent_url)) - return torrents + 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) -> xml.etree.ElementTree.ElementTree: - return xml.etree.ElementTree.fromstring(self.get_url(_url).text) + def get_xml(self, _url, retry: int = 3) -> xml.etree.ElementTree.Element: + return xml.etree.ElementTree.fromstring(self.get_url(_url, retry).text) # API JSON def get_json(self, _url) -> dict: @@ -52,3 +88,6 @@ class RequestContent(RequestURL): def get_content(self, _url): return self.get_url(_url).content + + def check_connection(self, _url): + return self.check_url(_url) diff --git a/src/module/network/request_url.py b/src/module/network/request_url.py index 8558dc45..85dadb7e 100644 --- a/src/module/network/request_url.py +++ b/src/module/network/request_url.py @@ -12,64 +12,83 @@ logger = logging.getLogger(__name__) class RequestURL: def __init__(self): - self.header = { - "user-agent": "Mozilla/5.0", - "Accept": "application/xml" - } + self.header = {"user-agent": "Mozilla/5.0", "Accept": "application/xml"} - def get_url(self, url): + def get_url(self, url, retry=3): try_time = 0 - while try_time < 5: + while True: try: - req = self.session.get(url=url, headers=self.header) + req = self.session.get(url=url, headers=self.header, timeout=5) req.raise_for_status() return req - except requests.RequestException as e: - logger.debug(f"URL: {url}") - logger.debug(e) - logger.warning(f"Cannot connect to {url}. Wait for 5 seconds.") - logger.warning("Please check DNS/Connection settings") - time.sleep(5) + except requests.RequestException: + logger.warning(f"[Network] Cannot connect to {url}. Wait for 5 seconds.") try_time += 1 + if try_time >= retry: + break + time.sleep(5) except Exception as e: - logger.debug(f"URL: {url}") logger.debug(e) break + logger.error(f"[Network] Failed connecting to {url}") + logger.warning("[Network] Please check DNS/Connection settings") + raise ConnectionError(f"Failed connecting to {url}") - def post_url(self, url: str, data: dict): + def post_url(self, url: str, data: dict, retry=3): try_time = 0 - while try_time < 5: + while True: try: - req = self.session.post(url=url, headers=self.header, data=data) + req = self.session.post( + url=url, headers=self.header, data=data, timeout=5 + ) req.raise_for_status() return req - except requests.RequestException as e: - logger.debug(e) - logger.warning(f"Cannot connect to {url}.") - logger.warning("Please check DNS/Connection settings") - time.sleep(5) + except requests.RequestException: + logger.warning(f"[Network] Cannot connect to {url}. Wait for 5 seconds.") try_time += 1 + if try_time >= retry: + break + time.sleep(5) except Exception as e: - logger.debug(f"URL: {url}") logger.debug(e) break + logger.error(f"[Network] Failed connecting to {url}") + logger.warning("[Network] Please check DNS/Connection settings") + raise ConnectionError(f"Failed connecting to {url}") + + def check_url(self, url: str): + if "://" not in url: + url = f"http://{url}" + try: + req = requests.head(url=url, headers=self.header, timeout=5) + req.raise_for_status() + return True + except requests.RequestException as e: + logger.debug(f"[Network] Cannot connect to {url}.") + return False def __enter__(self): self.session = requests.Session() if settings.proxy.enable: - if settings.proxy.type == "http": - url = f"http://{settings.proxy.host}:{settings.proxy.port}" + if "http" in settings.proxy.type: + url = f"{settings.proxy.type}://{settings.proxy.host}:{settings.proxy.port}" self.session.proxies = { "https": url, "http": url, } elif settings.proxy.type == "socks5": - socks.set_default_proxy(socks.SOCKS5, addr=settings.proxy.host, port=settings.proxy.port, rdns=True, - username=settings.proxy.username, password=settings.proxy.password) + socks.set_default_proxy( + socks.SOCKS5, + addr=settings.proxy.host, + port=settings.proxy.port, + rdns=True, + username=settings.proxy.username, + password=settings.proxy.password, + ) socket.socket = socks.socksocket + else: + logger.error(f"[Network] Unsupported proxy type: {settings.proxy.type}") return self def __exit__(self, exc_type, exc_val, exc_tb): self.session.close() - - diff --git a/src/module/network/site/__init__.py b/src/module/network/site/__init__.py new file mode 100644 index 00000000..04b8a615 --- /dev/null +++ b/src/module/network/site/__init__.py @@ -0,0 +1 @@ +from .mikan import mikan_parser \ No newline at end of file diff --git a/src/module/network/site/mikan.py b/src/module/network/site/mikan.py new file mode 100644 index 00000000..5a326925 --- /dev/null +++ b/src/module/network/site/mikan.py @@ -0,0 +1,9 @@ +def mikan_parser(soup): + torrent_titles = [] + torrent_urls = [] + torrent_homepage = [] + for item in soup.findall("./channel/item"): + torrent_titles.append(item.find("title").text) + torrent_urls.append(item.find("enclosure").attrib["url"]) + torrent_homepage.append(item.find("link").text) + return torrent_titles, torrent_urls, torrent_homepage \ No newline at end of file diff --git a/src/module/notification/__init__.py b/src/module/notification/__init__.py new file mode 100644 index 00000000..dbd1ac93 --- /dev/null +++ b/src/module/notification/__init__.py @@ -0,0 +1 @@ +from .notification import PostNotification \ No newline at end of file diff --git a/src/module/notification/notification.py b/src/module/notification/notification.py new file mode 100644 index 00000000..4181ac02 --- /dev/null +++ b/src/module/notification/notification.py @@ -0,0 +1,63 @@ +import logging + +from .plugin import * + +from module.models import Notification +from module.conf import settings +from module.database import BangumiDatabase + + +logger = logging.getLogger(__name__) + + +def getClient(type=settings.notification.type): + if type.lower() == "telegram": + return TelegramNotification + elif type.lower() == "server-chan": + return ServerChanNotification + elif type.lower() == "bark": + return BarkNotification + else: + return None + + +class PostNotification(getClient()): + def __init__(self): + super().__init__( + token=settings.notification.token, + chat_id=settings.notification.chat_id + ) + + @staticmethod + def _gen_message(notify: Notification) -> str: + with BangumiDatabase() as db: + poster_path = db.match_poster(notify.official_title) + if poster_path: + poster_link = "https://mikanani.me" + poster_path + text = f""" + 番剧名称:{notify.official_title}\n季度: 第{notify.season}季\n更新集数: 第{notify.episode}集\n{poster_link}\n + """ + else: + text = f""" + 番剧名称:{notify.official_title}\n季度: 第{notify.season}季\n更新集数: 第{notify.episode}集\n + """ + return text + + def send_msg(self, notify: Notification) -> bool: + text = self._gen_message(notify) + try: + self.post_msg(text) + logger.debug(f"Send notification: {notify.official_title}") + except Exception as e: + logger.warning(f"Failed to send notification: {e}") + return False + + +if __name__ == "__main__": + info = Notification( + official_title="魔法纪录 魔法少女小圆外传", + season=2, + episode=1, + ) + with PostNotification() as client: + client.send_msg(info) diff --git a/src/module/notification/plugin/__init__.py b/src/module/notification/plugin/__init__.py new file mode 100644 index 00000000..106087f6 --- /dev/null +++ b/src/module/notification/plugin/__init__.py @@ -0,0 +1,3 @@ +from .bark import BarkNotification +from .server_chan import ServerChanNotification +from .telegram import TelegramNotification \ No newline at end of file diff --git a/src/module/notification/plugin/bark.py b/src/module/notification/plugin/bark.py new file mode 100644 index 00000000..f405ab4b --- /dev/null +++ b/src/module/notification/plugin/bark.py @@ -0,0 +1,18 @@ +import logging +from module.network import RequestContent + + +logger = logging.getLogger(__name__) + + +class BarkNotification(RequestContent): + def __init__(self, token, **kwargs): + super().__init__() + self.token = token + self.notification_url = "https://api.day.app/push" + + def post_msg(self, text) -> bool: + data = {"title": "AutoBangumi 番剧更新", "body": text, "device_key": self.token} + resp = self.post_data(self.notification_url, data) + logger.debug(f"Bark notification: {resp.status_code}") + return resp.status_code == 200 diff --git a/src/module/notification/plugin/server_chan.py b/src/module/notification/plugin/server_chan.py new file mode 100644 index 00000000..5b4b32d0 --- /dev/null +++ b/src/module/notification/plugin/server_chan.py @@ -0,0 +1,22 @@ +import logging + +from module.network import RequestContent + +logger = logging.getLogger(__name__) + + +class ServerChanNotification(RequestContent): + """Server酱推送""" + + def __init__(self, token, **kwargs): + super().__init__() + self.notification_url = f"https://sctapi.ftqq.com/{token}.send" + + def post_msg(self, text: str) -> bool: + data = { + "title": "AutoBangumi 番剧更新", + "desp": text, + } + resp = self.post_data(self.notification_url, data) + logger.debug(f"ServerChan notification: {resp.status_code}") + return resp.status_code == 200 diff --git a/src/module/notification/plugin/slack.py b/src/module/notification/plugin/slack.py new file mode 100644 index 00000000..1773e85d --- /dev/null +++ b/src/module/notification/plugin/slack.py @@ -0,0 +1,18 @@ +import logging + +from module.network import RequestContent + +logger = logging.getLogger(__name__) + + +class SlackNotification(RequestContent): + def __init__(self, token, **kwargs): + super().__init__() + self.token = token + self.notification_url = "https://api.day.app/push" + + def post_msg(self, text) -> bool: + data = {"title": "AutoBangumi 番剧更新", "body": text, "device_key": self.token} + resp = self.post_data(self.notification_url, data) + logger.debug(f"Bark notification: {resp.status_code}") + return resp.status_code == 200 \ No newline at end of file diff --git a/src/module/notification/plugin/telegram.py b/src/module/notification/plugin/telegram.py new file mode 100644 index 00000000..71e90c16 --- /dev/null +++ b/src/module/notification/plugin/telegram.py @@ -0,0 +1,22 @@ +import logging + +from module.network.request_contents import RequestContent + +logger = logging.getLogger(__name__) + + +class TelegramNotification(RequestContent): + def __init__(self, token, chat_id): + super().__init__() + self.notification_url = f"https://api.telegram.org/bot{token}/sendMessage" + self.chat_id = chat_id + + def post_msg(self, text: str) -> bool: + data = { + "chat_id": self.chat_id, + "text": text, + "disable_notification": True, + } + resp = self.post_data(self.notification_url, data) + logger.debug(f"Telegram notification: {resp.status_code}") + return resp.status_code == 200 diff --git a/src/module/parser/analyser/__init__.py b/src/module/parser/analyser/__init__.py index a4cb6896..433a62d7 100644 --- a/src/module/parser/analyser/__init__.py +++ b/src/module/parser/analyser/__init__.py @@ -1,4 +1,3 @@ from .raw_parser import raw_parser from .torrent_parser import torrent_parser -from .tmdb_parser import TMDBMatcher - +from .tmdb_parser import tmdb_parser diff --git a/src/module/parser/analyser/bgm_parser.py b/src/module/parser/analyser/bgm_parser.py index 427cda11..af345270 100644 --- a/src/module/parser/analyser/bgm_parser.py +++ b/src/module/parser/analyser/bgm_parser.py @@ -1,17 +1,14 @@ from module.network import RequestContent -class BgmAPI: - def __init__(self): - self.search_url = lambda e: \ - f"https://api.bgm.tv/search/subject/{e}?type=2" - self.info_url = lambda e: \ - f"https://api.bgm.tv/subject/{e}" +search_url = lambda e: f"https://api.bgm.tv/search/subject/{e}?responseGroup=large" - def search(self, title): - url = self.search_url(title) - with RequestContent() as req: - contents = req.get_json(url)["list"] - if contents.__len__() == 0: - return None - return contents[0]["name"], contents[0]["name_cn"] + +def bgm_parser(title): + url = search_url(title) + with RequestContent() as req: + contents = req.get_json(url) + if contents: + return contents[0] + else: + return None diff --git a/src/module/parser/analyser/raw_parser.py b/src/module/parser/analyser/raw_parser.py index af89cfe5..ed630b25 100644 --- a/src/module/parser/analyser/raw_parser.py +++ b/src/module/parser/analyser/raw_parser.py @@ -108,6 +108,7 @@ def name_process(name: str): name_en = item.strip() return name_en, name_zh, name_jp + def find_tags(other): elements = re.sub(r"[\[\]()()]", " ", other).split(" ") # find CHT @@ -136,9 +137,9 @@ def process(raw_title: str): # 翻译组的名字 match_obj = TITLE_RE.match(content_title) # 处理标题 - season_info, episode_info, other = list(map( - lambda x: x.strip(), match_obj.groups() - )) + season_info, episode_info, other = list( + map(lambda x: x.strip(), match_obj.groups()) + ) process_raw = prefix_process(season_info, group) # 处理 前缀 raw_name, season_raw, season = season_process(process_raw) @@ -155,7 +156,18 @@ def process(raw_title: str): if raw_episode is not None: episode = int(raw_episode.group()) sub, dpi, source = find_tags(other) # 剩余信息处理 - return name_en, name_zh, name_jp, season, season_raw, episode, sub, dpi, source, group + return ( + name_en, + name_zh, + name_jp, + season, + season_raw, + episode, + sub, + dpi, + source, + group, + ) def raw_parser(raw: str) -> Episode | None: @@ -163,14 +175,7 @@ def raw_parser(raw: str) -> Episode | None: if ret is None: logger.error(f"Parser cannot analyse {raw}") return None - name_en, name_zh, name_jp, season, sr, episode, \ - sub, dpi, source, group = ret - return Episode(name_en, name_zh, name_jp, season, sr, episode, sub, group, dpi, source) - - -if __name__ == '__main__': - title = "【幻樱字幕组】【4月新番】【古见同学有交流障碍症 第二季 Komi-san wa, Komyushou Desu. S02】【22】【GB_MP4】【1920X1080】" - ep = raw_parser(title) - print(ep) - - + name_en, name_zh, name_jp, season, sr, episode, sub, dpi, source, group = ret + return Episode( + name_en, name_zh, name_jp, season, sr, episode, sub, group, dpi, source + ) diff --git a/src/module/parser/analyser/tmdb_parser.py b/src/module/parser/analyser/tmdb_parser.py index c57ed405..65f8f8eb 100644 --- a/src/module/parser/analyser/tmdb_parser.py +++ b/src/module/parser/analyser/tmdb_parser.py @@ -9,65 +9,70 @@ from module.conf import TMDB_API @dataclass class TMDBInfo: id: int - title_jp: str - title_zh: str + title: str + original_title: str season: list[dict] last_season: int - year_number: int + year: str -class TMDBMatcher: - def __init__(self): - self.search_url = lambda e: \ - f"https://api.themoviedb.org/3/search/tv?api_key={TMDB_API}&page=1&query={e}&include_adult=false" - self.info_url = lambda e: \ - f"https://api.themoviedb.org/3/tv/{e}?api_key={TMDB_API}&language=zh-CN" +LANGUAGE = { + "zh": "zh-CN", + "jp": "ja-JP", + "en": "en-US" +} - def is_animation(self, tv_id) -> bool: - url_info = self.info_url(tv_id) - with RequestContent() as req: - type_id = req.get_json(url_info)["genres"] - for type in type_id: - if type.get("id") == 16: - return True - return False +search_url = lambda e: \ + f"https://api.themoviedb.org/3/search/tv?api_key={TMDB_API}&page=1&query={e}&include_adult=false" +info_url = lambda e, key: \ + f"https://api.themoviedb.org/3/tv/{e}?api_key={TMDB_API}&language={LANGUAGE[key]}" - # def get_zh_title(self, id): - # alt_title_url = self.alt_title_url(id) - # titles = self._request.get_content(alt_title_url, content="json") - # for title in titles: - # if title["iso_3166_1"] == "CN": - # return title["title"] - # return None - @staticmethod - def get_season(seasons: list) -> int: - for season in seasons: - 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]) +def is_animation(tv_id, language) -> bool: + url_info = info_url(tv_id, language) + with RequestContent() as req: + type_id = req.get_json(url_info)["genres"] + for type in type_id: + if type.get("id") == 16: + return True + return False - def tmdb_search(self, title) -> TMDBInfo: - with RequestContent() as req: - url = self.search_url(title) + +def get_season(seasons: list) -> int: + for season in seasons: + 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]) + + +def tmdb_parser(title, language) -> TMDBInfo | None: + with RequestContent() as req: + url = search_url(title) + contents = req.get_json(url).get("results") + if contents.__len__() == 0: + url = search_url(title.replace(" ", "")) contents = req.get_json(url).get("results") - if contents.__len__() == 0: - url = self.search_url(title.replace(" ", "")) - contents = req.get_json(url).get("results") - # 判断动画 + # 判断动画 + if contents: for content in contents: id = content["id"] - if self.is_animation(id): + if is_animation(id, language): break - url_info = self.info_url(id) + url_info = info_url(id, language) info_content = req.get_json(url_info) + season = [{"season": s.get("name"), "air_date": s.get("air_date"), "poster_path": s.get("poster_path")} for s in info_content.get("seasons")] + last_season = get_season(season) + original_title = info_content.get("original_name") + official_title = info_content.get("name") + year_number = info_content.get("first_air_date").split("-")[0] + return TMDBInfo(id, official_title, original_title, season, last_season, str(year_number)) + else: + return None - season = [{"season": s.get("name"), "air_date": s.get("air_date")} for s in info_content.get("seasons")] - last_season = self.get_season(season) - title_jp = info_content.get("original_name") - title_zh = info_content.get("name") - year_number = info_content.get("first_air_date").split("-")[0] - return TMDBInfo(id, title_jp, title_zh, season, last_season, year_number) \ No newline at end of file + +if __name__ == '__main__': + title = "鬼灭之刃" + print(tmdb_parser(title, "zh")) \ No newline at end of file diff --git a/src/module/parser/analyser/torrent_parser.py b/src/module/parser/analyser/torrent_parser.py index e6a05165..fab6e485 100644 --- a/src/module/parser/analyser/torrent_parser.py +++ b/src/module/parser/analyser/torrent_parser.py @@ -1,21 +1,16 @@ import re import logging -from dataclasses import dataclass +import os.path as unix_path +import ntpath as win_path + +from module.models import EpisodeFile, SubtitleFile logger = logging.getLogger(__name__) - -@dataclass -class DownloadInfo: - name: str - season: int - suffix: str - file_name: str - folder_name: str - +PLATFORM = "Unix" RULES = [ - r"(.*) - (\d{1,4}|\d{1,4}\.\d{1,2})(?:v\d{1,2})?(?: )?(?:END)?(.*)", + r"(.*) - (\d{1,4}(?!\d|p)|\d{1,4}\.\d{1,2}(?!\d|p))(?:v\d{1,2})?(?: )?(?:END)?(.*)", r"(.*)[\[\ E](\d{1,4}|\d{1,4}\.\d{1,2})(?:v\d{1,2})?(?: )?(?:END)?[\]\ ](.*)", r"(.*)\[(?:第)?(\d*\.*\d*)[话集話](?:END)?\](.*)", r"(.*)第(\d*\.*\d*)[话話集](?:END)?(.*)", @@ -28,145 +23,78 @@ SUBTITLE_LANG = { } -def rename_init(name, folder_name, season, suffix) -> DownloadInfo: - n = re.split(r"[\[\]()【】()]", name) - suffix = suffix if suffix else n[-1] - if len(n) > 1: - file_name = name.replace(f"[{n[1]}]", "") +def split_path(torrent_path: str) -> str: + if PLATFORM == "Windows": + return win_path.split(torrent_path)[-1] else: - file_name = name - if season < 10: - season = f"0{season}" - return DownloadInfo(name, season, suffix, file_name, folder_name) + return unix_path.split(torrent_path)[-1] -def rename_normal(info: DownloadInfo): - for rule in RULES: - match_obj = re.match(rule, info.name, re.I) - if match_obj is not None: - episode = match_obj.group(2) - title = re.sub(r"([Ss]|Season )\d{1,3}", "", match_obj.group(1)).strip() - new_name = f"{title} S{info.season}E{episode}{match_obj.group(3)}" - return new_name +def get_group(group_and_title) -> tuple[str | None, str]: + n = re.split(r"[\[\]()【】()]", group_and_title) + while "" in n: + n.remove("") + if len(n) > 1: + if re.match(r"\d+", n[1]): + return None, group_and_title + return n[0], n[1] + else: + return None, n[0] -def rename_pn(info: DownloadInfo): - for rule in RULES: - match_obj = re.match(rule, info.file_name, re.I) - if match_obj is not None: - title = re.sub(r"([Ss]|Season )\d{1,3}", "", match_obj.group(1)).strip() - title = title if title != "" else info.folder_name - episode = match_obj.group(2) - new_name = re.sub( - r"[\[\]]", - "", - f"{title} S{info.season}E{episode}{info.suffix}", - ) - return new_name +def get_season_and_title(season_and_title) -> tuple[str, int]: + title = re.sub(r"([Ss]|Season )\d{1,3}", "", season_and_title).strip() + try: + season = re.search(r"([Ss]|Season )(\d{1,3})", season_and_title, re.I).group(2) + except AttributeError: + season = 1 + return title, int(season) -def rename_advance(info: DownloadInfo): - for rule in RULES: - match_obj = re.match(rule, info.file_name, re.I) - if match_obj is not None: - episode = match_obj.group(2) - new_name = re.sub( - r"[\[\]]", - "", - f"{info.folder_name} S{info.season}E{episode}{info.suffix}", - ) - return new_name - - -def rename_no_season_pn(info: DownloadInfo): - for rule in RULES: - match_obj = re.match(rule, info.file_name, re.I) - if match_obj is not None: - title = match_obj.group(1).strip() - episode = match_obj.group(2) - new_name = re.sub( - r"[\[\]]", - "", - f"{title} E{episode}{info.suffix}", - ) - return new_name - - -def rename_none(info: DownloadInfo): - return info.name - - -def rename_subtitle(info: DownloadInfo): - subtitle_lang = "zh" - break_flag = False +def get_subtitle_lang(subtitle_name: str) -> str: for key, value in SUBTITLE_LANG.items(): - for lang in value: - if lang in info.name: - subtitle_lang = key - break_flag = True - break - if break_flag: - break - for rule in RULES: - match_obj = re.match(rule, info.file_name, re.I) - if match_obj is not None: - title = re.sub(r"([Ss]|Season )\d{1,3}", "", match_obj.group(1)).strip() - title = title if title != "" else info.folder_name - new_name = re.sub( - r"[\[\]]", - "", - f"{title} S{info.season}E{match_obj.group(2)}.{subtitle_lang}{info.suffix}", - ) - return new_name - - -def rename_subtitle_advance(info: DownloadInfo): - subtitle_lang = "zh" - break_flag = False - for key, value in SUBTITLE_LANG.items(): - for lang in value: - if lang in info.name: - subtitle_lang = key - break_flag = True - break - if break_flag: - break - for rule in RULES: - match_obj = re.match(rule, info.file_name, re.I) - if match_obj is not None: - new_name = re.sub( - r"[\[\]]", - "", - f"{info.folder_name} S{info.season}E{match_obj.group(2)}.{subtitle_lang}{info.suffix}", - ) - return new_name - - -METHODS = { - "normal": rename_normal, - "pn": rename_pn, - "advance": rename_advance, - "no_season_pn": rename_no_season_pn, - "none": rename_none, - "subtitle_pn": rename_subtitle, - "subtitle_advance": rename_subtitle_advance, -} + for v in value: + if v in subtitle_name: + return key def torrent_parser( - file_name: str, - folder_name: str, - season: int, - suffix: str, - method: str = "pn", -): - info = rename_init(file_name, folder_name, season, suffix) - return METHODS[method.lower()](info) - - -if __name__ == '__main__': - title = "海盗战记 S02E17.zh.ass" - folder_name = "海盗战记" - season = 2 - suffix = ".ass" - print(torrent_parser(title, folder_name, season, suffix, method="advance")) \ No newline at end of file + torrent_path: str, + torrent_name: str | None = None, + season: int | None = None, + file_type: str = "media", +) -> EpisodeFile | SubtitleFile: + media_path = split_path(torrent_path) + for rule in RULES: + if torrent_name: + match_obj = re.match(rule, torrent_name, re.I) + else: + match_obj = re.match(rule, media_path, re.I) + if match_obj: + group, title = get_group(match_obj.group(1)) + if not season: + title, season = get_season_and_title(title) + else: + title, _ = get_season_and_title(title) + episode = int(match_obj.group(2)) + suffix = unix_path.splitext(torrent_path)[-1] + if file_type == "media": + return EpisodeFile( + media_path=torrent_path, + group=group, + title=title, + season=season, + episode=episode, + suffix=suffix, + ) + elif file_type == "subtitle": + language = get_subtitle_lang(media_path) + return SubtitleFile( + media_path=torrent_path, + group=group, + title=title, + season=season, + language=language, + episode=episode, + suffix=suffix, + ) diff --git a/src/module/parser/openai.py b/src/module/parser/openai.py index 139597f9..8b137891 100644 --- a/src/module/parser/openai.py +++ b/src/module/parser/openai.py @@ -1,2 +1 @@ - diff --git a/src/module/parser/title_parser.py b/src/module/parser/title_parser.py index c752e611..1f1ffd4b 100644 --- a/src/module/parser/title_parser.py +++ b/src/module/parser/title_parser.py @@ -1,86 +1,73 @@ import logging -from .analyser import raw_parser, torrent_parser, TMDBMatcher +from .analyser import raw_parser, torrent_parser, tmdb_parser -from module.models import BangumiData, Config +from module.models import BangumiData +from module.conf import settings logger = logging.getLogger(__name__) class TitleParser: def __init__(self): - self._tmdb_parser = TMDBMatcher() + pass @staticmethod def torrent_parser( - method: str, - torrent_name: str, - bangumi_name: str | None = None, - season: int | None = None, - suffix: str | None = None, + torrent_path: str, + torrent_name: str | None = None, + season: int | None = None, + file_type: str = "media", ): - return torrent_parser(torrent_name, bangumi_name, season, suffix, method) - - def tmdb_parser(self, title: str, season: int, language: str): - official_title, tmdb_season = None, None try: - tmdb_info = self._tmdb_parser.tmdb_search(title) - logger.debug(f"TMDB Matched, official title is {tmdb_info.title_zh}") + return torrent_parser(torrent_path, torrent_name, season, file_type) except Exception as e: - logger.debug(e) - logger.warning(f"{title} can not Matched with TMDB") - logger.info("Please change the bangumi info in webui") - return title, season - if language == "zh": - official_title = f"{tmdb_info.title_zh} ({tmdb_info.year_number})" - elif language == "jp": - official_title = f"{tmdb_info.title_jp} ({tmdb_info.year_number})" - tmdb_season = tmdb_info.last_season if tmdb_info.last_season else season - official_title = official_title if official_title else title - return official_title, tmdb_season + logger.warning(f"Cannot parse {torrent_path} with error {e}") - def raw_parser( - self, - raw: str, - settings: Config, - _id: int | None = None - ) -> BangumiData: + @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 + 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 + + @staticmethod + def raw_parser(raw: str, rss_link: str) -> BangumiData | None: language = settings.rss_parser.language try: episode = raw_parser(raw) titles = { "zh": episode.title_zh, "en": episode.title_en, - "jp": episode.title_jp + "jp": episode.title_jp, } - title_search = episode.title_zh if episode.title_zh else episode.title_en title_raw = episode.title_en if episode.title_en else episode.title_zh - if settings.rss_parser.enable_tmdb: - official_title, _season = self.tmdb_parser( - title_search, - episode.season, - language - ) - else: - official_title = titles[language] if titles[language] else titles["zh"] - _season = episode.season + official_title = titles[language] if titles[language] else titles["zh"] + _season = episode.season data = BangumiData( - id=_id, official_title=official_title, title_raw=title_raw, season=_season, season_raw=episode.season_raw, - group=episode.group, + group_name=episode.group, dpi=episode.resolution, source=episode.source, subtitle=episode.sub, - added=False, - eps_collect=True if episode.episode > 1 else False, + eps_collect=False if episode.episode > 1 else True, offset=0, - filter=settings.rss_parser.filter + filter=settings.rss_parser.filter, + rss_link=[rss_link], ) - logger.debug(f"RAW:{raw} >> {episode.title_en}") + logger.debug(f"RAW:{raw} >> {title_raw}") return data except Exception as e: logger.debug(e) - print(e) + logger.warning(f"Cannot parse {raw}.") + return None diff --git a/src/module/rss/__init__.py b/src/module/rss/__init__.py index f7c3f4ee..0b9167ea 100644 --- a/src/module/rss/__init__.py +++ b/src/module/rss/__init__.py @@ -1 +1,4 @@ -from .rss_analyser import RSSAnalyser +from .analyser import RSSAnalyser + + +analyser = RSSAnalyser() diff --git a/src/module/rss/analyser.py b/src/module/rss/analyser.py new file mode 100644 index 00000000..9ae35b37 --- /dev/null +++ b/src/module/rss/analyser.py @@ -0,0 +1,96 @@ +import re +import logging + +from module.network import RequestContent, TorrentInfo +from module.parser import TitleParser +from module.models import BangumiData +from module.database import BangumiDatabase +from module.conf import settings + +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 + ) + data.official_title = tmdb_title + data.year = year + data.season = season + else: + pass + data.official_title = re.sub(r"[/:.\\]", " ", data.official_title) + + @staticmethod + def get_rss_torrents(rss_link: str, full_parse: bool = True) -> list: + with RequestContent() as req: + if full_parse: + rss_torrents = req.get_torrents(rss_link) + else: + rss_torrents = req.get_torrents(rss_link, "\\d+-\\d+") + return rss_torrents + + def torrents_to_data( + self, torrents: list, rss_link: str, 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) + if not full_parse: + return [data] + new_data.append(data) + logger.debug(f"[RSS] New title found: {data.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 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) + 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) + if new_data: + return new_data + else: + return [] + + def link_to_data(self, link: str) -> BangumiData: + torrents = self.get_rss_torrents(link, False) + for torrent in torrents: + data = self.torrent_to_data(torrent, link) + if data: + return data diff --git a/src/module/rss/filter.py b/src/module/rss/filter.py new file mode 100644 index 00000000..8255dbde --- /dev/null +++ b/src/module/rss/filter.py @@ -0,0 +1,57 @@ +import logging + +from module.database import BangumiDatabase +from module.downloader import DownloadClient +from module.network import RequestContent +from module.conf import settings +from module.models import BangumiData + +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/src/module/rss/rss_analyser.py b/src/module/rss/rss_analyser.py deleted file mode 100644 index b1174cbe..00000000 --- a/src/module/rss/rss_analyser.py +++ /dev/null @@ -1,68 +0,0 @@ -import re -import logging - -from module.network import RequestContent -from module.parser import TitleParser -from module.core import DownloadClient -from module.models import BangumiData, Config - -logger = logging.getLogger(__name__) - - -class RSSAnalyser: - def __init__(self, settings: Config): - self._title_analyser = TitleParser() - self.settings = settings - - @staticmethod - def find_id(bangumi_info: list[BangumiData]) -> int: - _id = 0 - for info in bangumi_info: - if info.id > _id: - _id = info.id - return _id - - def rss_to_datas(self, bangumi_info: list[BangumiData], rss_link: str) -> list[BangumiData]: - with RequestContent() as req: - rss_torrents = req.get_torrents(rss_link) - # Find largest bangumi id - _id = self.find_id(bangumi_info) - for torrent in rss_torrents: - raw_title = torrent.name - extra_add = True - if bangumi_info is not []: - for info in bangumi_info: - if re.search(info.title_raw, raw_title) is not None: - logger.debug(f"Had added {info.official_title} in auto_download rule before") - extra_add = False - break - if extra_add: - _id += 1 - data = self._title_analyser.raw_parser( - raw=raw_title, - _id=_id, - settings=self.settings) - if data is not None and data.official_title not in bangumi_info: - bangumi_info.append(data) - return bangumi_info - - def rss_to_data(self, url, _filter: bool = True) -> BangumiData: - with RequestContent() as req: - rss_torrents = req.get_torrents(url, _filter) - for torrent in rss_torrents: - try: - data = self._title_analyser.raw_parser( - torrent.name, - settings=self.settings - ) - return data - except Exception as e: - logger.debug(e) - - def run(self, bangumi_info: list[BangumiData], rss_link: str): - logger.info("Start collecting RSS info.") - try: - self.rss_to_datas(bangumi_info, rss_link) - except Exception as e: - logger.debug(e) - logger.info("Finished") diff --git a/src/module/rss/searcher.py b/src/module/rss/searcher.py index f1d85507..0d547bd2 100644 --- a/src/module/rss/searcher.py +++ b/src/module/rss/searcher.py @@ -3,7 +3,6 @@ from module.conf import settings class RSSSearcher(RequestContent): - def __search_url(self, keywords: str) -> str: keywords.replace(" ", "+") url = f"{settings.rss_parser.custom_url}/RSS/Search?keyword={keywords}" diff --git a/src/module/searcher/__init__.py b/src/module/searcher/__init__.py new file mode 100644 index 00000000..534573fe --- /dev/null +++ b/src/module/searcher/__init__.py @@ -0,0 +1 @@ +from .searcher import SearchTorrent diff --git a/src/module/searcher/plugin/__init__.py b/src/module/searcher/plugin/__init__.py new file mode 100644 index 00000000..47852a16 --- /dev/null +++ b/src/module/searcher/plugin/__init__.py @@ -0,0 +1,8 @@ +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/src/module/searcher/plugin/mikan.py b/src/module/searcher/plugin/mikan.py new file mode 100644 index 00000000..947066c8 --- /dev/null +++ b/src/module/searcher/plugin/mikan.py @@ -0,0 +1,12 @@ +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/src/module/searcher/searcher.py b/src/module/searcher/searcher.py new file mode 100644 index 00000000..621022eb --- /dev/null +++ b/src/module/searcher/searcher.py @@ -0,0 +1,39 @@ +from module.searcher.plugin import search_url +from module.network import RequestContent +from module.models import BangumiData, TorrentBase + +SEARCH_KEY = [ + "group_name", + "title_raw", + "season_raw", + "subtitle", + "source", + "dpi", +] + + +class SearchTorrent(RequestContent): + 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) + + def to_dict(): + for torrent in torrents: + yield { + "name": torrent.name, + "torrent_link": torrent.torrent_link, + "homepage": torrent.homepage, + } + return [TorrentBase(**d) for d in to_dict()] + + def search_season(self, data: BangumiData): + 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] + + +if __name__ == '__main__': + with SearchTorrent() as st: + for t in st.search_torrents(["魔法科高校の劣等生"]): + print(t) \ No newline at end of file diff --git a/src/module/security/__init__.py b/src/module/security/__init__.py new file mode 100644 index 00000000..28f5d20e --- /dev/null +++ b/src/module/security/__init__.py @@ -0,0 +1,2 @@ +from .jwt import create_access_token +from .api import get_current_user, get_token_data, auth_user, update_user_info diff --git a/src/module/security/api.py b/src/module/security/api.py new file mode 100644 index 00000000..13069b72 --- /dev/null +++ b/src/module/security/api.py @@ -0,0 +1,57 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +from .jwt import verify_token + +from module.database.user import AuthDB +from module.models.user import User + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +async def get_current_user(token: str = Depends(oauth2_scheme)): + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + ) + payload = verify_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + ) + 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 + + +async def get_token_data(token: str = Depends(oauth2_scheme)): + payload = verify_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token" + ) + return payload + + +def update_user_info(user_data: User, current_user): + try: + with AuthDB() as db: + db.update_user(current_user.username, 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: + if not db.auth_user(username, password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid username or password", + ) diff --git a/src/module/security/jwt.py b/src/module/security/jwt.py new file mode 100644 index 00000000..23f711fd --- /dev/null +++ b/src/module/security/jwt.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta +from passlib.context import CryptContext +from jose import jwt, JWTError + + +app_pwd_key = "auto_bangumi" +app_pwd_algorithm = "HS256" + +# Hashing 密码 +app_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +# 创建 JWT Token +def create_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=1440) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, app_pwd_key, algorithm=app_pwd_algorithm) + return encoded_jwt + + +# 解码 Token +def decode_token(token: str): + try: + payload = jwt.decode(token, app_pwd_key, algorithms=[app_pwd_algorithm]) + username = payload.get("sub") + if username is None: + return None + return payload + except JWTError: + return None + + +def verify_token(token: str): + token_data = decode_token(token) + if token_data is None: + return None + expires = token_data.get("exp") + if datetime.utcnow() >= datetime.fromtimestamp(expires): + raise JWTError("Token expired") + return token_data + + +# 密码加密&验证 +def verify_password(plain_password, hashed_password): + return app_pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password): + return app_pwd_context.hash(password) diff --git a/src/module/update/__init__.py b/src/module/update/__init__.py new file mode 100644 index 00000000..0044a5be --- /dev/null +++ b/src/module/update/__init__.py @@ -0,0 +1 @@ +from .data_migration import data_migration diff --git a/src/module/update/data_migration.py b/src/module/update/data_migration.py new file mode 100644 index 00000000..21dd72f6 --- /dev/null +++ b/src/module/update/data_migration.py @@ -0,0 +1,20 @@ +import os + +from module.models import BangumiData +from module.utils import json_config +from module.database import BangumiDatabase + + +def data_migration(): + if not os.path.isfile("data/data.json"): + return False + old_data = json_config.load("data/data.json") + infos = old_data["bangumi_info"] + 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) + os.remove("data/data.json") diff --git a/src/module/utils/__init__.py b/src/module/utils/__init__.py index edd834d9..8b137891 100644 --- a/src/module/utils/__init__.py +++ b/src/module/utils/__init__.py @@ -1 +1 @@ -from .bangumi_data import load_program_data, save_program_data \ No newline at end of file + diff --git a/src/module/utils/bangumi_data.py b/src/module/utils/bangumi_data.py index 248e6d93..d688be50 100644 --- a/src/module/utils/bangumi_data.py +++ b/src/module/utils/bangumi_data.py @@ -1,27 +1,5 @@ import logging from .json_config import save, load -from module.models import ProgramData logger = logging.getLogger(__name__) - - -def load_program_data(path: str) -> ProgramData: - data = load(path) - try: - data = ProgramData(**data) - logger.info("Data file loaded") - except Exception as e: - logger.warning("Data file is not compatible with the current version, rebuilding...") - logger.debug(e) - data = ProgramData( - rss_link=data["rss_link"], - data_version=data["data_version"], - bangumi_info=[] - ) - return data - - -def save_program_data(path: str, data: ProgramData): - save(path, data.dict()) - logger.debug("Data file saved") diff --git a/src/module/utils/json_config.py b/src/module/utils/json_config.py index cb520f07..fdc974ca 100644 --- a/src/module/utils/json_config.py +++ b/src/module/utils/json_config.py @@ -15,4 +15,4 @@ def save(filename, obj): def get(url): req = requests.get(url) - return req.json() \ No newline at end of file + return req.json() diff --git a/src/test/test_raw_parser.py b/src/test/test_raw_parser.py index b4c3b446..f09f1aae 100644 --- a/src/test/test_raw_parser.py +++ b/src/test/test_raw_parser.py @@ -69,3 +69,4 @@ def test_raw_parser(): assert info.resolution == "720P" assert info.episode == 5 assert info.season == 1 + diff --git a/src/test/test_torrent_parser.py b/src/test/test_torrent_parser.py index 5252e570..54832c7f 100644 --- a/src/test/test_torrent_parser.py +++ b/src/test/test_torrent_parser.py @@ -3,59 +3,67 @@ from module.parser.analyser import torrent_parser def test_torrent_parser(): file_name = "[Lilith-Raws] Boku no Kokoro no Yabai Yatsu - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4" - folder_name = "我内心的糟糕念头(2023)" - season = 1 - suffix = ".mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "pn") == "Boku no Kokoro no Yabai Yatsu S01E01.mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "advance") == "我内心的糟糕念头(2023) S01E01.mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "none") == "[Lilith-Raws] Boku no Kokoro no Yabai Yatsu - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4" + bf = torrent_parser(file_name) + assert bf.title == "Boku no Kokoro no Yabai Yatsu" + assert bf.group == "Lilith-Raws" + assert bf.episode == 1 + assert bf.season == 1 file_name = "[Sakurato] Tonikaku Kawaii S2 [01][AVC-8bit 1080p AAC][CHS].mp4" - folder_name = "总之就是非常可爱(2021)" - season = 2 - suffix = ".mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "pn") == "Tonikaku Kawaii S02E01.mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "advance") == "总之就是非常可爱(2021) S02E01.mp4" + bf = torrent_parser(file_name) + assert bf.title == "Tonikaku Kawaii" + assert bf.group == "Sakurato" + assert bf.episode == 1 + assert bf.season == 2 file_name = "[SweetSub&LoliHouse] Heavenly Delusion - 01 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv" - folder_name = "天国大魔境(2023)" - season = 1 - suffix = ".mkv" - assert torrent_parser(file_name, folder_name, season, suffix, "pn") == "Heavenly Delusion S01E01.mkv" - assert torrent_parser(file_name, folder_name, season, suffix, "advance") == "天国大魔境(2023) S01E01.mkv" - - file_name = "[SBSUB][Kanojo mo Kanojo][01][GB][1080P](456E234).mp4" - folder_name = "女友也要有" - season = 1 - suffix = ".mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "pn") == "Kanojo mo Kanojo S01E01.mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "advance") == "女友也要有 S01E01.mp4" + bf = torrent_parser(file_name) + assert bf.title == "Heavenly Delusion" + assert bf.group == "SweetSub&LoliHouse" + assert bf.episode == 1 + assert bf.season == 1 file_name = "[SBSUB][CONAN][1082][V2][1080P][AVC_AAC][CHS_JP](C1E4E331).mp4" - folder_name = "名侦探柯南(1996)" - season = 1 - suffix = ".mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "pn") == "CONAN S01E1082.mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "advance") == "名侦探柯南(1996) S01E1082.mp4" + bf = torrent_parser(file_name) + assert bf.title == "CONAN" + assert bf.group == "SBSUB" + assert bf.episode == 1082 + assert bf.season == 1 - file_name = "海盗战记 S01E01.mp4" - folder_name = "海盗战记(2021)" - season = 1 - suffix = ".mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "pn") == "海盗战记 S01E01.mp4" - assert torrent_parser(file_name, folder_name, season, suffix, "advance") == "海盗战记(2021) S01E01.mp4" + file_name = "海盗战记 (2019) S01E01.mp4" + bf = torrent_parser(file_name) + assert bf.title == "海盗战记 (2019)" + assert bf.episode == 1 + assert bf.season == 1 + + file_name = "海盗战记/海盗战记 S01E01.mp4" + bf = torrent_parser(file_name) + assert bf.title == "海盗战记" + assert bf.episode == 1 + assert bf.season == 1 file_name = "海盗战记 S01E01.zh-tw.ass" - folder_name = "海盗战记(2021)" - season = 1 - suffix = ".ass" - assert torrent_parser(file_name, folder_name, season, suffix, "subtitle_pn") == "海盗战记 S01E01.zh-tw.ass" - assert torrent_parser(file_name, folder_name, season, suffix, "subtitle_advance") == "海盗战记(2021) S01E01.zh-tw.ass" + sf = torrent_parser(file_name, file_type="subtitle") + assert sf.title == "海盗战记" + assert sf.episode == 1 + assert sf.season == 1 + assert sf.language == "zh-tw" file_name = "海盗战记 S01E01.SC.ass" - folder_name = "海盗战记(2021)" - season = 1 - suffix = ".ass" - assert torrent_parser(file_name, folder_name, season, suffix, "subtitle_pn") == "海盗战记 S01E01.zh.ass" - assert torrent_parser(file_name, folder_name, season, suffix, "subtitle_advance") == "海盗战记(2021) S01E01.zh.ass" + sf = torrent_parser(file_name, file_type="subtitle") + assert sf.title == "海盗战记" + assert sf.season == 1 + assert sf.episode == 1 + assert sf.language == "zh" + file_name = "水星的魔女(2022) S00E19.mp4" + bf = torrent_parser(file_name, season=0) + assert bf.title == "水星的魔女(2022)" + assert bf.season == 0 + assert bf.episode == 19 + + file_name = "【失眠搬运组】放学后失眠的你-Kimi wa Houkago Insomnia - 06 [bilibili - 1080p AVC1 CHS-JP].mp4" + bf = torrent_parser(file_name, season=1) + assert bf.title == "放学后失眠的你-Kimi wa Houkago Insomnia" + assert bf.season == 1 + assert bf.episode == 6