init
This commit is contained in:
commit
4d06f86e62
47
.github/workflows/build-docker.yml
vendored
Normal file
47
.github/workflows/build-docker.yml
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
name: NASbot Docker
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Build Docker Image
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout
|
||||||
|
uses: actions/checkout@master
|
||||||
|
|
||||||
|
-
|
||||||
|
name: Release version
|
||||||
|
id: release_version
|
||||||
|
run: |
|
||||||
|
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||||
|
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
-
|
||||||
|
name: Set Up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
|
-
|
||||||
|
name: Set Up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
-
|
||||||
|
name: Login DockerHub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
-
|
||||||
|
name: Build Image
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_USERNAME }}/nasbot:latest
|
||||||
|
${{ secrets.DOCKER_USERNAME }}/nasbot:${{ env.app_version }}
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.idea/
|
||||||
|
*.c
|
||||||
|
build/
|
||||||
|
test.py
|
||||||
|
app/helper/sites.py
|
BIN
alembic/__pycache__/env.cpython-310.pyc
Normal file
BIN
alembic/__pycache__/env.cpython-310.pyc
Normal file
Binary file not shown.
80
alembic/env.py
Normal file
80
alembic/env.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from app.db.models import Base
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
render_as_batch=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
24
alembic/script.py.mako
Normal file
24
alembic/script.py.mako
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/log.cpython-310.pyc
Normal file
BIN
app/__pycache__/log.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-310.pyc
Normal file
BIN
app/__pycache__/main.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/scheduler.cpython-310.pyc
Normal file
BIN
app/__pycache__/scheduler.cpython-310.pyc
Normal file
Binary file not shown.
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
BIN
app/api/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/api/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/api/__pycache__/apiv1.cpython-310.pyc
Normal file
BIN
app/api/__pycache__/apiv1.cpython-310.pyc
Normal file
Binary file not shown.
12
app/api/apiv1.py
Normal file
12
app/api/apiv1.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.endpoints import login, users, sites, messages, webhooks, subscribes, media
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(login.router, tags=["login"])
|
||||||
|
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||||
|
api_router.include_router(sites.router, prefix="/sites", tags=["sites"])
|
||||||
|
api_router.include_router(messages.router, prefix="/messages", tags=["messages"])
|
||||||
|
api_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
|
||||||
|
api_router.include_router(subscribes.router, prefix="/subscribes", tags=["subscribes"])
|
||||||
|
api_router.include_router(media.router, prefix="/media", tags=["media"])
|
0
app/api/endpoints/__init__.py
Normal file
0
app/api/endpoints/__init__.py
Normal file
BIN
app/api/endpoints/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/api/endpoints/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/api/endpoints/__pycache__/login.cpython-310.pyc
Normal file
BIN
app/api/endpoints/__pycache__/login.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/api/endpoints/__pycache__/media.cpython-310.pyc
Normal file
BIN
app/api/endpoints/__pycache__/media.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/api/endpoints/__pycache__/messages.cpython-310.pyc
Normal file
BIN
app/api/endpoints/__pycache__/messages.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/api/endpoints/__pycache__/sites.cpython-310.pyc
Normal file
BIN
app/api/endpoints/__pycache__/sites.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/api/endpoints/__pycache__/subscribes.cpython-310.pyc
Normal file
BIN
app/api/endpoints/__pycache__/subscribes.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/api/endpoints/__pycache__/users.cpython-310.pyc
Normal file
BIN
app/api/endpoints/__pycache__/users.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/api/endpoints/__pycache__/webhooks.cpython-310.pyc
Normal file
BIN
app/api/endpoints/__pycache__/webhooks.cpython-310.pyc
Normal file
Binary file not shown.
38
app/api/endpoints/login.py
Normal file
38
app/api/endpoints/login.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.core import security, settings
|
||||||
|
from app.db import get_db
|
||||||
|
from app.db.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login/access-token", response_model=schemas.Token)
|
||||||
|
async def login_access_token(
|
||||||
|
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
获取认证Token
|
||||||
|
"""
|
||||||
|
user = User.authenticate(
|
||||||
|
db=db,
|
||||||
|
email=form_data.username,
|
||||||
|
password=form_data.password
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="用户名或密码不正确")
|
||||||
|
elif not user.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="用户未启用")
|
||||||
|
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
return {
|
||||||
|
"access_token": security.create_access_token(
|
||||||
|
user.id, expires_delta=access_token_expires
|
||||||
|
),
|
||||||
|
"token_type": "bearer",
|
||||||
|
}
|
25
app/api/endpoints/media.py
Normal file
25
app/api/endpoints/media.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.chain.identify import IdentifyChain
|
||||||
|
from app.db.models.user import User
|
||||||
|
from app.db.userauth import get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recognize", response_model=schemas.Context)
|
||||||
|
async def recognize(title: str,
|
||||||
|
subtitle: str = None,
|
||||||
|
current_user: User = Depends(get_current_active_user)):
|
||||||
|
"""
|
||||||
|
识别媒体信息
|
||||||
|
"""
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="需要授权",
|
||||||
|
)
|
||||||
|
# 识别媒体信息
|
||||||
|
context = IdentifyChain().process(title=title, subtitle=subtitle)
|
||||||
|
return context.to_dict()
|
51
app/api/endpoints/messages.py
Normal file
51
app/api/endpoints/messages.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from typing import Union
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.chain.user_message import UserMessageChain
|
||||||
|
from app.core import settings
|
||||||
|
from app.log import logger
|
||||||
|
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def start_message_chain(request: Request):
|
||||||
|
"""
|
||||||
|
启动链式任务
|
||||||
|
"""
|
||||||
|
UserMessageChain().process(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=schemas.Response)
|
||||||
|
async def user_message(background_tasks: BackgroundTasks, request: Request):
|
||||||
|
"""
|
||||||
|
用户消息响应
|
||||||
|
"""
|
||||||
|
background_tasks.add_task(start_message_chain, request)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int], nonce: str):
|
||||||
|
"""
|
||||||
|
用户消息响应
|
||||||
|
"""
|
||||||
|
logger.info(f"收到微信验证请求: {echostr}")
|
||||||
|
try:
|
||||||
|
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
|
||||||
|
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
|
||||||
|
sReceiveId=settings.WECHAT_CORPID)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"微信请求验证失败: {err}")
|
||||||
|
return str(err)
|
||||||
|
ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature,
|
||||||
|
sTimeStamp=timestamp,
|
||||||
|
sNonce=nonce,
|
||||||
|
sEchoStr=echostr)
|
||||||
|
if ret != 0:
|
||||||
|
logger.error("微信请求验证失败 VerifyURL ret: %s" % str(ret))
|
||||||
|
# 验证URL成功,将sEchoStr返回给企业号
|
||||||
|
return sEchoStr
|
43
app/api/endpoints/sites.py
Normal file
43
app/api/endpoints/sites.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.chain.cookiecloud import CookieCloudChain
|
||||||
|
from app.db import get_db
|
||||||
|
from app.db.models.site import Site
|
||||||
|
from app.db.models.user import User
|
||||||
|
from app.db.userauth import get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[schemas.Site])
|
||||||
|
async def read_sites(db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)) -> List[dict]:
|
||||||
|
"""
|
||||||
|
获取站点列表
|
||||||
|
"""
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="需要授权",
|
||||||
|
)
|
||||||
|
return Site.list(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cookiecloud", response_model=schemas.Response)
|
||||||
|
async def cookie_cloud_sync(current_user: User = Depends(get_current_active_user)) -> dict:
|
||||||
|
"""
|
||||||
|
运行CookieCloud同步站点信息
|
||||||
|
"""
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="需要授权",
|
||||||
|
)
|
||||||
|
status, error_msg = CookieCloudChain().process()
|
||||||
|
if not status:
|
||||||
|
return {"success": False, "message": error_msg}
|
||||||
|
return {"success": True, "message": error_msg}
|
90
app/api/endpoints/subscribes.py
Normal file
90
app/api/endpoints/subscribes.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.chain.subscribe import SubscribeChain
|
||||||
|
from app.core import settings
|
||||||
|
from app.db import get_db
|
||||||
|
from app.db.models.subscribe import Subscribe
|
||||||
|
from app.db.models.user import User
|
||||||
|
from app.db.userauth import get_current_active_superuser
|
||||||
|
from app.utils.types import MediaType
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def start_subscribe_chain(title: str,
|
||||||
|
mtype: MediaType, tmdbid: str, season: int, username: str):
|
||||||
|
"""
|
||||||
|
启动订阅链式任务
|
||||||
|
"""
|
||||||
|
SubscribeChain().process(title=title,
|
||||||
|
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[schemas.Subscribe])
|
||||||
|
async def read_subscribes(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_superuser)):
|
||||||
|
"""
|
||||||
|
查询所有订阅
|
||||||
|
"""
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="需要授权",
|
||||||
|
)
|
||||||
|
return Subscribe.list(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/seerr", response_model=schemas.Response)
|
||||||
|
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||||
|
authorization: str = Header(None)):
|
||||||
|
"""
|
||||||
|
Jellyseerr/Overseerr订阅
|
||||||
|
"""
|
||||||
|
if not authorization or authorization != settings.API_TOKEN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="授权失败",
|
||||||
|
)
|
||||||
|
req_json = await request.json()
|
||||||
|
if not req_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="报文内容为空",
|
||||||
|
)
|
||||||
|
notification_type = req_json.get("notification_type")
|
||||||
|
if notification_type not in ["MEDIA_APPROVED", "MEDIA_AUTO_APPROVED"]:
|
||||||
|
return {"success": False, "message": "不支持的通知类型"}
|
||||||
|
subject = req_json.get("subject")
|
||||||
|
media_type = MediaType.MOVIE if req_json.get("media", {}).get("media_type") == "movie" else MediaType.TV
|
||||||
|
tmdbId = req_json.get("media", {}).get("tmdbId")
|
||||||
|
if not media_type or not tmdbId or not subject:
|
||||||
|
return {"success": False, "message": "请求参数不正确"}
|
||||||
|
user_name = req_json.get("request", {}).get("requestedBy_username")
|
||||||
|
# 添加订阅
|
||||||
|
if media_type == MediaType.MOVIE:
|
||||||
|
background_tasks.add_task(start_subscribe_chain,
|
||||||
|
mtype=media_type,
|
||||||
|
tmdbid=tmdbId,
|
||||||
|
title=subject,
|
||||||
|
season=0,
|
||||||
|
username=user_name)
|
||||||
|
else:
|
||||||
|
seasons = []
|
||||||
|
for extra in req_json.get("extra", []):
|
||||||
|
if extra.get("name") == "Requested Seasons":
|
||||||
|
seasons = [int(str(sea).strip()) for sea in extra.get("value").split(", ") if str(sea).isdigit()]
|
||||||
|
break
|
||||||
|
for season in seasons:
|
||||||
|
background_tasks.add_task(start_subscribe_chain,
|
||||||
|
mtype=media_type,
|
||||||
|
tmdbid=tmdbId,
|
||||||
|
title=subject,
|
||||||
|
season=season,
|
||||||
|
username=user_name)
|
||||||
|
|
||||||
|
return {"success": True}
|
74
app/api/endpoints/users.py
Normal file
74
app/api/endpoints/users.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
from app.db import get_db
|
||||||
|
from app.db.models.user import User
|
||||||
|
from app.db.userauth import get_current_active_superuser, get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[schemas.User])
|
||||||
|
async def read_users(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
查询用户列表
|
||||||
|
"""
|
||||||
|
users = current_user.list(db)
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=schemas.User)
|
||||||
|
async def create_user(
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user_in: schemas.UserCreate,
|
||||||
|
current_user: User = Depends(get_current_active_superuser),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
新增用户
|
||||||
|
"""
|
||||||
|
user = current_user.get_by_email(db, email=user_in.email)
|
||||||
|
if user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="用户已存在",
|
||||||
|
)
|
||||||
|
user_info = user_in.dict()
|
||||||
|
if user_info.get("password"):
|
||||||
|
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
||||||
|
user_info.pop("password")
|
||||||
|
user = User(**user_info)
|
||||||
|
user = user.create(db)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=schemas.User)
|
||||||
|
async def read_user_by_id(
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
查询用户详情
|
||||||
|
"""
|
||||||
|
user = current_user.get(db, rid=user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="用户不存在",
|
||||||
|
)
|
||||||
|
if user == current_user:
|
||||||
|
return user
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="用户权限不足"
|
||||||
|
)
|
||||||
|
return user
|
27
app/api/endpoints/webhooks.py
Normal file
27
app/api/endpoints/webhooks.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Request
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.chain.webhook_message import WebhookMessageChain
|
||||||
|
from app.core import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def start_webhook_chain(message: dict):
|
||||||
|
"""
|
||||||
|
启动链式任务
|
||||||
|
"""
|
||||||
|
WebhookMessageChain().process(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=schemas.Response)
|
||||||
|
async def webhook_message(background_tasks: BackgroundTasks, token: str, request: Request):
|
||||||
|
"""
|
||||||
|
Webhook响应
|
||||||
|
"""
|
||||||
|
if token != settings.API_TOKEN:
|
||||||
|
return {"success": False, "message": "token认证不通过"}
|
||||||
|
|
||||||
|
background_tasks.add_task(start_webhook_chain, await request.json())
|
||||||
|
return {"success": True}
|
58
app/chain/__init__.py
Normal file
58
app/chain/__init__.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
from abc import abstractmethod
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from app.core import Context, ModuleManager, EventManager
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.singleton import AbstractSingleton, Singleton
|
||||||
|
|
||||||
|
|
||||||
|
class _ChainBase(AbstractSingleton, metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
处理链基类
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
公共初始化
|
||||||
|
"""
|
||||||
|
self.modulemanager = ModuleManager()
|
||||||
|
self.eventmanager = EventManager()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process(self, *args, **kwargs) -> Optional[Context]:
|
||||||
|
"""
|
||||||
|
处理链,返回上下文
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run_module(self, method: str, *args, **kwargs) -> Any:
|
||||||
|
"""
|
||||||
|
运行包含该方法的所有模块,然后返回结果
|
||||||
|
"""
|
||||||
|
|
||||||
|
def is_result_empty(ret):
|
||||||
|
"""
|
||||||
|
判断结果是否为空
|
||||||
|
"""
|
||||||
|
if isinstance(ret, tuple):
|
||||||
|
return all(value is None for value in ret)
|
||||||
|
else:
|
||||||
|
return result is None
|
||||||
|
|
||||||
|
logger.info(f"请求模块执行:{method} ...")
|
||||||
|
result = None
|
||||||
|
modules = self.modulemanager.get_modules(method)
|
||||||
|
for module in modules:
|
||||||
|
try:
|
||||||
|
if is_result_empty(result):
|
||||||
|
result = getattr(module, method)(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
temp = getattr(module, method)(*result)
|
||||||
|
else:
|
||||||
|
temp = getattr(module, method)(result)
|
||||||
|
if temp:
|
||||||
|
result = temp
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"运行模块出错:{module.__class__.__name__} - {err}")
|
||||||
|
return result
|
BIN
app/chain/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/chain/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/chain/__pycache__/common.cpython-310.pyc
Normal file
BIN
app/chain/__pycache__/common.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/chain/__pycache__/cookiecloud.cpython-310.pyc
Normal file
BIN
app/chain/__pycache__/cookiecloud.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/chain/__pycache__/douban_sync.cpython-310.pyc
Normal file
BIN
app/chain/__pycache__/douban_sync.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/chain/__pycache__/identify.cpython-310.pyc
Normal file
BIN
app/chain/__pycache__/identify.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/chain/__pycache__/search.cpython-310.pyc
Normal file
BIN
app/chain/__pycache__/search.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/chain/__pycache__/subscribe.cpython-310.pyc
Normal file
BIN
app/chain/__pycache__/subscribe.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/chain/__pycache__/user_message.cpython-310.pyc
Normal file
BIN
app/chain/__pycache__/user_message.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/chain/__pycache__/webhook_message.cpython-310.pyc
Normal file
BIN
app/chain/__pycache__/webhook_message.cpython-310.pyc
Normal file
Binary file not shown.
383
app/chain/common.py
Normal file
383
app/chain/common.py
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple, Set
|
||||||
|
|
||||||
|
from app.chain import _ChainBase
|
||||||
|
from app.core import MediaInfo
|
||||||
|
from app.core import TorrentInfo, Context
|
||||||
|
from app.core.meta import MetaBase
|
||||||
|
from app.helper.torrent import TorrentHelper
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
from app.utils.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class CommonChain(_ChainBase):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.torrent = TorrentHelper()
|
||||||
|
|
||||||
|
def process(self, *args, **kwargs) -> Optional[Context]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def post_message(self, title: str, text: str = None, image: str = None, userid: str = None):
|
||||||
|
"""
|
||||||
|
发送消息
|
||||||
|
"""
|
||||||
|
self.run_module('post_message', title=title, text=text, image=image, userid=userid)
|
||||||
|
|
||||||
|
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo, userid: str = None):
|
||||||
|
"""
|
||||||
|
发送添加下载的消息
|
||||||
|
"""
|
||||||
|
msg_text = ""
|
||||||
|
if torrent.site_name:
|
||||||
|
msg_text = f"站点:{torrent.site_name}"
|
||||||
|
if meta.get_resource_type_string():
|
||||||
|
msg_text = f"{msg_text}\n质量:{meta.get_resource_type_string()}"
|
||||||
|
if torrent.size:
|
||||||
|
if str(torrent.size).isdigit():
|
||||||
|
size = StringUtils.str_filesize(torrent.size)
|
||||||
|
else:
|
||||||
|
size = torrent.size
|
||||||
|
msg_text = f"{msg_text}\n大小:{size}"
|
||||||
|
if torrent.title:
|
||||||
|
msg_text = f"{msg_text}\n种子:{torrent.title}"
|
||||||
|
if torrent.seeders:
|
||||||
|
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
|
||||||
|
msg_text = f"{msg_text}\n促销:{torrent.get_volume_factor_string()}"
|
||||||
|
if torrent.hit_and_run:
|
||||||
|
msg_text = f"{msg_text}\nHit&Run:是"
|
||||||
|
if torrent.description:
|
||||||
|
html_re = re.compile(r'<[^>]+>', re.S)
|
||||||
|
description = html_re.sub('', torrent.description)
|
||||||
|
torrent.description = re.sub(r'<[^>]+>', '', description)
|
||||||
|
msg_text = f"{msg_text}\n描述:{torrent.description}"
|
||||||
|
|
||||||
|
self.post_message(title=f"{mediainfo.get_title_string()}"
|
||||||
|
f"{meta.get_season_episode_string()} 开始下载",
|
||||||
|
text=msg_text,
|
||||||
|
image=mediainfo.get_message_image(),
|
||||||
|
userid=userid)
|
||||||
|
|
||||||
|
def batch_download(self,
|
||||||
|
contexts: List[Context],
|
||||||
|
need_tvs: dict = None,
|
||||||
|
userid: str = None) -> Tuple[List[Context], dict]:
|
||||||
|
"""
|
||||||
|
根据缺失数据,自动种子列表中组合择优下载
|
||||||
|
:param contexts: 资源上下文列表
|
||||||
|
:param need_tvs: 缺失的剧集信息
|
||||||
|
:param userid: 用户ID
|
||||||
|
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[mediainfo.tmdb_id] = [
|
||||||
|
{
|
||||||
|
"season": season,
|
||||||
|
"episodes": episodes,
|
||||||
|
"total_episodes": len(episodes)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
# 已下载的项目
|
||||||
|
downloaded_list: list = []
|
||||||
|
|
||||||
|
def __download_torrent(_torrent: TorrentInfo) -> Tuple[Optional[Path], list]:
|
||||||
|
"""
|
||||||
|
下载种子文件
|
||||||
|
:return: 种子路径,种子文件清单
|
||||||
|
"""
|
||||||
|
torrent_file, _, _, files, error_msg = self.torrent.download_torrent(
|
||||||
|
url=_torrent.enclosure,
|
||||||
|
cookie=_torrent.site_cookie,
|
||||||
|
ua=_torrent.site_ua,
|
||||||
|
proxy=_torrent.site_proxy)
|
||||||
|
if not torrent_file:
|
||||||
|
logger.error(f"下载种子文件失败:{_torrent.title} - {_torrent.enclosure}")
|
||||||
|
self.run_module('post_message',
|
||||||
|
title=f"{_torrent.title} 种子下载失败!",
|
||||||
|
text=f"错误信息:{error_msg}\n种子链接:{_torrent.enclosure}",
|
||||||
|
userid=userid)
|
||||||
|
return None, []
|
||||||
|
return torrent_file, files
|
||||||
|
|
||||||
|
def __download(_context: Context, _torrent_file: Path = None, _episodes: Set[int] = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
下载及发送通知
|
||||||
|
"""
|
||||||
|
_torrent = _context.torrent_info
|
||||||
|
_media = _context.media_info
|
||||||
|
_meta = _context.meta_info
|
||||||
|
if not _torrent_file:
|
||||||
|
# 下载种子文件
|
||||||
|
_torrent_file, _ = __download_torrent(_torrent)
|
||||||
|
if not _torrent_file:
|
||||||
|
return
|
||||||
|
# 添加下载
|
||||||
|
_hash, error_msg = self.run_module("download",
|
||||||
|
torrent_path=_torrent_file,
|
||||||
|
mediainfo=_media,
|
||||||
|
episodes=_episodes)
|
||||||
|
if _hash:
|
||||||
|
# 下载成功
|
||||||
|
downloaded_list.append(_context)
|
||||||
|
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, userid=userid)
|
||||||
|
else:
|
||||||
|
# 下载失败
|
||||||
|
logger.error(f"{_media.get_title_string()} 添加下载任务失败:"
|
||||||
|
f"{_torrent.title} - {_torrent.enclosure},{error_msg}")
|
||||||
|
self.run_module('post_message',
|
||||||
|
title="添加下载任务失败:%s %s"
|
||||||
|
% (_media.get_title_string(), _meta.get_season_episode_string()),
|
||||||
|
text=f"站点:{_torrent.site_name}\n"
|
||||||
|
f"种子名称:{_meta.org_string}\n"
|
||||||
|
f"种子链接:{_torrent.enclosure}\n"
|
||||||
|
f"错误信息:{error_msg}",
|
||||||
|
image=_media.get_message_image(),
|
||||||
|
userid=userid)
|
||||||
|
return _hash
|
||||||
|
|
||||||
|
def __update_seasons(tmdbid, need, current):
|
||||||
|
"""
|
||||||
|
更新need_tvs季数
|
||||||
|
"""
|
||||||
|
need = list(set(need).difference(set(current)))
|
||||||
|
for cur in current:
|
||||||
|
for nt in need_tvs.get(tmdbid):
|
||||||
|
if cur == nt.get("season") or (cur == 1 and not nt.get("season")):
|
||||||
|
need_tvs[tmdbid].remove(nt)
|
||||||
|
if not need_tvs.get(tmdbid):
|
||||||
|
need_tvs.pop(tmdbid)
|
||||||
|
return need
|
||||||
|
|
||||||
|
def __update_episodes(tmdbid, seq, need, current):
|
||||||
|
"""
|
||||||
|
更新need_tvs集数
|
||||||
|
"""
|
||||||
|
need = list(set(need).difference(set(current)))
|
||||||
|
if need:
|
||||||
|
need_tvs[tmdbid][seq]["episodes"] = need
|
||||||
|
else:
|
||||||
|
need_tvs[tmdbid].pop(seq)
|
||||||
|
if not need_tvs.get(tmdbid):
|
||||||
|
need_tvs.pop(tmdbid)
|
||||||
|
return need
|
||||||
|
|
||||||
|
def __get_season_episodes(tmdbid, season):
|
||||||
|
"""
|
||||||
|
获取需要的季的集数
|
||||||
|
"""
|
||||||
|
if not need_tvs.get(tmdbid):
|
||||||
|
return 0
|
||||||
|
for nt in need_tvs.get(tmdbid):
|
||||||
|
if season == nt.get("season"):
|
||||||
|
return nt.get("total_episodes")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 如果是电影,直接下载
|
||||||
|
for context in contexts:
|
||||||
|
if context.media_info.type == MediaType.MOVIE:
|
||||||
|
__download(context)
|
||||||
|
|
||||||
|
# 电视剧整季匹配
|
||||||
|
if need_tvs:
|
||||||
|
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子
|
||||||
|
need_seasons = {}
|
||||||
|
for need_tmdbid, need_tv in need_tvs.items():
|
||||||
|
for tv in need_tv:
|
||||||
|
if not tv:
|
||||||
|
continue
|
||||||
|
if not tv.get("episodes"):
|
||||||
|
if not need_seasons.get(need_tmdbid):
|
||||||
|
need_seasons[need_tmdbid] = []
|
||||||
|
need_seasons[need_tmdbid].append(tv.get("season") or 1)
|
||||||
|
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
|
||||||
|
for need_tmdbid, need_season in need_seasons.items():
|
||||||
|
for context in contexts:
|
||||||
|
media = context.media_info
|
||||||
|
meta = context.meta_info
|
||||||
|
torrent = context.torrent_info
|
||||||
|
if media.type == MediaType.MOVIE:
|
||||||
|
continue
|
||||||
|
item_season = meta.get_season_list()
|
||||||
|
if meta.get_episode_list():
|
||||||
|
continue
|
||||||
|
if need_tmdbid == media.tmdb_id:
|
||||||
|
if set(item_season).issubset(set(need_season)):
|
||||||
|
if len(item_season) == 1:
|
||||||
|
# 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载
|
||||||
|
torrent_path, torrent_files = __download_torrent(torrent)
|
||||||
|
if not torrent_path:
|
||||||
|
continue
|
||||||
|
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||||
|
if not torrent_episodes \
|
||||||
|
or len(torrent_episodes) >= __get_season_episodes(need_tmdbid, item_season[0]):
|
||||||
|
_, download_id = __download(_context=context, _torrent_file=torrent_path)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"【Downloader】种子 {meta.org_string} 未含集数信息,解析文件数为 {len(torrent_episodes)}")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
download_id = __download(context)
|
||||||
|
if download_id:
|
||||||
|
# 更新仍需季集
|
||||||
|
need_season = __update_seasons(tmdbid=need_tmdbid,
|
||||||
|
need=need_season,
|
||||||
|
current=item_season)
|
||||||
|
# 电视剧季内的集匹配
|
||||||
|
if need_tvs:
|
||||||
|
need_tv_list = list(need_tvs)
|
||||||
|
for need_tmdbid in need_tv_list:
|
||||||
|
need_tv = need_tvs.get(need_tmdbid)
|
||||||
|
if not need_tv:
|
||||||
|
continue
|
||||||
|
index = 0
|
||||||
|
for tv in need_tv:
|
||||||
|
need_season = tv.get("season") or 1
|
||||||
|
need_episodes = tv.get("episodes")
|
||||||
|
total_episodes = tv.get("total_episodes")
|
||||||
|
# 缺失整季的转化为缺失集进行比较
|
||||||
|
if not need_episodes:
|
||||||
|
need_episodes = list(range(1, total_episodes + 1))
|
||||||
|
for context in contexts:
|
||||||
|
media = context.media_info
|
||||||
|
meta = context.meta_info
|
||||||
|
if media.type == MediaType.MOVIE:
|
||||||
|
continue
|
||||||
|
if media.tmdb_id == need_tmdbid:
|
||||||
|
if context in downloaded_list:
|
||||||
|
continue
|
||||||
|
# 只处理单季含集的种子
|
||||||
|
item_season = meta.get_season_list()
|
||||||
|
if len(item_season) != 1 or item_season[0] != need_season:
|
||||||
|
continue
|
||||||
|
item_episodes = meta.get_episode_list()
|
||||||
|
if not item_episodes:
|
||||||
|
continue
|
||||||
|
# 为需要集的子集则下载
|
||||||
|
if set(item_episodes).issubset(set(need_episodes)):
|
||||||
|
download_id = __download(context)
|
||||||
|
if download_id:
|
||||||
|
# 更新仍需集数
|
||||||
|
need_episodes = __update_episodes(tmdbid=need_tmdbid,
|
||||||
|
need=need_episodes,
|
||||||
|
seq=index,
|
||||||
|
current=item_episodes)
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
# 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR
|
||||||
|
if need_tvs:
|
||||||
|
need_tv_list = list(need_tvs)
|
||||||
|
for need_tmdbid in need_tv_list:
|
||||||
|
need_tv = need_tvs.get(need_tmdbid)
|
||||||
|
if not need_tv:
|
||||||
|
continue
|
||||||
|
index = 0
|
||||||
|
for tv in need_tv:
|
||||||
|
need_season = tv.get("season") or 1
|
||||||
|
need_episodes = tv.get("episodes")
|
||||||
|
if not need_episodes:
|
||||||
|
continue
|
||||||
|
for context in contexts:
|
||||||
|
media = context.media_info
|
||||||
|
meta = context.meta_info
|
||||||
|
torrent = context.torrent_info
|
||||||
|
if media.type == MediaType.MOVIE:
|
||||||
|
continue
|
||||||
|
if context in downloaded_list:
|
||||||
|
continue
|
||||||
|
if not need_episodes:
|
||||||
|
break
|
||||||
|
# 选中一个单季整季的或单季包括需要的所有集的
|
||||||
|
if media.tmdb_id == need_tmdbid \
|
||||||
|
and (not meta.get_episode_list()
|
||||||
|
or set(meta.get_episode_list()).intersection(set(need_episodes))) \
|
||||||
|
and len(meta.get_season_list()) == 1 \
|
||||||
|
and meta.get_season_list()[0] == need_season:
|
||||||
|
# 检查种子看是否有需要的集
|
||||||
|
torrent_path, torrent_files = __download_torrent(torrent)
|
||||||
|
if not torrent_path:
|
||||||
|
continue
|
||||||
|
# 种子全部集
|
||||||
|
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||||
|
# 选中的集
|
||||||
|
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
|
||||||
|
if not selected_episodes:
|
||||||
|
logger.info(f"{meta.org_string} 没有需要的集,跳过...")
|
||||||
|
continue
|
||||||
|
# 添加下载
|
||||||
|
download_id = __download(_context=context,
|
||||||
|
_torrent_file=torrent_path,
|
||||||
|
_episodes=selected_episodes)
|
||||||
|
if not download_id:
|
||||||
|
continue
|
||||||
|
# 更新仍需集数
|
||||||
|
need_episodes = __update_episodes(tmdbid=need_tmdbid,
|
||||||
|
need=need_episodes,
|
||||||
|
seq=index,
|
||||||
|
current=selected_episodes)
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
# 返回下载的资源,剩下没下完的
|
||||||
|
return downloaded_list, need_tvs
|
||||||
|
|
||||||
|
def get_no_exists_info(self, mediainfo: MediaInfo, no_exists: dict = None) -> Tuple[bool, dict]:
|
||||||
|
"""
|
||||||
|
检查媒体库,查询是否存在,对于剧集同时返回不存在的季集信息
|
||||||
|
:param mediainfo: 已识别的媒体信息
|
||||||
|
:param no_exists: 在调用该方法前已经存储的不存在的季集信息,有传入时该函数搜索的内容将会叠加后输出
|
||||||
|
:return: 当前媒体是否缺失,各标题总的季集和缺失的季集
|
||||||
|
"""
|
||||||
|
def __append_no_exists(_season: int, _episodes: list):
|
||||||
|
"""
|
||||||
|
添加不存在的季集信息
|
||||||
|
"""
|
||||||
|
if not no_exists.get(mediainfo.tmdb_id):
|
||||||
|
no_exists[mediainfo.tmdb_id] = [
|
||||||
|
{
|
||||||
|
"season": season,
|
||||||
|
"episodes": episodes,
|
||||||
|
"total_episodes": len(episodes)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
no_exists[mediainfo.tmdb_id].append({
|
||||||
|
"season": season,
|
||||||
|
"episodes": episodes,
|
||||||
|
"total_episodes": len(episodes)
|
||||||
|
})
|
||||||
|
|
||||||
|
if not no_exists:
|
||||||
|
no_exists = {}
|
||||||
|
if not mediainfo.seasons:
|
||||||
|
logger.error(f"媒体信息中没有季集信息:{mediainfo.get_title_string()}")
|
||||||
|
return False, {}
|
||||||
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
|
# 电影
|
||||||
|
exists_movies = self.run_module("media_exists", mediainfo)
|
||||||
|
if exists_movies:
|
||||||
|
logger.info(f"媒体库中已存在电影:{mediainfo.get_title_string()}")
|
||||||
|
return True, {}
|
||||||
|
return False, {}
|
||||||
|
else:
|
||||||
|
# 电视剧
|
||||||
|
exists_tvs = self.run_module("media_exists", mediainfo)
|
||||||
|
if not exists_tvs:
|
||||||
|
# 所有剧集均缺失
|
||||||
|
for season, episodes in mediainfo.seasons.items():
|
||||||
|
# 添加不存在的季集信息
|
||||||
|
__append_no_exists(season, episodes)
|
||||||
|
return False, no_exists
|
||||||
|
else:
|
||||||
|
# 存在一些,检查缺失的季集
|
||||||
|
for season, episodes in mediainfo.seasons.items():
|
||||||
|
exist_seasons = exists_tvs.get("seasons")
|
||||||
|
if exist_seasons.get(season):
|
||||||
|
# 取差集
|
||||||
|
episodes = set(episodes).difference(set(exist_seasons['season']))
|
||||||
|
# 添加不存在的季集信息
|
||||||
|
__append_no_exists(season, episodes)
|
||||||
|
# 存在不完整的剧集
|
||||||
|
if no_exists:
|
||||||
|
return False, no_exists
|
||||||
|
# 全部存在
|
||||||
|
return True, no_exists
|
55
app/chain/cookiecloud.py
Normal file
55
app/chain/cookiecloud.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from app.chain import _ChainBase
|
||||||
|
from app.core import settings
|
||||||
|
from app.db.sites import Sites
|
||||||
|
from app.helper.cookiecloud import CookieCloudHelper
|
||||||
|
from app.helper.sites import SitesHelper
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class CookieCloudChain(_ChainBase):
|
||||||
|
"""
|
||||||
|
同步站点Cookie
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.sites = Sites()
|
||||||
|
self.siteshelper = SitesHelper()
|
||||||
|
self.cookiecloud = CookieCloudHelper(
|
||||||
|
server=settings.COOKIECLOUD_HOST,
|
||||||
|
key=settings.COOKIECLOUD_KEY,
|
||||||
|
password=settings.COOKIECLOUD_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
通过CookieCloud同步站点Cookie
|
||||||
|
"""
|
||||||
|
logger.info("开始同步CookieCloud站点 ...")
|
||||||
|
cookies, msg = self.cookiecloud.download()
|
||||||
|
if not cookies:
|
||||||
|
logger.error(f"CookieCloud同步失败:{msg}")
|
||||||
|
return False, msg
|
||||||
|
# 保存Cookie或新增站点
|
||||||
|
_update_count = 0
|
||||||
|
_add_count = 0
|
||||||
|
for domain, cookie in cookies.items():
|
||||||
|
if self.sites.exists(domain):
|
||||||
|
# 更新站点Cookie
|
||||||
|
self.sites.update_cookie(domain, cookie)
|
||||||
|
_update_count += 1
|
||||||
|
else:
|
||||||
|
# 获取站点信息
|
||||||
|
indexer = self.siteshelper.get_indexer(domain)
|
||||||
|
if indexer:
|
||||||
|
# 新增站点
|
||||||
|
self.sites.add(name=indexer.get("name"),
|
||||||
|
url=indexer.get("domain"),
|
||||||
|
domain=domain,
|
||||||
|
cookie=cookie)
|
||||||
|
_add_count += 1
|
||||||
|
ret_msg = f"更新了{_update_count}个站点,新增了{_add_count}个站点"
|
||||||
|
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
||||||
|
return True, ret_msg
|
100
app/chain/douban_sync.py
Normal file
100
app/chain/douban_sync.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.chain import _ChainBase
|
||||||
|
from app.chain.common import CommonChain
|
||||||
|
from app.chain.search import SearchChain
|
||||||
|
from app.core import settings, MetaInfo
|
||||||
|
from app.db.subscribes import Subscribes
|
||||||
|
from app.helper.rss import RssHelper
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class DoubanSyncChain(_ChainBase):
|
||||||
|
"""
|
||||||
|
同步豆瓣相看数据
|
||||||
|
"""
|
||||||
|
|
||||||
|
_interests_url: str = "https://www.douban.com/feed/people/%s/interests"
|
||||||
|
|
||||||
|
_cache_path: Path = settings.TEMP_PATH / "__doubansync_cache__"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.rsshelper = RssHelper()
|
||||||
|
self.common = CommonChain()
|
||||||
|
self.searchchain = SearchChain()
|
||||||
|
self.subscribes = Subscribes()
|
||||||
|
|
||||||
|
def process(self):
|
||||||
|
"""
|
||||||
|
通过用户RSS同步豆瓣相看数据
|
||||||
|
"""
|
||||||
|
if not settings.DOUBAN_USER_IDS:
|
||||||
|
return
|
||||||
|
# 读取缓存
|
||||||
|
caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else []
|
||||||
|
for user_id in settings.DOUBAN_USER_IDS.split(","):
|
||||||
|
# 同步每个用户的豆瓣数据
|
||||||
|
if not user_id:
|
||||||
|
continue
|
||||||
|
logger.info(f"开始同步用户 {user_id} 的豆瓣想看数据 ...")
|
||||||
|
url = self._interests_url % user_id
|
||||||
|
results = self.rsshelper.parse(url)
|
||||||
|
if not results:
|
||||||
|
logger.error(f"未获取到用户 {user_id} 豆瓣RSS数据:{url}")
|
||||||
|
return
|
||||||
|
# 解析数据
|
||||||
|
for result in results:
|
||||||
|
dtype = result.get("title", "")[:2]
|
||||||
|
title = result.get("title", "")[2:]
|
||||||
|
if dtype not in ["想看"]:
|
||||||
|
continue
|
||||||
|
douban_id = result.get("link", "").split("/")[-2]
|
||||||
|
if not douban_id or douban_id in caches:
|
||||||
|
continue
|
||||||
|
# 根据豆瓣ID获取豆瓣数据
|
||||||
|
doubaninfo = self.run_module('douban_info', doubanid=douban_id)
|
||||||
|
if not doubaninfo:
|
||||||
|
logger.warn(f'未获取到豆瓣信息,标题:{title},豆瓣ID:{douban_id}')
|
||||||
|
continue
|
||||||
|
# 识别媒体信息
|
||||||
|
meta = MetaInfo(doubaninfo.get("original_title") or doubaninfo.get("title"))
|
||||||
|
if doubaninfo.get("year"):
|
||||||
|
meta.year = doubaninfo.get("year")
|
||||||
|
mediainfo = self.run_module('recognize_media', meta=meta)
|
||||||
|
if not mediainfo:
|
||||||
|
logger.warn(f'未识别到媒体信息,标题:{title},豆瓣ID:{douban_id}')
|
||||||
|
continue
|
||||||
|
# 加入缓存
|
||||||
|
caches.append(douban_id)
|
||||||
|
# 查询缺失的媒体信息
|
||||||
|
exist_flag, no_exists = self.common.get_no_exists_info(mediainfo=mediainfo)
|
||||||
|
if exist_flag:
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 媒体库中已存在')
|
||||||
|
continue
|
||||||
|
# 搜索
|
||||||
|
contexts = self.searchchain.process(meta=meta, mediainfo=mediainfo)
|
||||||
|
if not contexts:
|
||||||
|
logger.warn(f'{mediainfo.get_title_string()} 未搜索到资源')
|
||||||
|
continue
|
||||||
|
# 自动下载
|
||||||
|
_, lefts = self.common.batch_download(contexts=contexts, need_tvs=no_exists)
|
||||||
|
if not lefts:
|
||||||
|
# 全部下载完成
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 下载完成')
|
||||||
|
else:
|
||||||
|
# 未完成下载
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 未下载未完整,添加订阅 ...')
|
||||||
|
# 添加订阅
|
||||||
|
state, msg = self.subscribes.add(mediainfo,
|
||||||
|
season=meta.begin_season)
|
||||||
|
if state:
|
||||||
|
# 订阅成功
|
||||||
|
self.common.post_message(
|
||||||
|
title=f"{mediainfo.get_title_string()} 已添加订阅",
|
||||||
|
text="来自:豆瓣相看",
|
||||||
|
image=mediainfo.get_message_image())
|
||||||
|
|
||||||
|
logger.info(f"用户 {user_id} 豆瓣相看同步完成")
|
||||||
|
# 保存缓存
|
||||||
|
self._cache_path.write_text("\n".join(caches))
|
33
app/chain/identify.py
Normal file
33
app/chain/identify.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.chain import _ChainBase
|
||||||
|
from app.core import Context, MetaInfo, MediaInfo
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class IdentifyChain(_ChainBase):
|
||||||
|
"""
|
||||||
|
识别处理链
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process(self, title: str, subtitle: str = None) -> Optional[Context]:
|
||||||
|
"""
|
||||||
|
识别媒体信息
|
||||||
|
"""
|
||||||
|
logger.info(f'开始识别媒体信息,标题:{title},副标题:{subtitle} ...')
|
||||||
|
# 识别前预处理
|
||||||
|
result = self.run_module('prepare_recognize', title=title, subtitle=subtitle)
|
||||||
|
if result:
|
||||||
|
title, subtitle = result
|
||||||
|
# 识别元数据
|
||||||
|
metainfo = MetaInfo(title, subtitle)
|
||||||
|
# 识别媒体信息
|
||||||
|
mediainfo: MediaInfo = self.run_module('recognize_media', meta=metainfo)
|
||||||
|
if not mediainfo:
|
||||||
|
logger.warn(f'{title} 未识别到媒体信息')
|
||||||
|
return Context(meta=metainfo)
|
||||||
|
logger.info(f'{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.get_title_string()}')
|
||||||
|
# 更新媒体图片
|
||||||
|
self.run_module('obtain_image', mediainfo=mediainfo)
|
||||||
|
# 返回上下文
|
||||||
|
return Context(meta=metainfo, mediainfo=mediainfo, title=title, subtitle=subtitle)
|
73
app/chain/search.py
Normal file
73
app/chain/search.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app.chain import _ChainBase
|
||||||
|
from app.chain.common import CommonChain
|
||||||
|
from app.core import Context, MetaInfo, MediaInfo, TorrentInfo
|
||||||
|
from app.core.meta import MetaBase
|
||||||
|
from app.helper.sites import SitesHelper
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class SearchChain(_ChainBase):
|
||||||
|
"""
|
||||||
|
站点资源搜索处理链
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.common = CommonChain()
|
||||||
|
self.siteshelper = SitesHelper()
|
||||||
|
|
||||||
|
def process(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
|
keyword: str = None) -> Optional[List[Context]]:
|
||||||
|
"""
|
||||||
|
根据媒体信息,执行搜索
|
||||||
|
:param meta: 元数据
|
||||||
|
:param mediainfo: 媒体信息
|
||||||
|
:param keyword: 搜索关键词
|
||||||
|
"""
|
||||||
|
# 执行搜索
|
||||||
|
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||||
|
torrents: List[TorrentInfo] = self.run_module(
|
||||||
|
'search_torrents',
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
keyword=keyword,
|
||||||
|
sites=self.siteshelper.get_indexers()
|
||||||
|
)
|
||||||
|
if not torrents:
|
||||||
|
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||||
|
return []
|
||||||
|
# 过滤不匹配的资源
|
||||||
|
_match_torrents = []
|
||||||
|
if mediainfo:
|
||||||
|
for torrent in torrents:
|
||||||
|
# 比对IMDBID
|
||||||
|
if torrent.imdbid \
|
||||||
|
and mediainfo.imdb_id \
|
||||||
|
and torrent.imdbid == mediainfo.imdb_id:
|
||||||
|
_match_torrents.append(torrent)
|
||||||
|
continue
|
||||||
|
# 识别
|
||||||
|
torrent_meta = MetaInfo(torrent.title, torrent.description)
|
||||||
|
# 识别媒体信息
|
||||||
|
torrent_mediainfo: MediaInfo = self.run_module('recognize_media', meta=torrent_meta)
|
||||||
|
if not torrent_mediainfo:
|
||||||
|
logger.warn(f'未识别到媒体信息,标题:{torrent.title}')
|
||||||
|
continue
|
||||||
|
# 过滤
|
||||||
|
if torrent_mediainfo.tmdb_id == mediainfo.tmdb_id \
|
||||||
|
and torrent_mediainfo.type == mediainfo.type:
|
||||||
|
_match_torrents.append(torrent)
|
||||||
|
else:
|
||||||
|
_match_torrents = torrents
|
||||||
|
# 过滤种子
|
||||||
|
result = self.run_module("filter_torrents", torrent_list=_match_torrents)
|
||||||
|
if result is not None:
|
||||||
|
_match_torrents = result
|
||||||
|
if not _match_torrents:
|
||||||
|
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤条件的资源')
|
||||||
|
return []
|
||||||
|
# 组装上下文返回
|
||||||
|
return [Context(meta=MetaInfo(torrent.title),
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
torrentinfo=torrent) for torrent in _match_torrents]
|
195
app/chain/subscribe.py
Normal file
195
app/chain/subscribe.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from app.chain import _ChainBase
|
||||||
|
from app.chain.common import CommonChain
|
||||||
|
from app.chain.search import SearchChain
|
||||||
|
from app.core import MetaInfo, TorrentInfo, Context, MediaInfo
|
||||||
|
from app.db.subscribes import Subscribes
|
||||||
|
from app.helper.sites import SitesHelper
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
from app.utils.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeChain(_ChainBase):
|
||||||
|
"""
|
||||||
|
订阅处理链
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 站点最新种子缓存 {站点域名: 种子上下文}
|
||||||
|
_torrents_cache: Dict[str, List[Context]] = {}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.common = CommonChain()
|
||||||
|
self.searchchain = SearchChain()
|
||||||
|
self.subscribes = Subscribes()
|
||||||
|
self.siteshelper = SitesHelper()
|
||||||
|
|
||||||
|
def process(self, title: str,
|
||||||
|
mtype: MediaType = None,
|
||||||
|
tmdbid: str = None,
|
||||||
|
season: int = None,
|
||||||
|
username: str = None,
|
||||||
|
**kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
识别媒体信息并添加订阅
|
||||||
|
"""
|
||||||
|
logger.info(f'开始添加订阅,标题:{title} ...')
|
||||||
|
# 识别前预处理
|
||||||
|
result = self.run_module('prepare_recognize', title=title)
|
||||||
|
if result:
|
||||||
|
title, _ = result
|
||||||
|
# 识别元数据
|
||||||
|
metainfo = MetaInfo(title)
|
||||||
|
if mtype:
|
||||||
|
metainfo.type = mtype
|
||||||
|
if season:
|
||||||
|
metainfo.type = MediaType.TV
|
||||||
|
metainfo.begin_season = season
|
||||||
|
# 识别媒体信息
|
||||||
|
mediainfo = self.run_module('recognize_media', meta=metainfo, tmdbid=tmdbid)
|
||||||
|
if not mediainfo:
|
||||||
|
logger.warn(f'未识别到媒体信息,标题:{title},tmdbid:{tmdbid}')
|
||||||
|
return False
|
||||||
|
# 更新媒体图片
|
||||||
|
self.run_module('obtain_image', mediainfo=mediainfo)
|
||||||
|
# 添加订阅
|
||||||
|
state, err_msg = self.subscribes.add(mediainfo, season=season, **kwargs)
|
||||||
|
if state:
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} {err_msg}')
|
||||||
|
else:
|
||||||
|
logger.error(f'{mediainfo.get_title_string()} 添加订阅成功')
|
||||||
|
self.common.post_message(title=f"{mediainfo.get_title_string()} 已添加订阅",
|
||||||
|
text="用户:{username}",
|
||||||
|
image=mediainfo.get_message_image())
|
||||||
|
# 返回结果
|
||||||
|
return state
|
||||||
|
|
||||||
|
def search(self, sid: int = None, state: str = 'N'):
|
||||||
|
"""
|
||||||
|
订阅搜索
|
||||||
|
:param sid: 订阅ID,有值时只处理该订阅
|
||||||
|
:param state: 订阅状态 N:未搜索 R:已搜索
|
||||||
|
:return: 更新订阅状态为R或删除订阅
|
||||||
|
"""
|
||||||
|
if sid:
|
||||||
|
subscribes = [self.subscribes.get(sid)]
|
||||||
|
else:
|
||||||
|
subscribes = self.subscribes.list(state)
|
||||||
|
# 遍历订阅
|
||||||
|
for subscribe in subscribes:
|
||||||
|
# 如果状态为N则更新为R
|
||||||
|
if subscribe.state == 'N':
|
||||||
|
self.subscribes.update(subscribe.id, {'state': 'R'})
|
||||||
|
# 生成元数据
|
||||||
|
meta = MetaInfo(subscribe.name)
|
||||||
|
meta.year = subscribe.year
|
||||||
|
meta.begin_season = subscribe.season
|
||||||
|
meta.type = MediaType.MOVIE if subscribe.type == MediaType.MOVIE.value else MediaType.TV
|
||||||
|
# 识别媒体信息
|
||||||
|
mediainfo = self.run_module('recognize_media', meta=meta, tmdbid=subscribe.tmdbid)
|
||||||
|
if not mediainfo:
|
||||||
|
logger.warn(f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid}')
|
||||||
|
continue
|
||||||
|
# 查询缺失的媒体信息
|
||||||
|
exist_flag, no_exists = self.common.get_no_exists_info(mediainfo=mediainfo)
|
||||||
|
if exist_flag:
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 媒体库中已存在,完成订阅')
|
||||||
|
self.subscribes.delete(subscribe.id)
|
||||||
|
continue
|
||||||
|
# 搜索
|
||||||
|
contexts = self.searchchain.process(meta=meta, mediainfo=mediainfo, keyword=subscribe.keyword)
|
||||||
|
if not contexts:
|
||||||
|
logger.warn(f'{subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||||
|
continue
|
||||||
|
# 自动下载
|
||||||
|
_, lefts = self.common.batch_download(contexts=contexts, need_tvs=no_exists)
|
||||||
|
if not lefts:
|
||||||
|
# 全部下载完成
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 下载完成,完成订阅')
|
||||||
|
self.subscribes.delete(subscribe.id)
|
||||||
|
else:
|
||||||
|
# 未完成下载
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 未下载未完整,继续订阅 ...')
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""
|
||||||
|
刷新站点最新资源
|
||||||
|
"""
|
||||||
|
# 所有站点索引
|
||||||
|
indexers = self.siteshelper.get_indexers()
|
||||||
|
# 遍历站点缓存资源
|
||||||
|
for indexer in indexers:
|
||||||
|
domain = StringUtils.get_url_domain(indexer.get("domain"))
|
||||||
|
torrents: List[TorrentInfo] = self.run_module("refresh_torrents", sites=[indexer])
|
||||||
|
if torrents:
|
||||||
|
self._torrents_cache[domain] = []
|
||||||
|
for torrent in torrents:
|
||||||
|
# 识别
|
||||||
|
meta = MetaInfo(torrent.title, torrent.description)
|
||||||
|
# 识别媒体信息
|
||||||
|
mediainfo = self.run_module('recognize_media', meta=meta)
|
||||||
|
if not mediainfo:
|
||||||
|
logger.warn(f'未识别到媒体信息,标题:{torrent.title}')
|
||||||
|
continue
|
||||||
|
# 上下文
|
||||||
|
context = Context(meta=meta, mediainfo=mediainfo, torrentinfo=torrent)
|
||||||
|
self._torrents_cache[domain].append(context)
|
||||||
|
# 从缓存中匹配订阅
|
||||||
|
self.match()
|
||||||
|
|
||||||
|
def match(self):
|
||||||
|
"""
|
||||||
|
从缓存中匹配订阅,并自动下载
|
||||||
|
"""
|
||||||
|
# 所有订阅
|
||||||
|
subscribes = self.subscribes.list('R')
|
||||||
|
# 遍历订阅
|
||||||
|
for subscribe in subscribes:
|
||||||
|
# 生成元数据
|
||||||
|
meta = MetaInfo(subscribe.name)
|
||||||
|
meta.year = subscribe.year
|
||||||
|
meta.begin_season = subscribe.season
|
||||||
|
meta.type = MediaType.MOVIE if subscribe.type == MediaType.MOVIE.value else MediaType.TV
|
||||||
|
# 识别媒体信息
|
||||||
|
mediainfo: MediaInfo = self.run_module('recognize_media', meta=meta, tmdbid=subscribe.tmdbid)
|
||||||
|
if not mediainfo:
|
||||||
|
logger.warn(f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid}')
|
||||||
|
continue
|
||||||
|
# 查询缺失的媒体信息
|
||||||
|
exist_flag, no_exists = self.common.get_no_exists_info(mediainfo=mediainfo)
|
||||||
|
if exist_flag:
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 媒体库中已存在,完成订阅')
|
||||||
|
self.subscribes.delete(subscribe.id)
|
||||||
|
continue
|
||||||
|
# 遍历缓存种子
|
||||||
|
_match_context = []
|
||||||
|
for domain, contexts in self._torrents_cache.items():
|
||||||
|
for context in contexts:
|
||||||
|
# 检查是否匹配
|
||||||
|
torrent_meta = context.meta_info
|
||||||
|
torrent_mediainfo = context.media_info
|
||||||
|
torrent_info = context.torrent_info
|
||||||
|
if torrent_mediainfo.tmdb_id == mediainfo.tmdb_id \
|
||||||
|
and torrent_mediainfo.type == mediainfo.type:
|
||||||
|
if meta.begin_season and meta.begin_season != torrent_meta.begin_season:
|
||||||
|
continue
|
||||||
|
# 匹配成功
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 匹配成功:{torrent_info.title}')
|
||||||
|
_match_context.append(context)
|
||||||
|
logger(f'{mediainfo.get_title_string()} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||||
|
if _match_context:
|
||||||
|
# 批量择优下载
|
||||||
|
_, lefts = self.common.batch_download(contexts=_match_context, need_tvs=no_exists)
|
||||||
|
if not lefts:
|
||||||
|
# 全部下载完成
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 下载完成,完成订阅')
|
||||||
|
self.subscribes.delete(subscribe.id)
|
||||||
|
else:
|
||||||
|
# 未完成下载,计算剩余集数
|
||||||
|
left_episodes = lefts.get(mediainfo.tmdb_id, {}).get("episodes", [])
|
||||||
|
logger.info(f'{mediainfo.get_title_string()} 未下载未完整,更新缺失集数为{len(left_episodes)} ...')
|
||||||
|
self.subscribes.update(subscribe.id, {
|
||||||
|
"lack_episode": len(left_episodes)
|
||||||
|
})
|
266
app/chain/user_message.py
Normal file
266
app/chain/user_message.py
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.chain import _ChainBase
|
||||||
|
from app.chain.common import *
|
||||||
|
from app.chain.search import SearchChain
|
||||||
|
from app.core import MediaInfo, TorrentInfo, MetaInfo
|
||||||
|
from app.db.subscribes import Subscribes
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.types import EventType
|
||||||
|
|
||||||
|
|
||||||
|
class UserMessageChain(_ChainBase):
|
||||||
|
"""
|
||||||
|
外来消息处理链
|
||||||
|
"""
|
||||||
|
# 缓存的用户数据 {userid: {type: str, items: list}}
|
||||||
|
_user_cache: Dict[str, dict] = {}
|
||||||
|
# 每页数据量
|
||||||
|
_page_size: int = 8
|
||||||
|
# 当前页面
|
||||||
|
_current_page: int = 0
|
||||||
|
# 当前元数据
|
||||||
|
_current_meta: Optional[MetaInfo] = None
|
||||||
|
# 当前媒体信息
|
||||||
|
_current_media: Optional[MediaInfo] = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.common = CommonChain()
|
||||||
|
self.subscribes = Subscribes()
|
||||||
|
self.searchchain = SearchChain()
|
||||||
|
|
||||||
|
def process(self, request: Request, *args, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
识别消息内容,执行操作
|
||||||
|
"""
|
||||||
|
# 获取消息内容
|
||||||
|
info: dict = self.run_module('message_parser', request=request)
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
# 用户ID
|
||||||
|
userid = info.get('userid')
|
||||||
|
if not userid:
|
||||||
|
logger.debug(f'未识别到用户ID:{request}')
|
||||||
|
return
|
||||||
|
# 消息内容
|
||||||
|
text = str(info.get('text')).strip() if info.get('text') else None
|
||||||
|
if not text:
|
||||||
|
logger.debug(f'未识别到消息内容:{request}')
|
||||||
|
return
|
||||||
|
logger.info(f'收到用户消息内容,用户:{userid},内容:{text}')
|
||||||
|
if text.startswith('/'):
|
||||||
|
# 执行命令
|
||||||
|
self.eventmanager.send_event(
|
||||||
|
EventType.CommandExcute,
|
||||||
|
{
|
||||||
|
"cmd": text
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif text.isdigit():
|
||||||
|
# 缓存
|
||||||
|
cache_data: dict = self._user_cache.get(userid)
|
||||||
|
# 选择项目
|
||||||
|
if not cache_data \
|
||||||
|
or not cache_data.get('items') \
|
||||||
|
or len(cache_data.get('items')) < int(text):
|
||||||
|
# 发送消息
|
||||||
|
self.common.post_message(title="输入有误!", userid=userid)
|
||||||
|
return
|
||||||
|
# 缓存类型
|
||||||
|
cache_type: str = cache_data.get('type')
|
||||||
|
# 缓存列表
|
||||||
|
cache_list: list = cache_data.get('items')
|
||||||
|
# 选择
|
||||||
|
if cache_type == "Search":
|
||||||
|
mediainfo: MediaInfo = cache_list[int(text) - 1]
|
||||||
|
self._current_media = mediainfo
|
||||||
|
# 检查是否已存在
|
||||||
|
exists: list = self.run_module('media_exists', mediainfo=mediainfo)
|
||||||
|
if exists:
|
||||||
|
# 已存在
|
||||||
|
self.common.post_message(
|
||||||
|
title=f"{mediainfo.type.value} {mediainfo.get_title_string()} 媒体库中已存在", userid=userid)
|
||||||
|
return
|
||||||
|
# 搜索种子
|
||||||
|
contexts = self.searchchain.process(meta=self._current_meta, mediainfo=mediainfo)
|
||||||
|
if not contexts:
|
||||||
|
# 没有数据
|
||||||
|
self.common.post_message(title=f"{mediainfo.title} 未搜索到资源!", userid=userid)
|
||||||
|
return
|
||||||
|
# 更新缓存
|
||||||
|
self._user_cache[userid] = {
|
||||||
|
"type": "Torrent",
|
||||||
|
"items": contexts
|
||||||
|
}
|
||||||
|
self._current_page = 0
|
||||||
|
# 发送种子数据
|
||||||
|
self.__post_torrents_message(items=contexts[:self._page_size], userid=userid)
|
||||||
|
|
||||||
|
elif cache_type == "Subscribe":
|
||||||
|
# 订阅媒体
|
||||||
|
mediainfo: MediaInfo = cache_list[int(text) - 1]
|
||||||
|
self._current_media = mediainfo
|
||||||
|
state, msg = self.subscribes.add(mediainfo,
|
||||||
|
season=self._current_meta.begin_season,
|
||||||
|
episode=self._current_meta.begin_episode)
|
||||||
|
if state:
|
||||||
|
# 订阅成功
|
||||||
|
self.common.post_message(
|
||||||
|
title=f"{mediainfo.get_title_string()} 已添加订阅",
|
||||||
|
image=mediainfo.get_message_image(),
|
||||||
|
userid=userid)
|
||||||
|
else:
|
||||||
|
# 订阅失败
|
||||||
|
self.common.post_message(title=f"{mediainfo.title} 添加订阅失败:{msg}", userid=userid)
|
||||||
|
elif cache_type == "Torrent":
|
||||||
|
if int(text) == 0:
|
||||||
|
# 自动选择下载
|
||||||
|
# 查询缺失的媒体信息
|
||||||
|
exist_flag, no_exists = self.common.get_no_exists_info(mediainfo=self._current_media)
|
||||||
|
if exist_flag:
|
||||||
|
self.common.post_message(title=f"{self._current_media.get_title_string()} 媒体库中已存在",
|
||||||
|
userid=userid)
|
||||||
|
return
|
||||||
|
# 批量下载
|
||||||
|
self.common.batch_download(contexts=cache_list, need_tvs=no_exists, userid=userid)
|
||||||
|
else:
|
||||||
|
# 下载种子
|
||||||
|
torrent: TorrentInfo = cache_list[int(text) - 1]
|
||||||
|
# 识别种子信息
|
||||||
|
meta = MetaInfo(torrent.title)
|
||||||
|
# 预处理种子
|
||||||
|
torrent_file, msg = self.run_module("prepare_torrent", torrentinfo=torrent)
|
||||||
|
if not torrent_file:
|
||||||
|
# 下载失败
|
||||||
|
self.run_module('post_message',
|
||||||
|
title=f"{torrent.title} 种子下载失败!",
|
||||||
|
text=f"错误信息:{msg}\n种子链接:{torrent.enclosure}",
|
||||||
|
userid=userid)
|
||||||
|
return
|
||||||
|
# 添加下载
|
||||||
|
state, msg = self.run_module("download_torrent",
|
||||||
|
torrent_path=torrent_file,
|
||||||
|
mediainfo=self._current_media)
|
||||||
|
if not state:
|
||||||
|
# 下载失败
|
||||||
|
self.common.post_message(title=f"{torrent.title} 添加下载失败!",
|
||||||
|
text=f"错误信息:{msg}",
|
||||||
|
userid=userid)
|
||||||
|
return
|
||||||
|
# 下载成功,发送通知
|
||||||
|
self.common.post_download_message(meta=meta, mediainfo=self._current_media, torrent=torrent)
|
||||||
|
|
||||||
|
elif text.lower() == "p":
|
||||||
|
# 上一页
|
||||||
|
cache_data: dict = self._user_cache.get(userid)
|
||||||
|
if not cache_data:
|
||||||
|
# 没有缓存
|
||||||
|
self.common.post_message(title="输入有误!", userid=userid)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._current_page == 0:
|
||||||
|
# 第一页
|
||||||
|
self.common.post_message(title="已经是第一页了!", userid=userid)
|
||||||
|
return
|
||||||
|
cache_type: str = cache_data.get('type')
|
||||||
|
cache_list: list = cache_data.get('items')
|
||||||
|
# 减一页
|
||||||
|
self._current_page -= 1
|
||||||
|
if self._current_page == 0:
|
||||||
|
start = 0
|
||||||
|
end = self._page_size
|
||||||
|
else:
|
||||||
|
start = self._current_page * self._page_size
|
||||||
|
end = start + self._page_size
|
||||||
|
if cache_type == "Torrent":
|
||||||
|
# 发送种子数据
|
||||||
|
self.__post_torrents_message(items=cache_list[start:end], userid=userid)
|
||||||
|
else:
|
||||||
|
# 发送媒体数据
|
||||||
|
self.__post_medias_message(items=cache_list[start:end], userid=userid)
|
||||||
|
|
||||||
|
elif text.lower() == "n":
|
||||||
|
# 下一页
|
||||||
|
cache_data: dict = self._user_cache.get(userid)
|
||||||
|
if not cache_data:
|
||||||
|
# 没有缓存
|
||||||
|
self.common.post_message(title="输入有误!", userid=userid)
|
||||||
|
return
|
||||||
|
cache_type: str = cache_data.get('type')
|
||||||
|
cache_list: list = cache_data.get('items')
|
||||||
|
# 加一页
|
||||||
|
self._current_page += 1
|
||||||
|
cache_list = cache_list[self._current_page * self._page_size:]
|
||||||
|
if not cache_list:
|
||||||
|
# 没有数据
|
||||||
|
self.common.post_message(title="已经是最后一页了!", userid=userid)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
if cache_type == "Torrent":
|
||||||
|
# 发送种子数据
|
||||||
|
self.__post_torrents_message(items=cache_list, userid=userid)
|
||||||
|
else:
|
||||||
|
# 发送媒体数据
|
||||||
|
self.__post_medias_message(items=cache_list, userid=userid)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 搜索或订阅
|
||||||
|
if text.startswith("订阅"):
|
||||||
|
# 订阅
|
||||||
|
content = re.sub(r"订阅[::\s]*", "", text)
|
||||||
|
action = "Subscribe"
|
||||||
|
else:
|
||||||
|
# 搜索
|
||||||
|
content = re.sub(r"(搜索|下载)[::\s]*", "", text)
|
||||||
|
action = "Search"
|
||||||
|
# 提取要素
|
||||||
|
mtype, key_word, season_num, episode_num, year, title = StringUtils.get_keyword(content)
|
||||||
|
# 识别
|
||||||
|
meta = MetaInfo(title)
|
||||||
|
if not meta.get_name():
|
||||||
|
self.common.post_message(title="无法识别输入内容!", userid=userid)
|
||||||
|
return
|
||||||
|
# 合并信息
|
||||||
|
if mtype:
|
||||||
|
meta.type = mtype
|
||||||
|
if season_num:
|
||||||
|
meta.begin_season = season_num
|
||||||
|
if episode_num:
|
||||||
|
meta.begin_episode = episode_num
|
||||||
|
if year:
|
||||||
|
meta.year = year
|
||||||
|
self._current_meta = meta
|
||||||
|
# 开始搜索
|
||||||
|
medias = self.run_module('search_medias', meta=meta)
|
||||||
|
if not medias:
|
||||||
|
self.common.post_message(title=f"{meta.get_name()} 没有找到对应的媒体信息!", userid=userid)
|
||||||
|
return
|
||||||
|
self._user_cache[userid] = {
|
||||||
|
'type': action,
|
||||||
|
'items': medias
|
||||||
|
}
|
||||||
|
self._current_page = 0
|
||||||
|
self._current_media = None
|
||||||
|
# 发送媒体列表
|
||||||
|
self.__post_medias_message(items=medias[:self._page_size], userid=userid)
|
||||||
|
|
||||||
|
def __post_medias_message(self, items: list, userid: str):
|
||||||
|
"""
|
||||||
|
发送媒体列表消息
|
||||||
|
"""
|
||||||
|
self.run_module('post_medias_message',
|
||||||
|
title="请回复数字选择对应媒体(p:上一页, n:下一页)",
|
||||||
|
items=items,
|
||||||
|
userid=userid)
|
||||||
|
|
||||||
|
def __post_torrents_message(self, items: list, userid: str):
|
||||||
|
"""
|
||||||
|
发送种子列表消息
|
||||||
|
"""
|
||||||
|
self.run_module('post_torrents_message',
|
||||||
|
title="请回复数字下载对应资源(0:自动选择, p:上一页, n:下一页)",
|
||||||
|
items=items,
|
||||||
|
userid=userid)
|
20
app/chain/webhook_message.py
Normal file
20
app/chain/webhook_message.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.chain import _ChainBase
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookMessageChain(_ChainBase):
|
||||||
|
"""
|
||||||
|
响应Webhook事件
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process(self, message: dict) -> None:
|
||||||
|
"""
|
||||||
|
处理Webhook报文并发送消息
|
||||||
|
"""
|
||||||
|
# 获取主体内容
|
||||||
|
info = self.run_module('webhook_parser', message=message)
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
# 发送消息
|
||||||
|
self.run_module("post_message", title=info.get("title"), text=info.get("text"), image=info.get("image"))
|
95
app/command.py
Normal file
95
app/command.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.chain.cookiecloud import CookieCloudChain
|
||||||
|
from app.chain.douban_sync import DoubanSyncChain
|
||||||
|
from app.chain.subscribe import SubscribeChain
|
||||||
|
from app.core import eventmanager, PluginManager, EventManager
|
||||||
|
from app.core.event_manager import Event
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
from app.utils.types import EventType
|
||||||
|
|
||||||
|
|
||||||
|
class Command(metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
全局命令管理
|
||||||
|
"""
|
||||||
|
# 内建命令
|
||||||
|
_commands = {
|
||||||
|
"/cookiecloud": {
|
||||||
|
"func": CookieCloudChain().process,
|
||||||
|
"description": "同步CookieCloud的Cookie",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/doubansync": {
|
||||||
|
"func": DoubanSyncChain().process,
|
||||||
|
"description": "同步豆瓣想看",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"/subscribe": {
|
||||||
|
"func": SubscribeChain().search,
|
||||||
|
"description": "刷新所有订阅",
|
||||||
|
"data": {
|
||||||
|
'state': 'R',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 注册插件命令
|
||||||
|
plugin_commands = PluginManager().get_plugin_commands()
|
||||||
|
for command in plugin_commands:
|
||||||
|
self.register(
|
||||||
|
cmd=command.get('cmd'),
|
||||||
|
func=Command.send_plugin_event,
|
||||||
|
desc=command.get('desc'),
|
||||||
|
data={
|
||||||
|
'etype': command.get('event'),
|
||||||
|
'data': command.get('data')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def register(self, cmd: str, func: Any, data: dict = None, desc: str = None) -> None:
|
||||||
|
"""
|
||||||
|
注册命令
|
||||||
|
"""
|
||||||
|
self._commands[cmd] = {
|
||||||
|
"func": func,
|
||||||
|
"description": desc,
|
||||||
|
"data": data or {}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(self, cmd: str) -> Any:
|
||||||
|
"""
|
||||||
|
获取命令
|
||||||
|
"""
|
||||||
|
return self._commands.get(cmd, {})
|
||||||
|
|
||||||
|
def execute(self, cmd: str) -> None:
|
||||||
|
"""
|
||||||
|
执行命令
|
||||||
|
"""
|
||||||
|
command = self.get(cmd)
|
||||||
|
if command:
|
||||||
|
logger.info(f"开始执行:{command.get('description')} ...")
|
||||||
|
data = command['data'] if command.get('data') else {}
|
||||||
|
command['func'](**data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_plugin_event(etype: EventType, data: dict) -> None:
|
||||||
|
"""
|
||||||
|
发送插件命令
|
||||||
|
"""
|
||||||
|
EventManager().send_event(etype, data)
|
||||||
|
|
||||||
|
@eventmanager.register(EventType.CommandExcute)
|
||||||
|
def command_event(self, event: Event) -> None:
|
||||||
|
"""
|
||||||
|
注册命令执行事件
|
||||||
|
event_data: {
|
||||||
|
"cmd": "/xxx"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
cmd = event.event_data.get('cmd')
|
||||||
|
if self.get(cmd):
|
||||||
|
self.execute(cmd)
|
6
app/core/__init__.py
Normal file
6
app/core/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from .config import settings
|
||||||
|
from .event_manager import eventmanager, EventManager
|
||||||
|
from .meta_info import MetaInfo
|
||||||
|
from .module_manager import ModuleManager
|
||||||
|
from .plugin_manager import PluginManager
|
||||||
|
from .context import Context, MediaInfo, TorrentInfo
|
BIN
app/core/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/core/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/config.cpython-310.pyc
Normal file
BIN
app/core/__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/context.cpython-310.pyc
Normal file
BIN
app/core/__pycache__/context.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/event_manager.cpython-310.pyc
Normal file
BIN
app/core/__pycache__/event_manager.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/meta_info.cpython-310.pyc
Normal file
BIN
app/core/__pycache__/meta_info.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/module_manager.cpython-310.pyc
Normal file
BIN
app/core/__pycache__/module_manager.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/plugin_manager.cpython-310.pyc
Normal file
BIN
app/core/__pycache__/plugin_manager.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/security.cpython-310.pyc
Normal file
BIN
app/core/__pycache__/security.cpython-310.pyc
Normal file
Binary file not shown.
182
app/core/config.py
Normal file
182
app/core/config.py
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# 项目名称
|
||||||
|
PROJECT_NAME = "NASbot"
|
||||||
|
# API路径
|
||||||
|
API_V1_STR: str = "/api/v1"
|
||||||
|
# 密钥
|
||||||
|
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||||
|
# TOKEN过期时间
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||||
|
# 时区
|
||||||
|
TZ: str = "Asia/Shanghai"
|
||||||
|
# 监听地址,ipv6改为::
|
||||||
|
HOST: str = "0.0.0.0"
|
||||||
|
# 监听端口
|
||||||
|
PORT: int = 3001
|
||||||
|
# 配置文件目录
|
||||||
|
CONFIG_DIR: str = None
|
||||||
|
# 超级管理员
|
||||||
|
SUPERUSER: str = "admin"
|
||||||
|
# 超级管理员密码
|
||||||
|
SUPERUSER_PASSWORD: str = "password"
|
||||||
|
# API密钥,需要更换
|
||||||
|
API_TOKEN: str = "nasbot"
|
||||||
|
# 网络代理
|
||||||
|
PROXY_HOST: str = None
|
||||||
|
# 媒体信息搜索来源
|
||||||
|
SEARCH_SOURCE: str = "themoviedb"
|
||||||
|
# 刮削来源
|
||||||
|
SCRAP_SOURCE: str = "themoviedb"
|
||||||
|
# TMDB图片地址
|
||||||
|
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
||||||
|
# TMDB API地址
|
||||||
|
TMDB_API_DOMAIN: str = "api.themoviedb.org"
|
||||||
|
# TMDB API Key
|
||||||
|
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||||
|
# Fanart API Key
|
||||||
|
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
||||||
|
# 支持的后缀格式
|
||||||
|
RMT_MEDIAEXT: list = ['.mp4', '.mkv', '.ts', '.iso',
|
||||||
|
'.rmvb', '.avi', '.mov', '.mpeg',
|
||||||
|
'.mpg', '.wmv', '.3gp', '.asf',
|
||||||
|
'.m4v', '.flv', '.m2ts', '.strm',
|
||||||
|
'.tp']
|
||||||
|
# 支持的字幕文件后缀格式
|
||||||
|
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa']
|
||||||
|
# 支持的音轨文件后缀格式
|
||||||
|
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
||||||
|
# 索引器
|
||||||
|
INDEXER: str = "builtin"
|
||||||
|
# 消息通知渠道 telegram/wechat
|
||||||
|
MESSAGER: str = "telegram"
|
||||||
|
# WeChat企业ID
|
||||||
|
WECHAT_CORPID: str = None
|
||||||
|
# WeChat应用Secret
|
||||||
|
WECHAT_APP_SECRET: str = None
|
||||||
|
# WeChat应用ID
|
||||||
|
WECHAT_APP_ID: str = None
|
||||||
|
# WeChat代理服务器
|
||||||
|
WECHAT_PROXY: str = None
|
||||||
|
# WeChat Token
|
||||||
|
WECHAT_TOKEN: str = None
|
||||||
|
# WeChat EncodingAESKey
|
||||||
|
WECHAT_ENCODING_AESKEY: str = None
|
||||||
|
# WeChat 管理员
|
||||||
|
WECHAT_ADMINS: str = None
|
||||||
|
# Telegram Bot Token
|
||||||
|
TELEGRAM_TOKEN: str = None
|
||||||
|
# Telegram Chat ID
|
||||||
|
TELEGRAM_CHAT_ID: str = None
|
||||||
|
# Telegram 用户ID,使用,分隔
|
||||||
|
TELEGRAM_USERS: str = ""
|
||||||
|
# Telegram 管理员ID,使用,分隔
|
||||||
|
TELEGRAM_ADMINS: str = ""
|
||||||
|
# 下载器 qbittorrent/transmission
|
||||||
|
DOWNLOADER: str = "qbittorrent"
|
||||||
|
# Qbittorrent地址
|
||||||
|
QB_HOST: str = None
|
||||||
|
# Qbittorrent用户名
|
||||||
|
QB_USER: str = None
|
||||||
|
# Qbittorrent密码
|
||||||
|
QB_PASSWORD: str = None
|
||||||
|
# Transmission地址
|
||||||
|
TR_HOST: str = None
|
||||||
|
# Transmission用户名
|
||||||
|
TR_USER: str = None
|
||||||
|
# Transmission密码
|
||||||
|
TR_PASSWORD: str = None
|
||||||
|
# 下载保存目录,容器内映射路径需要一致
|
||||||
|
DOWNLOAD_PATH: str = "/downloads"
|
||||||
|
# 媒体服务器 emby/jellyfin/plex
|
||||||
|
MEDIASERVER: str = "emby"
|
||||||
|
# EMBY服务器地址
|
||||||
|
EMBY_HOST: str = None
|
||||||
|
# EMBY Api Key
|
||||||
|
EMBY_API_KEY: str = None
|
||||||
|
# Jellyfin服务器地址
|
||||||
|
JELLYFIN_HOST: str = None
|
||||||
|
# Jellyfin Api Key
|
||||||
|
JELLYFIN_API_KEY: str = None
|
||||||
|
# Plex服务器地址
|
||||||
|
PLEX_HOST: str = None
|
||||||
|
# Plex Token
|
||||||
|
PLEX_TOKEN: str = None
|
||||||
|
# 过滤规则
|
||||||
|
FILTER_RULE: str = ""
|
||||||
|
# 转移方式 link/copy/move/softlink
|
||||||
|
TRANSFER_TYPE: str = "copy"
|
||||||
|
# CookieCloud服务器地址
|
||||||
|
COOKIECLOUD_HOST: str = "https://nastool.org/cookiecloud"
|
||||||
|
# CookieCloud用户KEY
|
||||||
|
COOKIECLOUD_KEY: str = None
|
||||||
|
# CookieCloud端对端加密密码
|
||||||
|
COOKIECLOUD_PASSWORD: str = None
|
||||||
|
# CookieCloud同步间隔(分钟)
|
||||||
|
COOKIECLOUD_INTERVAL: int = 3600
|
||||||
|
# CookieCloud对应的浏览器UA
|
||||||
|
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||||
|
# 媒体库目录
|
||||||
|
LIBRARY_PATH: str = None
|
||||||
|
# 二级分类
|
||||||
|
LIBRARY_CATEGORY: bool = True
|
||||||
|
# 豆瓣用户ID,用于同步豆瓣数据,使用,分隔
|
||||||
|
DOUBAN_USER_IDS: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def INNER_CONFIG_PATH(self):
|
||||||
|
return self.ROOT_PATH / "config"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def CONFIG_PATH(self):
|
||||||
|
if self.CONFIG_DIR:
|
||||||
|
return Path(self.CONFIG_DIR)
|
||||||
|
return self.INNER_CONFIG_PATH
|
||||||
|
|
||||||
|
@property
|
||||||
|
def TEMP_PATH(self):
|
||||||
|
return self.CONFIG_PATH / "temp"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ROOT_PATH(self):
|
||||||
|
return Path(__file__).parents[2]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def PLUGIN_DATA_PATH(self):
|
||||||
|
return self.CONFIG_PATH / "plugins"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def LOG_PATH(self):
|
||||||
|
return self.CONFIG_PATH / "logs"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def PROXY(self):
|
||||||
|
if self.PROXY_HOST:
|
||||||
|
return {
|
||||||
|
"http": self.PROXY_HOST,
|
||||||
|
"https": self.PROXY_HOST
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
with self.CONFIG_PATH as p:
|
||||||
|
if not p.exists():
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.TEMP_PATH as p:
|
||||||
|
if not p.exists():
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.LOG_PATH as p:
|
||||||
|
if not p.exists():
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
421
app/core/context.py
Normal file
421
app/core/context.py
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.meta import MetaBase
|
||||||
|
from app.core.meta_info import MetaInfo
|
||||||
|
from app.utils.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class TorrentInfo(object):
|
||||||
|
# 站点ID
|
||||||
|
site: int = None
|
||||||
|
# 站点名称
|
||||||
|
site_name: Optional[str] = None
|
||||||
|
# 站点Cookie
|
||||||
|
site_cookie: Optional[str] = None
|
||||||
|
# 站点UA
|
||||||
|
site_ua: Optional[str] = None
|
||||||
|
# 站点是否使用代理
|
||||||
|
site_proxy: bool = False
|
||||||
|
# 站点优先级
|
||||||
|
site_order: int = 0
|
||||||
|
# 种子名称
|
||||||
|
title: Optional[str] = None
|
||||||
|
# 种子副标题
|
||||||
|
description: Optional[str] = None
|
||||||
|
# IMDB ID
|
||||||
|
imdbid: str = None
|
||||||
|
# 种子链接
|
||||||
|
enclosure: Optional[str] = None
|
||||||
|
# 详情页面
|
||||||
|
page_url: Optional[str] = None
|
||||||
|
# 种子大小
|
||||||
|
size: float = 0
|
||||||
|
# 做种者
|
||||||
|
seeders: int = 0
|
||||||
|
# 下载者
|
||||||
|
peers: int = 0
|
||||||
|
# 完成者
|
||||||
|
grabs: int = 0
|
||||||
|
# 发布时间
|
||||||
|
pubdate: Optional[str] = None
|
||||||
|
# 已过时间
|
||||||
|
date_elapsed: Optional[str] = None
|
||||||
|
# 上传因子
|
||||||
|
uploadvolumefactor: Optional[float] = None
|
||||||
|
# 下载因子
|
||||||
|
downloadvolumefactor: Optional[float] = None
|
||||||
|
# HR
|
||||||
|
hit_and_run: bool = False
|
||||||
|
# 种子标签
|
||||||
|
labels: Optional[list] = []
|
||||||
|
# 种子优先级
|
||||||
|
pri_order: int = 0
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(self, key) and value is not None:
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
def __getattr__(self, attribute):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __setattr__(self, name: str, value: Any):
|
||||||
|
self.__dict__[name] = value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_free_string(upload_volume_factor, download_volume_factor):
|
||||||
|
"""
|
||||||
|
计算促销类型
|
||||||
|
"""
|
||||||
|
if upload_volume_factor is None or download_volume_factor is None:
|
||||||
|
return "未知"
|
||||||
|
free_strs = {
|
||||||
|
"1.0 1.0": "普通",
|
||||||
|
"1.0 0.0": "免费",
|
||||||
|
"2.0 1.0": "2X",
|
||||||
|
"2.0 0.0": "2X免费",
|
||||||
|
"1.0 0.5": "50%",
|
||||||
|
"2.0 0.5": "2X 50%",
|
||||||
|
"1.0 0.7": "70%",
|
||||||
|
"1.0 0.3": "30%"
|
||||||
|
}
|
||||||
|
return free_strs.get('%.1f %.1f' % (upload_volume_factor, download_volume_factor), "未知")
|
||||||
|
|
||||||
|
def get_volume_factor_string(self):
|
||||||
|
"""
|
||||||
|
返回促销信息
|
||||||
|
"""
|
||||||
|
return self.get_free_string(self.uploadvolumefactor, self.downloadvolumefactor)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaInfo(object):
|
||||||
|
# 类型 电影、电视剧
|
||||||
|
type: MediaType = None
|
||||||
|
# 媒体标题
|
||||||
|
title: Optional[str] = None
|
||||||
|
# 年份
|
||||||
|
year: Optional[str] = None
|
||||||
|
# TMDB ID
|
||||||
|
tmdb_id: Optional[str] = None
|
||||||
|
# IMDB ID
|
||||||
|
imdb_id: Optional[str] = None
|
||||||
|
# TVDB ID
|
||||||
|
tvdb_id: Optional[str] = None
|
||||||
|
# 豆瓣ID
|
||||||
|
douban_id: Optional[str] = None
|
||||||
|
# 媒体原语种
|
||||||
|
original_language: Optional[str] = None
|
||||||
|
# 媒体原发行标题
|
||||||
|
original_title: Optional[str] = None
|
||||||
|
# 媒体发行日期
|
||||||
|
release_date: Optional[str] = None
|
||||||
|
# 背景图片
|
||||||
|
backdrop_path: Optional[str] = None
|
||||||
|
# 海报图片
|
||||||
|
poster_path: Optional[str] = None
|
||||||
|
# 评分
|
||||||
|
vote_average: int = 0
|
||||||
|
# 描述
|
||||||
|
overview: Optional[str] = None
|
||||||
|
# 各季的剧集清单信息
|
||||||
|
seasons: Optional[dict] = {}
|
||||||
|
# 二级分类
|
||||||
|
category: str = ""
|
||||||
|
# TMDB INFO
|
||||||
|
tmdb_info: Optional[dict] = {}
|
||||||
|
# 豆瓣 INFO
|
||||||
|
douban_info: Optional[dict] = {}
|
||||||
|
|
||||||
|
def __init__(self, tmdb_info: dict = None, douban_info: dict = None):
|
||||||
|
if tmdb_info:
|
||||||
|
self.set_tmdb_info(tmdb_info)
|
||||||
|
if douban_info:
|
||||||
|
self.set_douban_info(douban_info)
|
||||||
|
|
||||||
|
def __getattr__(self, attribute):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __setattr__(self, name: str, value: Any):
|
||||||
|
self.__dict__[name] = value
|
||||||
|
|
||||||
|
def set_image(self, name: str, image: str):
|
||||||
|
"""
|
||||||
|
设置图片地址
|
||||||
|
"""
|
||||||
|
setattr(self, f"{name}_path", image)
|
||||||
|
|
||||||
|
def set_category(self, cat: str):
|
||||||
|
"""
|
||||||
|
设置二级分类
|
||||||
|
"""
|
||||||
|
self.category = cat
|
||||||
|
|
||||||
|
def set_tmdb_info(self, info: dict):
|
||||||
|
"""
|
||||||
|
初始化媒信息
|
||||||
|
"""
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
# 本体
|
||||||
|
self.tmdb_info = info
|
||||||
|
# 类型
|
||||||
|
self.type = info.get('media_type')
|
||||||
|
# TMDBID
|
||||||
|
self.tmdb_id = info.get('id')
|
||||||
|
if not self.tmdb_id:
|
||||||
|
return
|
||||||
|
# 额外ID
|
||||||
|
if info.get("external_ids"):
|
||||||
|
self.tvdb_id = info.get("external_ids", {}).get("tvdb_id", 0)
|
||||||
|
self.imdb_id = info.get("external_ids", {}).get("imdb_id", "")
|
||||||
|
# 评分
|
||||||
|
self.vote_average = round(float(info.get('vote_average')), 1) if info.get('vote_average') else 0
|
||||||
|
# 描述
|
||||||
|
self.overview = info.get('overview')
|
||||||
|
# 原语种
|
||||||
|
self.original_language = info.get('original_language')
|
||||||
|
if self.type == MediaType.MOVIE:
|
||||||
|
# 标题
|
||||||
|
self.title = info.get('title')
|
||||||
|
# 原标题
|
||||||
|
self.original_title = info.get('original_title')
|
||||||
|
# 发行日期
|
||||||
|
self.release_date = info.get('release_date')
|
||||||
|
if self.release_date:
|
||||||
|
# 年份
|
||||||
|
self.year = self.release_date[:4]
|
||||||
|
else:
|
||||||
|
# 电视剧
|
||||||
|
self.title = info.get('name')
|
||||||
|
# 原标题
|
||||||
|
self.original_title = info.get('original_name')
|
||||||
|
# 发行日期
|
||||||
|
self.release_date = info.get('first_air_date')
|
||||||
|
if self.release_date:
|
||||||
|
# 年份
|
||||||
|
self.year = self.release_date[:4]
|
||||||
|
# 季集信息
|
||||||
|
if info.get('seasons'):
|
||||||
|
for season_info in info.get('seasons'):
|
||||||
|
if not season_info.get("season_number"):
|
||||||
|
continue
|
||||||
|
episode_count = season_info.get("episode_count")
|
||||||
|
self.seasons[season_info.get("season_number")] = list(range(1, episode_count + 1))
|
||||||
|
# 海报
|
||||||
|
if info.get('poster_path'):
|
||||||
|
self.poster_path = f"https://{settings.TMDB_IMAGE_DOMAIN}{info.get('poster_path')}"
|
||||||
|
# 背景
|
||||||
|
if info.get('backdrop_path'):
|
||||||
|
self.backdrop_path = f"https://{settings.TMDB_IMAGE_DOMAIN}{info.get('backdrop_path')}"
|
||||||
|
|
||||||
|
def set_douban_info(self, info: dict):
|
||||||
|
"""
|
||||||
|
初始化豆瓣信息
|
||||||
|
"""
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
# 本体
|
||||||
|
self.douban_info = info
|
||||||
|
# 豆瓣ID
|
||||||
|
self.douban_id = info.get("id")
|
||||||
|
# 评分
|
||||||
|
if not self.vote_average:
|
||||||
|
rating = info.get('rating')
|
||||||
|
if rating:
|
||||||
|
vote_average = float(rating.get("value"))
|
||||||
|
else:
|
||||||
|
vote_average = 0
|
||||||
|
self.vote_average = vote_average
|
||||||
|
# 标题
|
||||||
|
if not self.title:
|
||||||
|
self.title = info.get('title')
|
||||||
|
# 年份
|
||||||
|
if not self.year:
|
||||||
|
self.year = info.get('year')[:4] if info.get('year') else None
|
||||||
|
# 原语种标题
|
||||||
|
if not self.original_title:
|
||||||
|
self.original_title = info.get("original_title")
|
||||||
|
# 类型
|
||||||
|
if not self.type:
|
||||||
|
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
|
||||||
|
if not self.poster_path:
|
||||||
|
if self.type == MediaType.MOVIE:
|
||||||
|
# 海报
|
||||||
|
poster_path = info.get('cover', {}).get("url")
|
||||||
|
if not poster_path:
|
||||||
|
poster_path = info.get('cover_url')
|
||||||
|
if not poster_path:
|
||||||
|
poster_path = info.get('pic', {}).get("large")
|
||||||
|
else:
|
||||||
|
# 海报
|
||||||
|
poster_path = info.get('pic', {}).get("normal")
|
||||||
|
self.poster_path = poster_path
|
||||||
|
# 简介
|
||||||
|
if not self.overview:
|
||||||
|
overview = info.get("card_subtitle") or ""
|
||||||
|
if not self.year and overview:
|
||||||
|
if overview.split("/")[0].strip().isdigit():
|
||||||
|
self.year = overview.split("/")[0].strip()
|
||||||
|
|
||||||
|
def get_detail_url(self):
|
||||||
|
"""
|
||||||
|
TMDB媒体详情页地址
|
||||||
|
"""
|
||||||
|
if self.tmdb_id:
|
||||||
|
if self.type == MediaType.MOVIE:
|
||||||
|
return "https://www.themoviedb.org/movie/%s" % self.tmdb_id
|
||||||
|
else:
|
||||||
|
return "https://www.themoviedb.org/tv/%s" % self.tmdb_id
|
||||||
|
elif self.douban_id:
|
||||||
|
return "https://movie.douban.com/subject/%s" % self.douban_id
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_stars(self):
|
||||||
|
"""
|
||||||
|
返回评分星星个数
|
||||||
|
"""
|
||||||
|
if not self.vote_average:
|
||||||
|
return ""
|
||||||
|
return "".rjust(int(self.vote_average), "★")
|
||||||
|
|
||||||
|
def get_star_string(self):
|
||||||
|
if self.vote_average:
|
||||||
|
return "评分:%s" % self.get_stars()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_backdrop_image(self, default: bool = False):
|
||||||
|
"""
|
||||||
|
返回背景图片地址
|
||||||
|
"""
|
||||||
|
if self.backdrop_path:
|
||||||
|
return self.backdrop_path
|
||||||
|
return default or ""
|
||||||
|
|
||||||
|
def get_message_image(self, default: bool = None):
|
||||||
|
"""
|
||||||
|
返回消息图片地址
|
||||||
|
"""
|
||||||
|
if self.backdrop_path:
|
||||||
|
return self.backdrop_path
|
||||||
|
return self.get_poster_image(default=default)
|
||||||
|
|
||||||
|
def get_poster_image(self, default: bool = None):
|
||||||
|
"""
|
||||||
|
返回海报图片地址
|
||||||
|
"""
|
||||||
|
if self.poster_path:
|
||||||
|
return self.poster_path
|
||||||
|
return default or ""
|
||||||
|
|
||||||
|
def get_title_string(self):
|
||||||
|
if self.title:
|
||||||
|
return "%s (%s)" % (self.title, self.year) if self.year else self.title
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_overview_string(self, max_len: int = 140):
|
||||||
|
"""
|
||||||
|
返回带限定长度的简介信息
|
||||||
|
:param max_len: 内容长度
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
overview = str(self.overview).strip()
|
||||||
|
placeholder = ' ...'
|
||||||
|
max_len = max(len(placeholder), max_len - len(placeholder))
|
||||||
|
overview = (overview[:max_len] + placeholder) if len(overview) > max_len else overview
|
||||||
|
return overview
|
||||||
|
|
||||||
|
def get_season_episodes(self, sea: int) -> list:
|
||||||
|
"""
|
||||||
|
返回指定季度的剧集信息
|
||||||
|
"""
|
||||||
|
if not self.seasons:
|
||||||
|
return []
|
||||||
|
return self.seasons.get(sea) or []
|
||||||
|
|
||||||
|
|
||||||
|
class Context(object):
|
||||||
|
"""
|
||||||
|
上下文对象
|
||||||
|
"""
|
||||||
|
# 识别前的信息
|
||||||
|
title: Optional[str] = None
|
||||||
|
subtitle: Optional[str] = None
|
||||||
|
|
||||||
|
# 用户信息
|
||||||
|
userid: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
|
||||||
|
# 操作类型
|
||||||
|
action: Optional[str] = None
|
||||||
|
|
||||||
|
# 识别信息
|
||||||
|
_meta_info: Optional[MetaBase] = None
|
||||||
|
# 种子信息
|
||||||
|
_torrent_info: Optional[TorrentInfo] = None
|
||||||
|
# 媒体信息
|
||||||
|
_media_info: Optional[MediaInfo] = None
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
meta: MetaBase = None,
|
||||||
|
mediainfo: MediaInfo = None,
|
||||||
|
torrentinfo: TorrentInfo = None,
|
||||||
|
**kwargs):
|
||||||
|
if meta:
|
||||||
|
self._meta_info = meta
|
||||||
|
if mediainfo:
|
||||||
|
self._media_info = mediainfo
|
||||||
|
if torrentinfo:
|
||||||
|
self._torrent_info = torrentinfo
|
||||||
|
if kwargs:
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meta_info(self):
|
||||||
|
return self._meta_info
|
||||||
|
|
||||||
|
def set_meta_info(self, title: str, subtitle: str = None):
|
||||||
|
self._meta_info = MetaInfo(title, subtitle)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_info(self):
|
||||||
|
return self._media_info
|
||||||
|
|
||||||
|
def set_media_info(self,
|
||||||
|
tmdb_info: dict = None,
|
||||||
|
douban_info: dict = None):
|
||||||
|
self._media_info = MediaInfo(tmdb_info, douban_info)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def torrent_info(self):
|
||||||
|
return self._torrent_info
|
||||||
|
|
||||||
|
def set_torrent_info(self, info: dict):
|
||||||
|
self._torrent_info = TorrentInfo(**info)
|
||||||
|
|
||||||
|
def __getattr__(self, attribute):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __setattr__(self, name: str, value: Any):
|
||||||
|
self.__dict__[name] = value
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""
|
||||||
|
转换为字典
|
||||||
|
"""
|
||||||
|
def object_to_dict(obj):
|
||||||
|
attributes = [
|
||||||
|
attr for attr in dir(obj)
|
||||||
|
if not callable(getattr(obj, attr)) and not attr.startswith("_")
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
attr: getattr(obj, attr).value
|
||||||
|
if isinstance(getattr(obj, attr), MediaType)
|
||||||
|
else getattr(obj, attr) for attr in attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"meta_info": object_to_dict(self.meta_info),
|
||||||
|
"media_info": object_to_dict(self.media_info)
|
||||||
|
}
|
105
app/core/event_manager.py
Normal file
105
app/core/event_manager.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
from queue import Queue, Empty
|
||||||
|
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
from app.utils.types import EventType
|
||||||
|
|
||||||
|
|
||||||
|
class EventManager(metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
事件管理器
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 事件队列
|
||||||
|
_eventQueue: Queue = None
|
||||||
|
# 事件响应函数字典
|
||||||
|
_handlers: dict = {}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 事件队列
|
||||||
|
self._eventQueue = Queue()
|
||||||
|
# 事件响应函数字典
|
||||||
|
self._handlers = {}
|
||||||
|
|
||||||
|
def get_event(self):
|
||||||
|
"""
|
||||||
|
获取事件
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
event = self._eventQueue.get(block=True, timeout=1)
|
||||||
|
handlerList = self._handlers.get(event.event_type)
|
||||||
|
return event, handlerList or []
|
||||||
|
except Empty:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
def add_event_listener(self, etype: EventType, handler: type):
|
||||||
|
"""
|
||||||
|
注册事件处理
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
handlerList = self._handlers[etype.value]
|
||||||
|
except KeyError:
|
||||||
|
handlerList = []
|
||||||
|
self._handlers[etype.value] = handlerList
|
||||||
|
if handler not in handlerList:
|
||||||
|
handlerList.append(handler)
|
||||||
|
logger.debug(f"Event Registed:{etype.value} - {handler}")
|
||||||
|
|
||||||
|
def remove_event_listener(self, etype: EventType, handler: type):
|
||||||
|
"""
|
||||||
|
移除监听器的处理函数
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
handlerList = self._handlers[etype.value]
|
||||||
|
if handler in handlerList[:]:
|
||||||
|
handlerList.remove(handler)
|
||||||
|
if not handlerList:
|
||||||
|
del self._handlers[etype.value]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send_event(self, etype: EventType, data: dict = None):
|
||||||
|
"""
|
||||||
|
发送事件
|
||||||
|
"""
|
||||||
|
if etype not in EventType:
|
||||||
|
return
|
||||||
|
event = Event(etype.value)
|
||||||
|
event.event_data = data or {}
|
||||||
|
logger.debug(f"发送事件:{etype.value} - {event.event_data}")
|
||||||
|
self._eventQueue.put(event)
|
||||||
|
|
||||||
|
def register(self, etype: [EventType, list]):
|
||||||
|
"""
|
||||||
|
事件注册
|
||||||
|
:param etype: 事件类型
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(f):
|
||||||
|
if isinstance(etype, list):
|
||||||
|
for et in etype:
|
||||||
|
self.add_event_listener(et, f)
|
||||||
|
elif type(etype) == type(EventType):
|
||||||
|
for et in etype.__members__.values():
|
||||||
|
self.add_event_listener(et, f)
|
||||||
|
else:
|
||||||
|
self.add_event_listener(etype, f)
|
||||||
|
return f
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class Event(object):
|
||||||
|
"""
|
||||||
|
事件对象
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, event_type=None):
|
||||||
|
# 事件类型
|
||||||
|
self.event_type = event_type
|
||||||
|
# 字典用于保存具体的事件数据
|
||||||
|
self.event_data = {}
|
||||||
|
|
||||||
|
|
||||||
|
# 实例引用,用于注册事件
|
||||||
|
eventmanager = EventManager()
|
3
app/core/meta/__init__.py
Normal file
3
app/core/meta/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .metabase import MetaBase
|
||||||
|
from .metavideo import MetaVideo
|
||||||
|
from .metaanime import MetaAnime
|
BIN
app/core/meta/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/core/meta/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/meta/__pycache__/metaanime.cpython-310.pyc
Normal file
BIN
app/core/meta/__pycache__/metaanime.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/meta/__pycache__/metabase.cpython-310.pyc
Normal file
BIN
app/core/meta/__pycache__/metabase.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/meta/__pycache__/metavideo.cpython-310.pyc
Normal file
BIN
app/core/meta/__pycache__/metavideo.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/core/meta/__pycache__/release_groups.cpython-310.pyc
Normal file
BIN
app/core/meta/__pycache__/release_groups.cpython-310.pyc
Normal file
Binary file not shown.
218
app/core/meta/metaanime.py
Normal file
218
app/core/meta/metaanime.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import re
|
||||||
|
import zhconv
|
||||||
|
import anitopy
|
||||||
|
from app.core.meta.metabase import MetaBase
|
||||||
|
from app.core.meta.release_groups import ReleaseGroupsMatcher
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
from app.utils.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class MetaAnime(MetaBase):
|
||||||
|
"""
|
||||||
|
识别动漫
|
||||||
|
"""
|
||||||
|
_anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL']
|
||||||
|
_name_nostring_re = r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}"
|
||||||
|
|
||||||
|
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||||
|
super().__init__(title, subtitle, isfile)
|
||||||
|
if not title:
|
||||||
|
return
|
||||||
|
# 调用第三方模块识别动漫
|
||||||
|
try:
|
||||||
|
original_title = title
|
||||||
|
# 字幕组信息会被预处理掉
|
||||||
|
anitopy_info_origin = anitopy.parse(title)
|
||||||
|
title = self.__prepare_title(title)
|
||||||
|
anitopy_info = anitopy.parse(title)
|
||||||
|
if anitopy_info:
|
||||||
|
# 名称
|
||||||
|
name = anitopy_info.get("anime_title")
|
||||||
|
if name and name.find("/") != -1:
|
||||||
|
name = name.split("/")[-1].strip()
|
||||||
|
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
|
||||||
|
anitopy_info = anitopy.parse("[ANIME]" + title)
|
||||||
|
if anitopy_info:
|
||||||
|
name = anitopy_info.get("anime_title")
|
||||||
|
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
|
||||||
|
name_match = re.search(r'\[(.+?)]', title)
|
||||||
|
if name_match and name_match.group(1):
|
||||||
|
name = name_match.group(1).strip()
|
||||||
|
# 拆份中英文名称
|
||||||
|
if name:
|
||||||
|
lastword_type = ""
|
||||||
|
for word in name.split():
|
||||||
|
if not word:
|
||||||
|
continue
|
||||||
|
if word.endswith(']'):
|
||||||
|
word = word[:-1]
|
||||||
|
if word.isdigit():
|
||||||
|
if lastword_type == "cn":
|
||||||
|
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
||||||
|
elif lastword_type == "en":
|
||||||
|
self.en_name = "%s %s" % (self.en_name or "", word)
|
||||||
|
elif StringUtils.is_chinese(word):
|
||||||
|
self.cn_name = "%s %s" % (self.cn_name or "", word)
|
||||||
|
lastword_type = "cn"
|
||||||
|
else:
|
||||||
|
self.en_name = "%s %s" % (self.en_name or "", word)
|
||||||
|
lastword_type = "en"
|
||||||
|
if self.cn_name:
|
||||||
|
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
|
||||||
|
if self.cn_name:
|
||||||
|
self.cn_name = re.sub(r'%s' % self._name_nostring_re, '', self.cn_name, flags=re.IGNORECASE).strip()
|
||||||
|
self.cn_name = zhconv.convert(self.cn_name, "zh-hans")
|
||||||
|
if self.en_name:
|
||||||
|
self.en_name = re.sub(r'%s' % self._name_nostring_re, '', self.en_name, flags=re.IGNORECASE).strip().title()
|
||||||
|
self._name = StringUtils.str_title(self.en_name)
|
||||||
|
# 年份
|
||||||
|
year = anitopy_info.get("anime_year")
|
||||||
|
if str(year).isdigit():
|
||||||
|
self.year = str(year)
|
||||||
|
# 季号
|
||||||
|
anime_season = anitopy_info.get("anime_season")
|
||||||
|
if isinstance(anime_season, list):
|
||||||
|
if len(anime_season) == 1:
|
||||||
|
begin_season = anime_season[0]
|
||||||
|
end_season = None
|
||||||
|
else:
|
||||||
|
begin_season = anime_season[0]
|
||||||
|
end_season = anime_season[-1]
|
||||||
|
elif anime_season:
|
||||||
|
begin_season = anime_season
|
||||||
|
end_season = None
|
||||||
|
else:
|
||||||
|
begin_season = None
|
||||||
|
end_season = None
|
||||||
|
if begin_season:
|
||||||
|
self.begin_season = int(begin_season)
|
||||||
|
if end_season and int(end_season) != self.begin_season:
|
||||||
|
self.end_season = int(end_season)
|
||||||
|
self.total_seasons = (self.end_season - self.begin_season) + 1
|
||||||
|
else:
|
||||||
|
self.total_seasons = 1
|
||||||
|
self.type = MediaType.TV
|
||||||
|
# 集号
|
||||||
|
episode_number = anitopy_info.get("episode_number")
|
||||||
|
if isinstance(episode_number, list):
|
||||||
|
if len(episode_number) == 1:
|
||||||
|
begin_episode = episode_number[0]
|
||||||
|
end_episode = None
|
||||||
|
else:
|
||||||
|
begin_episode = episode_number[0]
|
||||||
|
end_episode = episode_number[-1]
|
||||||
|
elif episode_number:
|
||||||
|
begin_episode = episode_number
|
||||||
|
end_episode = None
|
||||||
|
else:
|
||||||
|
begin_episode = None
|
||||||
|
end_episode = None
|
||||||
|
if begin_episode:
|
||||||
|
try:
|
||||||
|
self.begin_episode = int(begin_episode)
|
||||||
|
if end_episode and int(end_episode) != self.begin_episode:
|
||||||
|
self.end_episode = int(end_episode)
|
||||||
|
self.total_episodes = (self.end_episode - self.begin_episode) + 1
|
||||||
|
else:
|
||||||
|
self.total_episodes = 1
|
||||||
|
except Exception as err:
|
||||||
|
print(str(err))
|
||||||
|
self.begin_episode = None
|
||||||
|
self.end_episode = None
|
||||||
|
self.type = MediaType.TV
|
||||||
|
# 类型
|
||||||
|
if not self.type:
|
||||||
|
anime_type = anitopy_info.get('anime_type')
|
||||||
|
if isinstance(anime_type, list):
|
||||||
|
anime_type = anime_type[0]
|
||||||
|
if anime_type and anime_type.upper() == "TV":
|
||||||
|
self.type = MediaType.TV
|
||||||
|
else:
|
||||||
|
self.type = MediaType.MOVIE
|
||||||
|
# 分辨率
|
||||||
|
self.resource_pix = anitopy_info.get("video_resolution")
|
||||||
|
if isinstance(self.resource_pix, list):
|
||||||
|
self.resource_pix = self.resource_pix[0]
|
||||||
|
if self.resource_pix:
|
||||||
|
if re.search(r'x', self.resource_pix, re.IGNORECASE):
|
||||||
|
self.resource_pix = re.split(r'[Xx]', self.resource_pix)[-1] + "p"
|
||||||
|
else:
|
||||||
|
self.resource_pix = self.resource_pix.lower()
|
||||||
|
if str(self.resource_pix).isdigit():
|
||||||
|
self.resource_pix = str(self.resource_pix) + "p"
|
||||||
|
# 制作组/字幕组
|
||||||
|
self.resource_team = \
|
||||||
|
ReleaseGroupsMatcher().match(title=original_title) or \
|
||||||
|
anitopy_info_origin.get("release_group") or None
|
||||||
|
# 视频编码
|
||||||
|
self.video_encode = anitopy_info.get("video_term")
|
||||||
|
if isinstance(self.video_encode, list):
|
||||||
|
self.video_encode = self.video_encode[0]
|
||||||
|
# 音频编码
|
||||||
|
self.audio_encode = anitopy_info.get("audio_term")
|
||||||
|
if isinstance(self.audio_encode, list):
|
||||||
|
self.audio_encode = self.audio_encode[0]
|
||||||
|
# 解析副标题,只要季和集
|
||||||
|
self.init_subtitle(self.org_string)
|
||||||
|
if not self._subtitle_flag and self.subtitle:
|
||||||
|
self.init_subtitle(self.subtitle)
|
||||||
|
if not self.type:
|
||||||
|
self.type = MediaType.TV
|
||||||
|
except Exception as e:
|
||||||
|
print(str(e))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __prepare_title(title: str):
|
||||||
|
"""
|
||||||
|
对命名进行预处理
|
||||||
|
"""
|
||||||
|
if not title:
|
||||||
|
return title
|
||||||
|
# 所有【】换成[]
|
||||||
|
title = title.replace("【", "[").replace("】", "]").strip()
|
||||||
|
# 截掉xx番剧漫
|
||||||
|
match = re.search(r"新番|月?番|[日美国][漫剧]", title)
|
||||||
|
if match and match.span()[1] < len(title) - 1:
|
||||||
|
title = re.sub(".*番.|.*[日美国][漫剧].", "", title)
|
||||||
|
elif match:
|
||||||
|
title = title[:title.rfind('[')]
|
||||||
|
# 截掉分类
|
||||||
|
first_item = title.split(']')[0]
|
||||||
|
if first_item and re.search(r"[动漫画纪录片电影视连续剧集日美韩中港台海外亚洲华语大陆综艺原盘高清]{2,}|TV|Animation|Movie|Documentar|Anime",
|
||||||
|
zhconv.convert(first_item, "zh-hans"),
|
||||||
|
re.IGNORECASE):
|
||||||
|
title = re.sub(r"^[^]]*]", "", title).strip()
|
||||||
|
# 去掉大小
|
||||||
|
title = re.sub(r'[0-9.]+\s*[MGT]i?B(?![A-Z]+)', "", title, flags=re.IGNORECASE)
|
||||||
|
# 将TVxx改为xx
|
||||||
|
title = re.sub(r"\[TV\s+(\d{1,4})", r"[\1", title, flags=re.IGNORECASE)
|
||||||
|
# 将4K转为2160p
|
||||||
|
title = re.sub(r'\[4k]', '2160p', title, flags=re.IGNORECASE)
|
||||||
|
# 处理/分隔的中英文标题
|
||||||
|
names = title.split("]")
|
||||||
|
if len(names) > 1 and title.find("- ") == -1:
|
||||||
|
titles = []
|
||||||
|
for name in names:
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
left_char = ''
|
||||||
|
if name.startswith('['):
|
||||||
|
left_char = '['
|
||||||
|
name = name[1:]
|
||||||
|
if name and name.find("/") != -1:
|
||||||
|
if name.split("/")[-1].strip():
|
||||||
|
titles.append("%s%s" % (left_char, name.split("/")[-1].strip()))
|
||||||
|
else:
|
||||||
|
titles.append("%s%s" % (left_char, name.split("/")[0].strip()))
|
||||||
|
elif name:
|
||||||
|
if StringUtils.is_chinese(name) and not StringUtils.is_all_chinese(name):
|
||||||
|
if not re.search(r"\[\d+", name, re.IGNORECASE):
|
||||||
|
name = re.sub(r'[\d|#::\-()()\u4e00-\u9fff]', '', name).strip()
|
||||||
|
if not name or name.strip().isdigit():
|
||||||
|
continue
|
||||||
|
if name == '[':
|
||||||
|
titles.append("")
|
||||||
|
else:
|
||||||
|
titles.append("%s%s" % (left_char, name.strip()))
|
||||||
|
return "]".join(titles)
|
||||||
|
return title
|
427
app/core/meta/metabase.py
Normal file
427
app/core/meta/metabase.py
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
from typing import Union, Optional
|
||||||
|
|
||||||
|
import cn2an
|
||||||
|
import regex as re
|
||||||
|
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
from app.utils.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class MetaBase(object):
|
||||||
|
"""
|
||||||
|
媒体信息基类
|
||||||
|
"""
|
||||||
|
# 是否处理的文件
|
||||||
|
isfile: bool = False
|
||||||
|
# 原字符串
|
||||||
|
org_string: Optional[str] = None
|
||||||
|
# 副标题
|
||||||
|
subtitle: Optional[str] = None
|
||||||
|
# 类型 电影、电视剧
|
||||||
|
type: Optional[MediaType] = None
|
||||||
|
# 识别的中文名
|
||||||
|
cn_name: Optional[str] = None
|
||||||
|
# 识别的英文名
|
||||||
|
en_name: Optional[str] = None
|
||||||
|
# 年份
|
||||||
|
year: Optional[str] = None
|
||||||
|
# 总季数
|
||||||
|
total_seasons: int = 0
|
||||||
|
# 识别的开始季 数字
|
||||||
|
begin_season: Optional[int] = None
|
||||||
|
# 识别的结束季 数字
|
||||||
|
end_season: Optional[int] = None
|
||||||
|
# 总集数
|
||||||
|
total_episodes: int = 0
|
||||||
|
# 识别的开始集
|
||||||
|
begin_episode: Optional[int] = None
|
||||||
|
# 识别的结束集
|
||||||
|
end_episode: Optional[int] = None
|
||||||
|
# Partx Cd Dvd Disk Disc
|
||||||
|
part: Optional[str] = None
|
||||||
|
# 识别的资源类型
|
||||||
|
resource_type: Optional[str] = None
|
||||||
|
# 识别的效果
|
||||||
|
resource_effect: Optional[str] = None
|
||||||
|
# 识别的分辨率
|
||||||
|
resource_pix: Optional[str] = None
|
||||||
|
# 识别的制作组/字幕组
|
||||||
|
resource_team: Optional[str] = None
|
||||||
|
# 视频编码
|
||||||
|
video_encode: Optional[str] = None
|
||||||
|
# 音频编码
|
||||||
|
audio_encode: Optional[str] = None
|
||||||
|
|
||||||
|
# 副标题解析
|
||||||
|
_subtitle_flag = False
|
||||||
|
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
|
||||||
|
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
|
||||||
|
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP\-]+)\s*[集话話期](?!\s*[全共])"
|
||||||
|
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期]"
|
||||||
|
|
||||||
|
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||||
|
if not title:
|
||||||
|
return
|
||||||
|
self.org_string = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.isfile = isfile
|
||||||
|
|
||||||
|
def get_name(self):
|
||||||
|
"""
|
||||||
|
返回名称
|
||||||
|
"""
|
||||||
|
if self.cn_name and StringUtils.is_all_chinese(self.cn_name):
|
||||||
|
return self.cn_name
|
||||||
|
elif self.en_name:
|
||||||
|
return self.en_name
|
||||||
|
elif self.cn_name:
|
||||||
|
return self.cn_name
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def init_subtitle(self, title_text: str):
|
||||||
|
"""
|
||||||
|
副标题识别
|
||||||
|
"""
|
||||||
|
if not title_text:
|
||||||
|
return
|
||||||
|
title_text = f" {title_text} "
|
||||||
|
if re.search(r'[全第季集话話期]', title_text, re.IGNORECASE):
|
||||||
|
# 第x季
|
||||||
|
season_str = re.search(r'%s' % self._subtitle_season_re, title_text, re.IGNORECASE)
|
||||||
|
if season_str:
|
||||||
|
seasons = season_str.group(1)
|
||||||
|
if seasons:
|
||||||
|
seasons = seasons.upper().replace("S", "").strip()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
end_season = None
|
||||||
|
if seasons.find('-') != -1:
|
||||||
|
seasons = seasons.split('-')
|
||||||
|
begin_season = int(cn2an.cn2an(seasons[0].strip(), mode='smart'))
|
||||||
|
if len(seasons) > 1:
|
||||||
|
end_season = int(cn2an.cn2an(seasons[1].strip(), mode='smart'))
|
||||||
|
else:
|
||||||
|
begin_season = int(cn2an.cn2an(seasons, mode='smart'))
|
||||||
|
except Exception as err:
|
||||||
|
print(str(err))
|
||||||
|
return
|
||||||
|
if self.begin_season is None and isinstance(begin_season, int):
|
||||||
|
self.begin_season = begin_season
|
||||||
|
self.total_seasons = 1
|
||||||
|
if self.begin_season is not None \
|
||||||
|
and self.end_season is None \
|
||||||
|
and isinstance(end_season, int) \
|
||||||
|
and end_season != self.begin_season:
|
||||||
|
self.end_season = end_season
|
||||||
|
self.total_seasons = (self.end_season - self.begin_season) + 1
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
# 第x集
|
||||||
|
episode_str = re.search(r'%s' % self._subtitle_episode_re, title_text, re.IGNORECASE)
|
||||||
|
if episode_str:
|
||||||
|
episodes = episode_str.group(1)
|
||||||
|
if episodes:
|
||||||
|
episodes = episodes.upper().replace("E", "").replace("P", "").strip()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
end_episode = None
|
||||||
|
if episodes.find('-') != -1:
|
||||||
|
episodes = episodes.split('-')
|
||||||
|
begin_episode = int(cn2an.cn2an(episodes[0].strip(), mode='smart'))
|
||||||
|
if len(episodes) > 1:
|
||||||
|
end_episode = int(cn2an.cn2an(episodes[1].strip(), mode='smart'))
|
||||||
|
else:
|
||||||
|
begin_episode = int(cn2an.cn2an(episodes, mode='smart'))
|
||||||
|
except Exception as err:
|
||||||
|
print(str(err))
|
||||||
|
return
|
||||||
|
if self.begin_episode is None and isinstance(begin_episode, int):
|
||||||
|
self.begin_episode = begin_episode
|
||||||
|
self.total_episodes = 1
|
||||||
|
if self.begin_episode is not None \
|
||||||
|
and self.end_episode is None \
|
||||||
|
and isinstance(end_episode, int) \
|
||||||
|
and end_episode != self.begin_episode:
|
||||||
|
self.end_episode = end_episode
|
||||||
|
self.total_episodes = (self.end_episode - self.begin_episode) + 1
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
# x集全
|
||||||
|
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
||||||
|
if episode_all_str:
|
||||||
|
episode_all = episode_all_str.group(1)
|
||||||
|
if not episode_all:
|
||||||
|
episode_all = episode_all_str.group(2)
|
||||||
|
if episode_all and self.begin_episode is None:
|
||||||
|
try:
|
||||||
|
self.total_episodes = int(cn2an.cn2an(episode_all.strip(), mode='smart'))
|
||||||
|
except Exception as err:
|
||||||
|
print(str(err))
|
||||||
|
return
|
||||||
|
self.begin_episode = None
|
||||||
|
self.end_episode = None
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
# 全x季 x季全
|
||||||
|
season_all_str = re.search(r"%s" % self._subtitle_season_all_re, title_text, re.IGNORECASE)
|
||||||
|
if season_all_str:
|
||||||
|
season_all = season_all_str.group(1)
|
||||||
|
if not season_all:
|
||||||
|
season_all = season_all_str.group(2)
|
||||||
|
if season_all and self.begin_season is None and self.begin_episode is None:
|
||||||
|
try:
|
||||||
|
self.total_seasons = int(cn2an.cn2an(season_all.strip(), mode='smart'))
|
||||||
|
except Exception as err:
|
||||||
|
print(str(err))
|
||||||
|
return
|
||||||
|
self.begin_season = 1
|
||||||
|
self.end_season = self.total_seasons
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._subtitle_flag = True
|
||||||
|
|
||||||
|
def is_in_season(self, season: Union[list, int, str]):
|
||||||
|
"""
|
||||||
|
是否包含季
|
||||||
|
"""
|
||||||
|
if isinstance(season, list):
|
||||||
|
if self.end_season is not None:
|
||||||
|
meta_season = list(range(self.begin_season, self.end_season + 1))
|
||||||
|
else:
|
||||||
|
if self.begin_season is not None:
|
||||||
|
meta_season = [self.begin_season]
|
||||||
|
else:
|
||||||
|
meta_season = [1]
|
||||||
|
|
||||||
|
return set(meta_season).issuperset(set(season))
|
||||||
|
else:
|
||||||
|
if self.end_season is not None:
|
||||||
|
return self.begin_season <= int(season) <= self.end_season
|
||||||
|
else:
|
||||||
|
if self.begin_season is not None:
|
||||||
|
return int(season) == self.begin_season
|
||||||
|
else:
|
||||||
|
return int(season) == 1
|
||||||
|
|
||||||
|
def is_in_episode(self, episode: Union[list, int, str]):
|
||||||
|
"""
|
||||||
|
是否包含集
|
||||||
|
"""
|
||||||
|
if isinstance(episode, list):
|
||||||
|
if self.end_episode is not None:
|
||||||
|
meta_episode = list(range(self.begin_episode, self.end_episode + 1))
|
||||||
|
else:
|
||||||
|
meta_episode = [self.begin_episode]
|
||||||
|
return set(meta_episode).issuperset(set(episode))
|
||||||
|
else:
|
||||||
|
if self.end_episode is not None:
|
||||||
|
return self.begin_episode <= int(episode) <= self.end_episode
|
||||||
|
else:
|
||||||
|
return int(episode) == self.begin_episode
|
||||||
|
|
||||||
|
def get_season_string(self):
|
||||||
|
"""
|
||||||
|
返回季字符串
|
||||||
|
"""
|
||||||
|
if self.begin_season is not None:
|
||||||
|
return "S%s" % str(self.begin_season).rjust(2, "0") \
|
||||||
|
if self.end_season is None \
|
||||||
|
else "S%s-S%s" % \
|
||||||
|
(str(self.begin_season).rjust(2, "0"),
|
||||||
|
str(self.end_season).rjust(2, "0"))
|
||||||
|
else:
|
||||||
|
if self.type == MediaType.MOVIE:
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
return "S01"
|
||||||
|
|
||||||
|
def get_season_item(self):
|
||||||
|
"""
|
||||||
|
返回begin_season 的Sxx
|
||||||
|
"""
|
||||||
|
if self.begin_season is not None:
|
||||||
|
return "S%s" % str(self.begin_season).rjust(2, "0")
|
||||||
|
else:
|
||||||
|
if self.type == MediaType.MOVIE:
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
return "S01"
|
||||||
|
|
||||||
|
def get_season_seq(self):
|
||||||
|
"""
|
||||||
|
返回begin_season 的数字
|
||||||
|
"""
|
||||||
|
if self.begin_season is not None:
|
||||||
|
return str(self.begin_season)
|
||||||
|
else:
|
||||||
|
if self.type == MediaType.MOVIE:
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
return "1"
|
||||||
|
|
||||||
|
def get_season_list(self):
|
||||||
|
"""
|
||||||
|
返回季的数组
|
||||||
|
"""
|
||||||
|
if self.begin_season is None:
|
||||||
|
if self.type == MediaType.MOVIE:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return [1]
|
||||||
|
elif self.end_season is not None:
|
||||||
|
return [season for season in range(self.begin_season, self.end_season + 1)]
|
||||||
|
else:
|
||||||
|
return [self.begin_season]
|
||||||
|
|
||||||
|
def set_season(self, sea: Union[list, int, str]):
|
||||||
|
"""
|
||||||
|
更新季
|
||||||
|
"""
|
||||||
|
if not sea:
|
||||||
|
return
|
||||||
|
if isinstance(sea, list):
|
||||||
|
if len(sea) == 1 and str(sea[0]).isdigit():
|
||||||
|
self.begin_season = int(sea[0])
|
||||||
|
self.end_season = None
|
||||||
|
elif len(sea) > 1 and str(sea[0]).isdigit() and str(sea[-1]).isdigit():
|
||||||
|
self.begin_season = int(sea[0])
|
||||||
|
self.end_season = int(sea[-1])
|
||||||
|
elif str(sea).isdigit():
|
||||||
|
self.begin_season = int(sea)
|
||||||
|
self.end_season = None
|
||||||
|
|
||||||
|
def set_episode(self, ep: Union[list, int, str]):
|
||||||
|
"""
|
||||||
|
更新集
|
||||||
|
"""
|
||||||
|
if not ep:
|
||||||
|
return
|
||||||
|
if isinstance(ep, list):
|
||||||
|
if len(ep) == 1 and str(ep[0]).isdigit():
|
||||||
|
self.begin_episode = int(ep[0])
|
||||||
|
self.end_episode = None
|
||||||
|
elif len(ep) > 1 and str(ep[0]).isdigit() and str(ep[-1]).isdigit():
|
||||||
|
self.begin_episode = int(ep[0])
|
||||||
|
self.end_episode = int(ep[-1])
|
||||||
|
elif str(ep).isdigit():
|
||||||
|
self.begin_episode = int(ep)
|
||||||
|
self.end_episode = None
|
||||||
|
|
||||||
|
#
|
||||||
|
def get_episode_string(self):
|
||||||
|
"""
|
||||||
|
返回集字符串
|
||||||
|
"""
|
||||||
|
if self.begin_episode is not None:
|
||||||
|
return "E%s" % str(self.begin_episode).rjust(2, "0") \
|
||||||
|
if self.end_episode is None \
|
||||||
|
else "E%s-E%s" % \
|
||||||
|
(
|
||||||
|
str(self.begin_episode).rjust(2, "0"),
|
||||||
|
str(self.end_episode).rjust(2, "0"))
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_episode_list(self):
|
||||||
|
"""
|
||||||
|
返回集的数组
|
||||||
|
"""
|
||||||
|
if self.begin_episode is None:
|
||||||
|
return []
|
||||||
|
elif self.end_episode is not None:
|
||||||
|
return [episode for episode in range(self.begin_episode, self.end_episode + 1)]
|
||||||
|
else:
|
||||||
|
return [self.begin_episode]
|
||||||
|
|
||||||
|
def get_episode_items(self):
|
||||||
|
"""
|
||||||
|
返回集的并列表达方式,用于支持单文件多集
|
||||||
|
"""
|
||||||
|
return "E%s" % "E".join(str(episode).rjust(2, '0') for episode in self.get_episode_list())
|
||||||
|
|
||||||
|
def get_episode_seqs(self):
|
||||||
|
"""
|
||||||
|
返回单文件多集的集数表达方式,用于支持单文件多集
|
||||||
|
"""
|
||||||
|
episodes = self.get_episode_list()
|
||||||
|
if episodes:
|
||||||
|
# 集 xx
|
||||||
|
if len(episodes) == 1:
|
||||||
|
return str(episodes[0])
|
||||||
|
else:
|
||||||
|
return "%s-%s" % (episodes[0], episodes[-1])
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_episode_seq(self):
|
||||||
|
"""
|
||||||
|
返回begin_episode 的数字
|
||||||
|
"""
|
||||||
|
episodes = self.get_episode_list()
|
||||||
|
if episodes:
|
||||||
|
return str(episodes[0])
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_season_episode_string(self):
|
||||||
|
"""
|
||||||
|
返回季集字符串
|
||||||
|
"""
|
||||||
|
if self.type == MediaType.MOVIE:
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
seaion = self.get_season_string()
|
||||||
|
episode = self.get_episode_string()
|
||||||
|
if seaion and episode:
|
||||||
|
return "%s %s" % (seaion, episode)
|
||||||
|
elif seaion:
|
||||||
|
return "%s" % seaion
|
||||||
|
elif episode:
|
||||||
|
return "%s" % episode
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_resource_type_string(self):
|
||||||
|
"""
|
||||||
|
返回资源类型字符串,含分辨率
|
||||||
|
"""
|
||||||
|
ret_string = ""
|
||||||
|
if self.resource_type:
|
||||||
|
ret_string = f"{ret_string} {self.resource_type}"
|
||||||
|
if self.resource_effect:
|
||||||
|
ret_string = f"{ret_string} {self.resource_effect}"
|
||||||
|
if self.resource_pix:
|
||||||
|
ret_string = f"{ret_string} {self.resource_pix}"
|
||||||
|
return ret_string
|
||||||
|
|
||||||
|
def get_edtion_string(self):
|
||||||
|
"""
|
||||||
|
返回资源类型字符串,不含分辨率
|
||||||
|
"""
|
||||||
|
ret_string = ""
|
||||||
|
if self.resource_type:
|
||||||
|
ret_string = f"{ret_string} {self.resource_type}"
|
||||||
|
if self.resource_effect:
|
||||||
|
ret_string = f"{ret_string} {self.resource_effect}"
|
||||||
|
return ret_string.strip()
|
||||||
|
|
||||||
|
def get_resource_team_string(self):
|
||||||
|
"""
|
||||||
|
返回发布组/字幕组字符串
|
||||||
|
"""
|
||||||
|
if self.resource_team:
|
||||||
|
return self.resource_team
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_video_encode_string(self):
|
||||||
|
"""
|
||||||
|
返回视频编码
|
||||||
|
"""
|
||||||
|
return self.video_encode or ""
|
||||||
|
|
||||||
|
def get_audio_encode_string(self):
|
||||||
|
"""
|
||||||
|
返回音频编码
|
||||||
|
"""
|
||||||
|
return self.audio_encode or ""
|
557
app/core/meta/metavideo.py
Normal file
557
app/core/meta/metavideo.py
Normal file
@ -0,0 +1,557 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.meta.metabase import MetaBase
|
||||||
|
from app.core.meta.release_groups import ReleaseGroupsMatcher
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
from app.utils.tokens import Tokens
|
||||||
|
from app.utils.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class MetaVideo(MetaBase):
|
||||||
|
"""
|
||||||
|
识别电影、电视剧
|
||||||
|
"""
|
||||||
|
# 控制标位区
|
||||||
|
_stop_name_flag = False
|
||||||
|
_stop_cnname_flag = False
|
||||||
|
_last_token = ""
|
||||||
|
_last_token_type = ""
|
||||||
|
_continue_flag = True
|
||||||
|
_unknown_name_str = ""
|
||||||
|
_source = ""
|
||||||
|
_effect = []
|
||||||
|
# 正则式区
|
||||||
|
_season_re = r"S(\d{2})|^S(\d{1,2})$|S(\d{1,2})E"
|
||||||
|
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
|
||||||
|
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
|
||||||
|
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
|
||||||
|
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$"
|
||||||
|
_effect_re = r"^REMUX$|^UHD$|^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
|
||||||
|
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
||||||
|
_name_no_begin_re = r"^\[.+?]"
|
||||||
|
_name_no_chinese_re = r".*版|.*字幕"
|
||||||
|
_name_se_words = ['共', '第', '季', '集', '话', '話', '期']
|
||||||
|
_name_nostring_re = r"^PTS|^JADE|^AOD|^CHC|^[A-Z]{1,4}TV[\-0-9UVHDK]*" \
|
||||||
|
r"|HBO$|\s+HBO|\d{1,2}th|\d{1,2}bit|NETFLIX|AMAZON|IMAX|^3D|\s+3D|^BBC\s+|\s+BBC|BBC$|DISNEY\+?|XXX|\s+DC$" \
|
||||||
|
r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
|
||||||
|
r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \
|
||||||
|
r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \
|
||||||
|
r"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\w+字幕组" \
|
||||||
|
r"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$" \
|
||||||
|
r"|S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" \
|
||||||
|
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" \
|
||||||
|
r"|[248]K|\d{3,4}[PIX]+" \
|
||||||
|
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]"
|
||||||
|
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
|
||||||
|
_resources_pix_re2 = r"(^[248]+K)"
|
||||||
|
_video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$"
|
||||||
|
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$"
|
||||||
|
|
||||||
|
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||||
|
super().__init__(title, subtitle, isfile)
|
||||||
|
if not title:
|
||||||
|
return
|
||||||
|
original_title = title
|
||||||
|
self._source = ""
|
||||||
|
self._effect = []
|
||||||
|
# 判断是否纯数字命名
|
||||||
|
title_path = Path(title)
|
||||||
|
if title_path.suffix.lower() in settings.RMT_MEDIAEXT \
|
||||||
|
and title_path.stem.isdigit() \
|
||||||
|
and len(title_path.stem) < 5:
|
||||||
|
self.begin_episode = int(title_path.stem)
|
||||||
|
self.type = MediaType.TV
|
||||||
|
return
|
||||||
|
# 去掉名称中第1个[]的内容
|
||||||
|
title = re.sub(r'%s' % self._name_no_begin_re, "", title, count=1)
|
||||||
|
# 把xxxx-xxxx年份换成前一个年份,常出现在季集上
|
||||||
|
title = re.sub(r'([\s.]+)(\d{4})-(\d{4})', r'\1\2', title)
|
||||||
|
# 把大小去掉
|
||||||
|
title = re.sub(r'[0-9.]+\s*[MGT]i?B(?![A-Z]+)', "", title, flags=re.IGNORECASE)
|
||||||
|
# 把年月日去掉
|
||||||
|
title = re.sub(r'\d{4}[\s._-]\d{1,2}[\s._-]\d{1,2}', "", title)
|
||||||
|
# 拆分tokens
|
||||||
|
tokens = Tokens(title)
|
||||||
|
self.tokens = tokens
|
||||||
|
# 解析名称、年份、季、集、资源类型、分辨率等
|
||||||
|
token = tokens.get_next()
|
||||||
|
while token:
|
||||||
|
# Part
|
||||||
|
self.__init_part(token)
|
||||||
|
# 标题
|
||||||
|
if self._continue_flag:
|
||||||
|
self.__init_name(token)
|
||||||
|
# 年份
|
||||||
|
if self._continue_flag:
|
||||||
|
self.__init_year(token)
|
||||||
|
# 分辨率
|
||||||
|
if self._continue_flag:
|
||||||
|
self.__init_resource_pix(token)
|
||||||
|
# 季
|
||||||
|
if self._continue_flag:
|
||||||
|
self.__init_season(token)
|
||||||
|
# 集
|
||||||
|
if self._continue_flag:
|
||||||
|
self.__init_episode(token)
|
||||||
|
# 资源类型
|
||||||
|
if self._continue_flag:
|
||||||
|
self.__init_resource_type(token)
|
||||||
|
# 视频编码
|
||||||
|
if self._continue_flag:
|
||||||
|
self.__init_video_encode(token)
|
||||||
|
# 音频编码
|
||||||
|
if self._continue_flag:
|
||||||
|
self.__init_audio_encode(token)
|
||||||
|
# 取下一个,直到没有为卡
|
||||||
|
token = tokens.get_next()
|
||||||
|
self._continue_flag = True
|
||||||
|
# 合成质量
|
||||||
|
if self._effect:
|
||||||
|
self._effect.reverse()
|
||||||
|
self.resource_effect = " ".join(self._effect)
|
||||||
|
if self._source:
|
||||||
|
self.resource_type = self._source.strip()
|
||||||
|
# 提取原盘DIY
|
||||||
|
if self.resource_type and "BluRay" in self.resource_type:
|
||||||
|
if (self.subtitle and re.findall(r'D[Ii]Y', self.subtitle)) \
|
||||||
|
or re.findall(r'-D[Ii]Y@', original_title):
|
||||||
|
self.resource_type = f"{self.resource_type} DIY"
|
||||||
|
# 解析副标题,只要季和集
|
||||||
|
self.init_subtitle(self.org_string)
|
||||||
|
if not self._subtitle_flag and self.subtitle:
|
||||||
|
self.init_subtitle(self.subtitle)
|
||||||
|
# 没有识别出类型时默认为电影
|
||||||
|
if not self.type:
|
||||||
|
self.type = MediaType.MOVIE
|
||||||
|
# 去掉名字中不需要的干扰字符,过短的纯数字不要
|
||||||
|
self.cn_name = self.__fix_name(self.cn_name)
|
||||||
|
self.en_name = StringUtils.str_title(self.__fix_name(self.en_name))
|
||||||
|
# 处理part
|
||||||
|
if self.part and self.part.upper() == "PART":
|
||||||
|
self.part = None
|
||||||
|
# 制作组/字幕组
|
||||||
|
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
|
||||||
|
|
||||||
|
def __fix_name(self, name: str):
|
||||||
|
if not name:
|
||||||
|
return name
|
||||||
|
name = re.sub(r'%s' % self._name_nostring_re, '', name,
|
||||||
|
flags=re.IGNORECASE).strip()
|
||||||
|
name = re.sub(r'\s+', ' ', name)
|
||||||
|
if name.isdigit() \
|
||||||
|
and int(name) < 1800 \
|
||||||
|
and not self.year \
|
||||||
|
and not self.begin_season \
|
||||||
|
and not self.resource_pix \
|
||||||
|
and not self.resource_type \
|
||||||
|
and not self.audio_encode \
|
||||||
|
and not self.video_encode:
|
||||||
|
if self.begin_episode is None:
|
||||||
|
self.begin_episode = int(name)
|
||||||
|
name = None
|
||||||
|
elif self.is_in_episode(int(name)) and not self.begin_season:
|
||||||
|
name = None
|
||||||
|
return name
|
||||||
|
|
||||||
|
def __init_name(self, token: str):
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
# 回收标题
|
||||||
|
if self._unknown_name_str:
|
||||||
|
if not self.cn_name:
|
||||||
|
if not self.en_name:
|
||||||
|
self.en_name = self._unknown_name_str
|
||||||
|
elif self._unknown_name_str != self.year:
|
||||||
|
self.en_name = "%s %s" % (self.en_name, self._unknown_name_str)
|
||||||
|
self._last_token_type = "enname"
|
||||||
|
self._unknown_name_str = ""
|
||||||
|
if self._stop_name_flag:
|
||||||
|
return
|
||||||
|
if token.upper() == "AKA":
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
return
|
||||||
|
if token in self._name_se_words:
|
||||||
|
self._last_token_type = 'name_se_words'
|
||||||
|
return
|
||||||
|
if StringUtils.is_chinese(token):
|
||||||
|
# 含有中文,直接做为标题(连着的数字或者英文会保留),且不再取用后面出现的中文
|
||||||
|
self._last_token_type = "cnname"
|
||||||
|
if not self.cn_name:
|
||||||
|
self.cn_name = token
|
||||||
|
elif not self._stop_cnname_flag:
|
||||||
|
if not re.search("%s" % self._name_no_chinese_re, token, flags=re.IGNORECASE) \
|
||||||
|
and not re.search("%s" % self._name_se_words, token, flags=re.IGNORECASE):
|
||||||
|
self.cn_name = "%s %s" % (self.cn_name, token)
|
||||||
|
self._stop_cnname_flag = True
|
||||||
|
else:
|
||||||
|
is_roman_digit = re.search(self._roman_numerals, token)
|
||||||
|
# 阿拉伯数字或者罗马数字
|
||||||
|
if token.isdigit() or is_roman_digit:
|
||||||
|
# 第季集后面的不要
|
||||||
|
if self._last_token_type == 'name_se_words':
|
||||||
|
return
|
||||||
|
if self.get_name():
|
||||||
|
# 名字后面以 0 开头的不要,极有可能是集
|
||||||
|
if token.startswith('0'):
|
||||||
|
return
|
||||||
|
# 检查是否真正的数字
|
||||||
|
if token.isdigit():
|
||||||
|
try:
|
||||||
|
int(token)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
# 中文名后面跟的数字不是年份的极有可能是集
|
||||||
|
if not is_roman_digit \
|
||||||
|
and self._last_token_type == "cnname" \
|
||||||
|
and int(token) < 1900:
|
||||||
|
return
|
||||||
|
if (token.isdigit() and len(token) < 4) or is_roman_digit:
|
||||||
|
# 4位以下的数字或者罗马数字,拼装到已有标题中
|
||||||
|
if self._last_token_type == "cnname":
|
||||||
|
self.cn_name = "%s %s" % (self.cn_name, token)
|
||||||
|
elif self._last_token_type == "enname":
|
||||||
|
self.en_name = "%s %s" % (self.en_name, token)
|
||||||
|
self._continue_flag = False
|
||||||
|
elif token.isdigit() and len(token) == 4:
|
||||||
|
# 4位数字,可能是年份,也可能真的是标题的一部分,也有可能是集
|
||||||
|
if not self._unknown_name_str:
|
||||||
|
self._unknown_name_str = token
|
||||||
|
else:
|
||||||
|
# 名字未出现前的第一个数字,记下来
|
||||||
|
if not self._unknown_name_str:
|
||||||
|
self._unknown_name_str = token
|
||||||
|
elif re.search(r"%s" % self._season_re, token, re.IGNORECASE):
|
||||||
|
# 季的处理
|
||||||
|
if self.en_name and re.search(r"SEASON$", self.en_name, re.IGNORECASE):
|
||||||
|
# 如果匹配到季,英文名结尾为Season,说明Season属于标题,不应在后续作为干扰词去除
|
||||||
|
self.en_name += ' '
|
||||||
|
self._stop_name_flag = True
|
||||||
|
return
|
||||||
|
elif re.search(r"%s" % self._episode_re, token, re.IGNORECASE) \
|
||||||
|
or re.search(r"(%s)" % self._resources_type_re, token, re.IGNORECASE) \
|
||||||
|
or re.search(r"%s" % self._resources_pix_re, token, re.IGNORECASE):
|
||||||
|
# 集、来源、版本等不要
|
||||||
|
self._stop_name_flag = True
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# 后缀名不要
|
||||||
|
if ".%s".lower() % token in settings.RMT_MEDIAEXT:
|
||||||
|
return
|
||||||
|
# 英文或者英文+数字,拼装起来
|
||||||
|
if self.en_name:
|
||||||
|
self.en_name = "%s %s" % (self.en_name, token)
|
||||||
|
else:
|
||||||
|
self.en_name = token
|
||||||
|
self._last_token_type = "enname"
|
||||||
|
|
||||||
|
def __init_part(self, token: str):
|
||||||
|
if not self.get_name():
|
||||||
|
return
|
||||||
|
if not self.year \
|
||||||
|
and not self.begin_season \
|
||||||
|
and not self.begin_episode \
|
||||||
|
and not self.resource_pix \
|
||||||
|
and not self.resource_type:
|
||||||
|
return
|
||||||
|
re_res = re.search(r"%s" % self._part_re, token, re.IGNORECASE)
|
||||||
|
if re_res:
|
||||||
|
if not self.part:
|
||||||
|
self.part = re_res.group(1)
|
||||||
|
nextv = self.tokens.cur()
|
||||||
|
if nextv \
|
||||||
|
and ((nextv.isdigit() and (len(nextv) == 1 or len(nextv) == 2 and nextv.startswith('0')))
|
||||||
|
or nextv.upper() in ['A', 'B', 'C', 'I', 'II', 'III']):
|
||||||
|
self.part = "%s%s" % (self.part, nextv)
|
||||||
|
self.tokens.get_next()
|
||||||
|
self._last_token_type = "part"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = False
|
||||||
|
|
||||||
|
def __init_year(self, token: str):
|
||||||
|
if not self.get_name():
|
||||||
|
return
|
||||||
|
if not token.isdigit():
|
||||||
|
return
|
||||||
|
if len(token) != 4:
|
||||||
|
return
|
||||||
|
if not 1900 < int(token) < 2050:
|
||||||
|
return
|
||||||
|
if self.year:
|
||||||
|
if self.en_name:
|
||||||
|
self.en_name = "%s %s" % (self.en_name.strip(), self.year)
|
||||||
|
elif self.cn_name:
|
||||||
|
self.cn_name = "%s %s" % (self.cn_name, self.year)
|
||||||
|
elif self.en_name and re.search(r"SEASON$", self.en_name, re.IGNORECASE):
|
||||||
|
# 如果匹配到年,且英文名结尾为Season,说明Season属于标题,不应在后续作为干扰词去除
|
||||||
|
self.en_name += ' '
|
||||||
|
self.year = token
|
||||||
|
self._last_token_type = "year"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
|
||||||
|
def __init_resource_pix(self, token: str):
|
||||||
|
if not self.get_name():
|
||||||
|
return
|
||||||
|
re_res = re.findall(r"%s" % self._resources_pix_re, token, re.IGNORECASE)
|
||||||
|
if re_res:
|
||||||
|
self._last_token_type = "pix"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
resource_pix = None
|
||||||
|
for pixs in re_res:
|
||||||
|
if isinstance(pixs, tuple):
|
||||||
|
pix_t = None
|
||||||
|
for pix_i in pixs:
|
||||||
|
if pix_i:
|
||||||
|
pix_t = pix_i
|
||||||
|
break
|
||||||
|
if pix_t:
|
||||||
|
resource_pix = pix_t
|
||||||
|
else:
|
||||||
|
resource_pix = pixs
|
||||||
|
if resource_pix and not self.resource_pix:
|
||||||
|
self.resource_pix = resource_pix.lower()
|
||||||
|
break
|
||||||
|
if self.resource_pix \
|
||||||
|
and self.resource_pix.isdigit() \
|
||||||
|
and self.resource_pix[-1] not in 'kpi':
|
||||||
|
self.resource_pix = "%sp" % self.resource_pix
|
||||||
|
else:
|
||||||
|
re_res = re.search(r"%s" % self._resources_pix_re2, token, re.IGNORECASE)
|
||||||
|
if re_res:
|
||||||
|
self._last_token_type = "pix"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
if not self.resource_pix:
|
||||||
|
self.resource_pix = re_res.group(1).lower()
|
||||||
|
|
||||||
|
def __init_season(self, token: str):
|
||||||
|
re_res = re.findall(r"%s" % self._season_re, token, re.IGNORECASE)
|
||||||
|
if re_res:
|
||||||
|
self._last_token_type = "season"
|
||||||
|
self.type = MediaType.TV
|
||||||
|
self._stop_name_flag = True
|
||||||
|
self._continue_flag = True
|
||||||
|
for se in re_res:
|
||||||
|
if isinstance(se, tuple):
|
||||||
|
se_t = None
|
||||||
|
for se_i in se:
|
||||||
|
if se_i and str(se_i).isdigit():
|
||||||
|
se_t = se_i
|
||||||
|
break
|
||||||
|
if se_t:
|
||||||
|
se = int(se_t)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
se = int(se)
|
||||||
|
if self.begin_season is None:
|
||||||
|
self.begin_season = se
|
||||||
|
self.total_seasons = 1
|
||||||
|
else:
|
||||||
|
if se > self.begin_season:
|
||||||
|
self.end_season = se
|
||||||
|
self.total_seasons = (self.end_season - self.begin_season) + 1
|
||||||
|
if self.isfile and self.total_seasons > 1:
|
||||||
|
self.end_season = None
|
||||||
|
self.total_seasons = 1
|
||||||
|
elif token.isdigit():
|
||||||
|
try:
|
||||||
|
int(token)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
if self._last_token_type == "SEASON" \
|
||||||
|
and self.begin_season is None \
|
||||||
|
and len(token) < 3:
|
||||||
|
self.begin_season = int(token)
|
||||||
|
self.total_seasons = 1
|
||||||
|
self._last_token_type = "season"
|
||||||
|
self._stop_name_flag = True
|
||||||
|
self._continue_flag = False
|
||||||
|
self.type = MediaType.TV
|
||||||
|
elif token.upper() == "SEASON" and self.begin_season is None:
|
||||||
|
self._last_token_type = "SEASON"
|
||||||
|
|
||||||
|
def __init_episode(self, token: str):
|
||||||
|
re_res = re.findall(r"%s" % self._episode_re, token, re.IGNORECASE)
|
||||||
|
if re_res:
|
||||||
|
self._last_token_type = "episode"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
self.type = MediaType.TV
|
||||||
|
for se in re_res:
|
||||||
|
if isinstance(se, tuple):
|
||||||
|
se_t = None
|
||||||
|
for se_i in se:
|
||||||
|
if se_i and str(se_i).isdigit():
|
||||||
|
se_t = se_i
|
||||||
|
break
|
||||||
|
if se_t:
|
||||||
|
se = int(se_t)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
se = int(se)
|
||||||
|
if self.begin_episode is None:
|
||||||
|
self.begin_episode = se
|
||||||
|
self.total_episodes = 1
|
||||||
|
else:
|
||||||
|
if se > self.begin_episode:
|
||||||
|
self.end_episode = se
|
||||||
|
self.total_episodes = (self.end_episode - self.begin_episode) + 1
|
||||||
|
if self.isfile and self.total_episodes > 2:
|
||||||
|
self.end_episode = None
|
||||||
|
self.total_episodes = 1
|
||||||
|
elif token.isdigit():
|
||||||
|
try:
|
||||||
|
int(token)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
if self.begin_episode is not None \
|
||||||
|
and self.end_episode is None \
|
||||||
|
and len(token) < 5 \
|
||||||
|
and int(token) > self.begin_episode \
|
||||||
|
and self._last_token_type == "episode":
|
||||||
|
self.end_episode = int(token)
|
||||||
|
self.total_episodes = (self.end_episode - self.begin_episode) + 1
|
||||||
|
if self.isfile and self.total_episodes > 2:
|
||||||
|
self.end_episode = None
|
||||||
|
self.total_episodes = 1
|
||||||
|
self._continue_flag = False
|
||||||
|
self.type = MediaType.TV
|
||||||
|
elif self.begin_episode is None \
|
||||||
|
and 1 < len(token) < 4 \
|
||||||
|
and self._last_token_type != "year" \
|
||||||
|
and self._last_token_type != "videoencode" \
|
||||||
|
and token != self._unknown_name_str:
|
||||||
|
self.begin_episode = int(token)
|
||||||
|
self.total_episodes = 1
|
||||||
|
self._last_token_type = "episode"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
self.type = MediaType.TV
|
||||||
|
elif self._last_token_type == "EPISODE" \
|
||||||
|
and self.begin_episode is None \
|
||||||
|
and len(token) < 5:
|
||||||
|
self.begin_episode = int(token)
|
||||||
|
self.total_episodes = 1
|
||||||
|
self._last_token_type = "episode"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
self.type = MediaType.TV
|
||||||
|
elif token.upper() == "EPISODE":
|
||||||
|
self._last_token_type = "EPISODE"
|
||||||
|
|
||||||
|
def __init_resource_type(self, token):
|
||||||
|
if not self.get_name():
|
||||||
|
return
|
||||||
|
source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE)
|
||||||
|
if source_res:
|
||||||
|
self._last_token_type = "source"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
if not self._source:
|
||||||
|
self._source = source_res.group(1)
|
||||||
|
self._last_token = self._source.upper()
|
||||||
|
return
|
||||||
|
elif token.upper() == "DL" \
|
||||||
|
and self._last_token_type == "source" \
|
||||||
|
and self._last_token == "WEB":
|
||||||
|
self._source = "WEB-DL"
|
||||||
|
self._continue_flag = False
|
||||||
|
return
|
||||||
|
elif token.upper() == "RAY" \
|
||||||
|
and self._last_token_type == "source" \
|
||||||
|
and self._last_token == "BLU":
|
||||||
|
self._source = "BluRay"
|
||||||
|
self._continue_flag = False
|
||||||
|
return
|
||||||
|
elif token.upper() == "WEBDL":
|
||||||
|
self._source = "WEB-DL"
|
||||||
|
self._continue_flag = False
|
||||||
|
return
|
||||||
|
effect_res = re.search(r"(%s)" % self._effect_re, token, re.IGNORECASE)
|
||||||
|
if effect_res:
|
||||||
|
self._last_token_type = "effect"
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
effect = effect_res.group(1)
|
||||||
|
if effect not in self._effect:
|
||||||
|
self._effect.append(effect)
|
||||||
|
self._last_token = effect.upper()
|
||||||
|
|
||||||
|
def __init_video_encode(self, token: str):
|
||||||
|
if not self.get_name():
|
||||||
|
return
|
||||||
|
if not self.year \
|
||||||
|
and not self.resource_pix \
|
||||||
|
and not self.resource_type \
|
||||||
|
and not self.begin_season \
|
||||||
|
and not self.begin_episode:
|
||||||
|
return
|
||||||
|
re_res = re.search(r"(%s)" % self._video_encode_re, token, re.IGNORECASE)
|
||||||
|
if re_res:
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
self._last_token_type = "videoencode"
|
||||||
|
if not self.video_encode:
|
||||||
|
self.video_encode = re_res.group(1).upper()
|
||||||
|
self._last_token = self.video_encode
|
||||||
|
elif self.video_encode == "10bit":
|
||||||
|
self.video_encode = f"{re_res.group(1).upper()} 10bit"
|
||||||
|
self._last_token = re_res.group(1).upper()
|
||||||
|
elif token.upper() in ['H', 'X']:
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
self._last_token_type = "videoencode"
|
||||||
|
self._last_token = token.upper() if token.upper() == "H" else token.lower()
|
||||||
|
elif token in ["264", "265"] \
|
||||||
|
and self._last_token_type == "videoencode" \
|
||||||
|
and self._last_token in ['H', 'X']:
|
||||||
|
self.video_encode = "%s%s" % (self._last_token, token)
|
||||||
|
elif token.isdigit() \
|
||||||
|
and self._last_token_type == "videoencode" \
|
||||||
|
and self._last_token in ['VC', 'MPEG']:
|
||||||
|
self.video_encode = "%s%s" % (self._last_token, token)
|
||||||
|
elif token.upper() == "10BIT":
|
||||||
|
self._last_token_type = "videoencode"
|
||||||
|
if not self.video_encode:
|
||||||
|
self.video_encode = "10bit"
|
||||||
|
else:
|
||||||
|
self.video_encode = f"{self.video_encode} 10bit"
|
||||||
|
|
||||||
|
def __init_audio_encode(self, token: str):
|
||||||
|
if not self.get_name():
|
||||||
|
return
|
||||||
|
if not self.year \
|
||||||
|
and not self.resource_pix \
|
||||||
|
and not self.resource_type \
|
||||||
|
and not self.begin_season \
|
||||||
|
and not self.begin_episode:
|
||||||
|
return
|
||||||
|
re_res = re.search(r"(%s)" % self._audio_encode_re, token, re.IGNORECASE)
|
||||||
|
if re_res:
|
||||||
|
self._continue_flag = False
|
||||||
|
self._stop_name_flag = True
|
||||||
|
self._last_token_type = "audioencode"
|
||||||
|
self._last_token = re_res.group(1).upper()
|
||||||
|
if not self.audio_encode:
|
||||||
|
self.audio_encode = re_res.group(1)
|
||||||
|
else:
|
||||||
|
if self.audio_encode.upper() == "DTS":
|
||||||
|
self.audio_encode = "%s-%s" % (self.audio_encode, re_res.group(1))
|
||||||
|
else:
|
||||||
|
self.audio_encode = "%s %s" % (self.audio_encode, re_res.group(1))
|
||||||
|
elif token.isdigit() \
|
||||||
|
and self._last_token_type == "audioencode":
|
||||||
|
if self.audio_encode:
|
||||||
|
if self._last_token.isdigit():
|
||||||
|
self.audio_encode = "%s.%s" % (self.audio_encode, token)
|
||||||
|
elif self.audio_encode[-1].isdigit():
|
||||||
|
self.audio_encode = "%s %s.%s" % (self.audio_encode[:-1], self.audio_encode[-1], token)
|
||||||
|
else:
|
||||||
|
self.audio_encode = "%s %s" % (self.audio_encode, token)
|
||||||
|
self._last_token = token
|
111
app/core/meta/release_groups.py
Normal file
111
app/core/meta/release_groups.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import regex as re
|
||||||
|
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
识别制作组、字幕组
|
||||||
|
"""
|
||||||
|
__release_groups: str = None
|
||||||
|
custom_release_groups: str = None
|
||||||
|
custom_separator: str = None
|
||||||
|
RELEASE_GROUPS: dict = {
|
||||||
|
"0ff": ['FF(?:(?:A|WE)B|CD|E(?:DU|B)|TV)'],
|
||||||
|
"1pt": [],
|
||||||
|
"52pt": [],
|
||||||
|
"audiences": ['Audies', 'AD(?:Audio|E(?:|book)|Music|Web)'],
|
||||||
|
"azusa": [],
|
||||||
|
"beitai": ['BeiTai'],
|
||||||
|
"btschool": ['Bts(?:CHOOL|HD|PAD|TV)', 'Zone'],
|
||||||
|
"carpt": ['CarPT'],
|
||||||
|
"chdbits": ['CHD(?:|Bits|PAD|(?:|HK)TV|WEB)', 'StBOX', 'OneHD', 'Lee', 'xiaopie'],
|
||||||
|
"discfan": [],
|
||||||
|
"dragonhd": [],
|
||||||
|
"eastgame": ['(?:(?:iNT|(?:HALFC|Mini(?:S|H|FH)D))-|)TLF'],
|
||||||
|
"filelist": [],
|
||||||
|
"gainbound": ['(?:DG|GBWE)B'],
|
||||||
|
"hares": ['Hares(?:|(?:M|T)V|Web)'],
|
||||||
|
"hd4fans": [],
|
||||||
|
"hdarea": ['HDA(?:pad|rea|TV)', 'EPiC'],
|
||||||
|
"hdatmos": [],
|
||||||
|
"hdbd": [],
|
||||||
|
"hdchina": ['HDC(?:|hina|TV)', 'k9611', 'tudou', 'iHD'],
|
||||||
|
"hddolby": ['D(?:ream|BTV)', '(?:HD|QHstudI)o'],
|
||||||
|
"hdfans": ['beAst(?:|TV)'],
|
||||||
|
"hdhome": ['HDH(?:|ome|Pad|TV|WEB)'],
|
||||||
|
"hdpt": ['HDPT(?:|Web)'],
|
||||||
|
"hdsky": ['HDS(?:|ky|TV|Pad|WEB)', 'AQLJ'],
|
||||||
|
"hdtime": [],
|
||||||
|
"HDU": [],
|
||||||
|
"hdvideo": [],
|
||||||
|
"hdzone": ['HDZ(?:|one)'],
|
||||||
|
"hhanclub": ['HHWEB'],
|
||||||
|
"hitpt": [],
|
||||||
|
"htpt": ['HTPT'],
|
||||||
|
"iptorrents": [],
|
||||||
|
"joyhd": [],
|
||||||
|
"keepfrds": ['FRDS', 'Yumi', 'cXcY'],
|
||||||
|
"lemonhd": ['L(?:eague(?:(?:C|H)D|(?:M|T)V|NF|WEB)|HD)', 'i18n', 'CiNT'],
|
||||||
|
"mteam": ['MTeam(?:|TV)', 'MPAD'],
|
||||||
|
"nanyangpt": [],
|
||||||
|
"nicept": [],
|
||||||
|
"oshen": [],
|
||||||
|
"ourbits": ['Our(?:Bits|TV)', 'FLTTH', 'Ao', 'PbK', 'MGs', 'iLove(?:HD|TV)'],
|
||||||
|
"piggo": ['PiGo(?:NF|(?:H|WE)B)'],
|
||||||
|
"ptchina": [],
|
||||||
|
"pterclub": ['PTer(?:|DIY|Game|(?:M|T)V|WEB)'],
|
||||||
|
"pthome": ['PTH(?:|Audio|eBook|music|ome|tv|WEB)'],
|
||||||
|
"ptmsg": [],
|
||||||
|
"ptsbao": ['PTsbao', 'OPS', 'F(?:Fans(?:AIeNcE|BD|D(?:VD|IY)|TV|WEB)|HDMv)', 'SGXT'],
|
||||||
|
"pttime": [],
|
||||||
|
"putao": ['PuTao'],
|
||||||
|
"soulvoice": [],
|
||||||
|
"springsunday": ['CMCT(?:|V)'],
|
||||||
|
"sharkpt": ['Shark(?:|WEB|DIY|TV|MV)'],
|
||||||
|
"tccf": [],
|
||||||
|
"tjupt": ['TJUPT'],
|
||||||
|
"totheglory": ['TTG', 'WiKi', 'NGB', 'DoA', '(?:ARi|ExRE)N'],
|
||||||
|
"U2": [],
|
||||||
|
"ultrahd": [],
|
||||||
|
"others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:|yG)',
|
||||||
|
'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'],
|
||||||
|
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', '(?:Lilith|NC)-Raws', '织梦字幕组']
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
release_groups = []
|
||||||
|
for site_groups in self.RELEASE_GROUPS.values():
|
||||||
|
for release_group in site_groups:
|
||||||
|
release_groups.append(release_group)
|
||||||
|
self.__release_groups = '|'.join(release_groups)
|
||||||
|
|
||||||
|
def match(self, title: str = None, groups: str = None):
|
||||||
|
"""
|
||||||
|
:param title: 资源标题或文件名
|
||||||
|
:param groups: 制作组/字幕组
|
||||||
|
:return: 匹配结果
|
||||||
|
"""
|
||||||
|
if not title:
|
||||||
|
return ""
|
||||||
|
if not groups:
|
||||||
|
if self.custom_release_groups:
|
||||||
|
groups = f"{self.__release_groups}|{self.custom_release_groups}"
|
||||||
|
else:
|
||||||
|
groups = self.__release_groups
|
||||||
|
title = f"{title} "
|
||||||
|
groups_re = re.compile(r"(?<=[-@\[£【&])(?:%s)(?=[@.\s\]\[】&])" % groups, re.I)
|
||||||
|
# 处理一个制作组识别多次的情况,保留顺序
|
||||||
|
unique_groups = []
|
||||||
|
for item in re.findall(groups_re, title):
|
||||||
|
if item not in unique_groups:
|
||||||
|
unique_groups.append(item)
|
||||||
|
separator = self.custom_separator or "@"
|
||||||
|
return separator.join(unique_groups)
|
||||||
|
|
||||||
|
def update_custom(self, release_groups: str = None, separator: str = None):
|
||||||
|
"""
|
||||||
|
更新自定义制作组/字幕组,自定义分隔符
|
||||||
|
"""
|
||||||
|
self.custom_release_groups = release_groups
|
||||||
|
self.custom_separator = separator
|
43
app/core/meta_info.py
Normal file
43
app/core/meta_info.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import regex as re
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.meta import MetaAnime, MetaVideo
|
||||||
|
|
||||||
|
|
||||||
|
def MetaInfo(title: str, subtitle: str = None):
|
||||||
|
"""
|
||||||
|
媒体整理入口,根据名称和副标题,判断是哪种类型的识别,返回对应对象
|
||||||
|
:param title: 标题、种子名、文件名
|
||||||
|
:param subtitle: 副标题、描述
|
||||||
|
:return: MetaAnime、MetaVideo
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 判断是否处理文件
|
||||||
|
if title and Path(title).suffix.lower() in settings.RMT_MEDIAEXT:
|
||||||
|
isfile = True
|
||||||
|
else:
|
||||||
|
isfile = False
|
||||||
|
|
||||||
|
return MetaAnime(title, subtitle, isfile) if is_anime(title) else MetaVideo(title, subtitle, isfile)
|
||||||
|
|
||||||
|
|
||||||
|
def is_anime(name: str):
|
||||||
|
"""
|
||||||
|
判断是否为动漫
|
||||||
|
:param name: 名称
|
||||||
|
:return: 是否动漫
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
if re.search(r'【[+0-9XVPI-]+】\s*【', name, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
if re.search(r'\s+-\s+[\dv]{1,4}\s+', name, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
if re.search(r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}", name,
|
||||||
|
re.IGNORECASE):
|
||||||
|
return False
|
||||||
|
if re.search(r'\[[+0-9XVPI-]+]\s*\[', name, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
return False
|
72
app/core/module_manager.py
Normal file
72
app/core/module_manager.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from types import FunctionType
|
||||||
|
from typing import Generator, Optional
|
||||||
|
|
||||||
|
from app.core import settings
|
||||||
|
from app.helper import ModuleHelper
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleManager(metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
模块管理器
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 模块列表
|
||||||
|
_modules: dict = {}
|
||||||
|
# 运行态模块列表
|
||||||
|
_running_modules: dict = {}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.load_modules()
|
||||||
|
|
||||||
|
def load_modules(self):
|
||||||
|
"""
|
||||||
|
加载所有模块
|
||||||
|
"""
|
||||||
|
# 扫描模块目录
|
||||||
|
modules = ModuleHelper.load(
|
||||||
|
"app.modules",
|
||||||
|
filter_func=lambda _, obj: hasattr(obj, 'init_module') and hasattr(obj, 'init_setting')
|
||||||
|
)
|
||||||
|
self._running_modules = {}
|
||||||
|
self._modules = {}
|
||||||
|
for module in modules:
|
||||||
|
module_id = module.__name__
|
||||||
|
self._modules[module_id] = module
|
||||||
|
# 生成实例
|
||||||
|
self._running_modules[module_id] = module()
|
||||||
|
self._running_modules[module_id].init_module()
|
||||||
|
logger.info(f"Moudle Loaded:{module_id}")
|
||||||
|
|
||||||
|
def get_modules(self, method: str) -> Generator:
|
||||||
|
"""
|
||||||
|
获取模块列表
|
||||||
|
"""
|
||||||
|
|
||||||
|
def check_method(func: FunctionType) -> bool:
|
||||||
|
"""
|
||||||
|
检查函数是否已实现
|
||||||
|
"""
|
||||||
|
return func.__code__.co_code != b'd\x01S\x00'
|
||||||
|
|
||||||
|
def check_setting(setting: Optional[tuple]) -> bool:
|
||||||
|
"""
|
||||||
|
检查开关是否己打开
|
||||||
|
"""
|
||||||
|
if not setting:
|
||||||
|
return True
|
||||||
|
switch, value = setting
|
||||||
|
if getattr(settings, switch) and value is True:
|
||||||
|
return True
|
||||||
|
if getattr(settings, switch) == value:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._running_modules:
|
||||||
|
return []
|
||||||
|
for _, module in self._running_modules.items():
|
||||||
|
if hasattr(module, method) \
|
||||||
|
and check_method(getattr(module, method)) \
|
||||||
|
and check_setting(module.init_setting()):
|
||||||
|
yield module
|
302
app/core/plugin_manager.py
Normal file
302
app/core/plugin_manager.py
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import traceback
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Tuple, Optional, List, Any
|
||||||
|
|
||||||
|
from app.helper import ModuleHelper
|
||||||
|
|
||||||
|
from app.core import EventManager
|
||||||
|
from app.db.systemconfigs import SystemConfigs
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
from app.utils.types import SystemConfigKey
|
||||||
|
|
||||||
|
|
||||||
|
class PluginManager(metaclass=Singleton):
|
||||||
|
"""
|
||||||
|
插件管理器
|
||||||
|
"""
|
||||||
|
systemconfigs: SystemConfigs = None
|
||||||
|
eventmanager: EventManager = None
|
||||||
|
|
||||||
|
# 插件列表
|
||||||
|
_plugins: dict = {}
|
||||||
|
# 运行态插件列表
|
||||||
|
_running_plugins: dict = {}
|
||||||
|
# 配置Key
|
||||||
|
_config_key: str = "plugin.%s"
|
||||||
|
# 事件处理线程
|
||||||
|
_thread: Thread = None
|
||||||
|
# 开关
|
||||||
|
_active: bool = False
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.init_config()
|
||||||
|
|
||||||
|
def init_config(self):
|
||||||
|
self.systemconfigs = SystemConfigs()
|
||||||
|
self.eventmanager = EventManager()
|
||||||
|
# 停止已有插件
|
||||||
|
self.stop_service()
|
||||||
|
# 启动插件
|
||||||
|
self.start_service()
|
||||||
|
|
||||||
|
def __run(self):
|
||||||
|
"""
|
||||||
|
事件处理线程
|
||||||
|
"""
|
||||||
|
while self._active:
|
||||||
|
event, handlers = self.eventmanager.get_event()
|
||||||
|
if event:
|
||||||
|
logger.info(f"处理事件:{event.event_type} - {handlers}")
|
||||||
|
for handler in handlers:
|
||||||
|
try:
|
||||||
|
names = handler.__qualname__.split(".")
|
||||||
|
self.run_plugin_method(names[0], names[1], event)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||||
|
|
||||||
|
def start_service(self):
|
||||||
|
"""
|
||||||
|
启动
|
||||||
|
"""
|
||||||
|
# 加载插件
|
||||||
|
self.__load_plugins()
|
||||||
|
|
||||||
|
# 将事件管理器设为启动
|
||||||
|
self._active = True
|
||||||
|
self._thread = Thread(target=self.__run)
|
||||||
|
# 启动事件处理线程
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop_service(self):
|
||||||
|
"""
|
||||||
|
停止
|
||||||
|
"""
|
||||||
|
# 将事件管理器设为停止
|
||||||
|
self._active = False
|
||||||
|
# 等待事件处理线程退出
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join()
|
||||||
|
# 停止所有插件
|
||||||
|
self.__stop_plugins()
|
||||||
|
|
||||||
|
def __load_plugins(self):
|
||||||
|
"""
|
||||||
|
加载所有插件
|
||||||
|
"""
|
||||||
|
# 扫描插件目录
|
||||||
|
plugins = ModuleHelper.load(
|
||||||
|
"app.plugins",
|
||||||
|
filter_func=lambda _, obj: hasattr(obj, 'init_plugin')
|
||||||
|
)
|
||||||
|
# 排序
|
||||||
|
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
|
||||||
|
# 用户已安装插件列表
|
||||||
|
user_plugins = self.systemconfigs.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
|
self._running_plugins = {}
|
||||||
|
self._plugins = {}
|
||||||
|
for plugin in plugins:
|
||||||
|
plugin_id = plugin.__name__
|
||||||
|
self._plugins[plugin_id] = plugin
|
||||||
|
# 未安装的跳过加载
|
||||||
|
if plugin_id not in user_plugins:
|
||||||
|
continue
|
||||||
|
# 生成实例
|
||||||
|
self._running_plugins[plugin_id] = plugin()
|
||||||
|
# 初始化配置
|
||||||
|
self.reload_plugin(plugin_id)
|
||||||
|
logger.info(f"加载插件:{plugin}")
|
||||||
|
|
||||||
|
def reload_plugin(self, pid: str):
|
||||||
|
"""
|
||||||
|
生效插件配置
|
||||||
|
"""
|
||||||
|
if not pid:
|
||||||
|
return
|
||||||
|
if not self._running_plugins.get(pid):
|
||||||
|
return
|
||||||
|
if hasattr(self._running_plugins[pid], "init_plugin"):
|
||||||
|
try:
|
||||||
|
self._running_plugins[pid].init_plugin(self.get_plugin_config(pid))
|
||||||
|
logger.debug(f"生效插件配置:{pid}")
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"加载插件 {pid} 出错:{err} - {traceback.format_exc()}")
|
||||||
|
|
||||||
|
def __stop_plugins(self):
|
||||||
|
"""
|
||||||
|
停止所有插件
|
||||||
|
"""
|
||||||
|
for plugin in self._running_plugins.values():
|
||||||
|
if hasattr(plugin, "stop_service"):
|
||||||
|
plugin.stop_service()
|
||||||
|
|
||||||
|
def get_plugin_config(self, pid: str) -> dict:
|
||||||
|
"""
|
||||||
|
获取插件配置
|
||||||
|
"""
|
||||||
|
if not self._plugins.get(pid):
|
||||||
|
return {}
|
||||||
|
return self.systemconfigs.get(self._config_key % pid) or {}
|
||||||
|
|
||||||
|
def get_plugin_page(self, pid: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
获取插件额外页面数据
|
||||||
|
:return: 标题,页面内容,确定按钮响应函数
|
||||||
|
"""
|
||||||
|
if not self._running_plugins.get(pid):
|
||||||
|
return None, None, None
|
||||||
|
if not hasattr(self._running_plugins[pid], "get_page"):
|
||||||
|
return None, None, None
|
||||||
|
return self._running_plugins[pid].get_page()
|
||||||
|
|
||||||
|
def get_plugin_script(self, pid: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取插件额外脚本
|
||||||
|
"""
|
||||||
|
if not self._running_plugins.get(pid):
|
||||||
|
return None
|
||||||
|
if not hasattr(self._running_plugins[pid], "get_script"):
|
||||||
|
return None
|
||||||
|
return self._running_plugins[pid].get_script()
|
||||||
|
|
||||||
|
def get_plugin_state(self, pid: str) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
获取插件状态
|
||||||
|
"""
|
||||||
|
if not self._running_plugins.get(pid):
|
||||||
|
return None
|
||||||
|
if not hasattr(self._running_plugins[pid], "get_state"):
|
||||||
|
return None
|
||||||
|
return self._running_plugins[pid].get_state()
|
||||||
|
|
||||||
|
def save_plugin_config(self, pid: str, conf: dict) -> bool:
|
||||||
|
"""
|
||||||
|
保存插件配置
|
||||||
|
"""
|
||||||
|
if not self._plugins.get(pid):
|
||||||
|
return False
|
||||||
|
return self.systemconfigs.set(self._config_key % pid, conf)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_plugin_color(plugin: str) -> str:
|
||||||
|
"""
|
||||||
|
获取插件的主题色
|
||||||
|
"""
|
||||||
|
if hasattr(plugin, "plugin_color") and plugin.plugin_color:
|
||||||
|
return plugin.plugin_color
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_plugins_conf(self, auth_level: int) -> dict:
|
||||||
|
"""
|
||||||
|
获取所有插件配置
|
||||||
|
"""
|
||||||
|
all_confs = {}
|
||||||
|
for pid, plugin in self._running_plugins.items():
|
||||||
|
# 基本属性
|
||||||
|
conf = {}
|
||||||
|
# 权限
|
||||||
|
if hasattr(plugin, "auth_level") \
|
||||||
|
and plugin.auth_level > auth_level:
|
||||||
|
continue
|
||||||
|
# 名称
|
||||||
|
if hasattr(plugin, "plugin_name"):
|
||||||
|
conf.update({"name": plugin.plugin_name})
|
||||||
|
# 描述
|
||||||
|
if hasattr(plugin, "plugin_desc"):
|
||||||
|
conf.update({"desc": plugin.plugin_desc})
|
||||||
|
# 版本号
|
||||||
|
if hasattr(plugin, "plugin_version"):
|
||||||
|
conf.update({"version": plugin.plugin_version})
|
||||||
|
# 图标
|
||||||
|
if hasattr(plugin, "plugin_icon"):
|
||||||
|
conf.update({"icon": plugin.plugin_icon})
|
||||||
|
# ID前缀
|
||||||
|
if hasattr(plugin, "plugin_config_prefix"):
|
||||||
|
conf.update({"prefix": plugin.plugin_config_prefix})
|
||||||
|
# 插件额外的页面
|
||||||
|
if hasattr(plugin, "get_page"):
|
||||||
|
title, _, _ = plugin.get_page()
|
||||||
|
conf.update({"page": title})
|
||||||
|
# 插件额外的脚本
|
||||||
|
if hasattr(plugin, "get_script"):
|
||||||
|
conf.update({"script": plugin.get_script()})
|
||||||
|
# 主题色
|
||||||
|
conf.update({"color": self.__get_plugin_color(plugin)})
|
||||||
|
# 配置项
|
||||||
|
conf.update({"fields": plugin.get_fields() or {}})
|
||||||
|
# 配置值
|
||||||
|
conf.update({"config": self.get_plugin_config(pid)})
|
||||||
|
# 状态
|
||||||
|
conf.update({"state": plugin.get_state()})
|
||||||
|
# 汇总
|
||||||
|
all_confs[pid] = conf
|
||||||
|
return all_confs
|
||||||
|
|
||||||
|
def get_plugin_apps(self, auth_level: int) -> dict:
|
||||||
|
"""
|
||||||
|
获取所有插件
|
||||||
|
"""
|
||||||
|
all_confs = {}
|
||||||
|
installed_apps = self.systemconfigs.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||||
|
for pid, plugin in self._plugins.items():
|
||||||
|
# 基本属性
|
||||||
|
conf = {}
|
||||||
|
# 权限
|
||||||
|
if hasattr(plugin, "auth_level") \
|
||||||
|
and plugin.auth_level > auth_level:
|
||||||
|
continue
|
||||||
|
# ID
|
||||||
|
conf.update({"id": pid})
|
||||||
|
# 安装状态
|
||||||
|
if pid in installed_apps:
|
||||||
|
conf.update({"installed": True})
|
||||||
|
else:
|
||||||
|
conf.update({"installed": False})
|
||||||
|
# 名称
|
||||||
|
if hasattr(plugin, "plugin_name"):
|
||||||
|
conf.update({"name": plugin.plugin_name})
|
||||||
|
# 描述
|
||||||
|
if hasattr(plugin, "plugin_desc"):
|
||||||
|
conf.update({"desc": plugin.plugin_desc})
|
||||||
|
# 版本
|
||||||
|
if hasattr(plugin, "plugin_version"):
|
||||||
|
conf.update({"version": plugin.plugin_version})
|
||||||
|
# 图标
|
||||||
|
if hasattr(plugin, "plugin_icon"):
|
||||||
|
conf.update({"icon": plugin.plugin_icon})
|
||||||
|
# 主题色
|
||||||
|
conf.update({"color": self.__get_plugin_color(plugin)})
|
||||||
|
if hasattr(plugin, "plugin_author"):
|
||||||
|
conf.update({"author": plugin.plugin_author})
|
||||||
|
# 作者链接
|
||||||
|
if hasattr(plugin, "author_url"):
|
||||||
|
conf.update({"author_url": plugin.author_url})
|
||||||
|
# 汇总
|
||||||
|
all_confs[pid] = conf
|
||||||
|
return all_confs
|
||||||
|
|
||||||
|
def get_plugin_commands(self) -> List[dict]:
|
||||||
|
"""
|
||||||
|
获取插件命令
|
||||||
|
[{
|
||||||
|
"cmd": "/xx",
|
||||||
|
"event": EventType.xx,
|
||||||
|
"desc": "xxxx",
|
||||||
|
"data": {}
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
ret_commands = []
|
||||||
|
for _, plugin in self._running_plugins.items():
|
||||||
|
if hasattr(plugin, "get_command"):
|
||||||
|
ret_commands.append(plugin.get_command())
|
||||||
|
return ret_commands
|
||||||
|
|
||||||
|
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
|
||||||
|
"""
|
||||||
|
运行插件方法
|
||||||
|
"""
|
||||||
|
if not self._running_plugins.get(pid):
|
||||||
|
return None
|
||||||
|
if not hasattr(self._running_plugins[pid], method):
|
||||||
|
return None
|
||||||
|
return getattr(self._running_plugins[pid], method)(*args, **kwargs)
|
49
app/core/security.py
Normal file
49
app/core/security.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Union, Optional
|
||||||
|
import jwt
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from app.core.config import settings
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
# Token认证
|
||||||
|
reusable_oauth2 = OAuth2PasswordBearer(
|
||||||
|
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
subject: Union[str, Any], expires_delta: timedelta = None
|
||||||
|
) -> str:
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(
|
||||||
|
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
|
to_encode = {"exp": expire, "sub": str(subject)}
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(data, key) -> Optional[bytes]:
|
||||||
|
"""
|
||||||
|
解密二进制数据
|
||||||
|
"""
|
||||||
|
fernet = Fernet(key)
|
||||||
|
try:
|
||||||
|
return fernet.decrypt(data)
|
||||||
|
except Exception as e:
|
||||||
|
print(str(e))
|
||||||
|
return None
|
29
app/db/__init__.py
Normal file
29
app/db/__init__.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from sqlalchemy import create_engine, QueuePool
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# 数据库引擎
|
||||||
|
Engine = create_engine(f"sqlite:///{settings.CONFIG_PATH}/user.db",
|
||||||
|
pool_pre_ping=True,
|
||||||
|
echo=False,
|
||||||
|
poolclass=QueuePool,
|
||||||
|
pool_size=1000,
|
||||||
|
pool_recycle=60 * 10,
|
||||||
|
max_overflow=0)
|
||||||
|
# 数据库会话
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=Engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""
|
||||||
|
获取数据库会话
|
||||||
|
:return: Session
|
||||||
|
"""
|
||||||
|
db = None
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
if db:
|
||||||
|
db.close()
|
BIN
app/db/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/db/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/db/__pycache__/init.cpython-310.pyc
Normal file
BIN
app/db/__pycache__/init.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/db/__pycache__/sites.cpython-310.pyc
Normal file
BIN
app/db/__pycache__/sites.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/db/__pycache__/subscribes.cpython-310.pyc
Normal file
BIN
app/db/__pycache__/subscribes.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/db/__pycache__/systemconfigs.cpython-310.pyc
Normal file
BIN
app/db/__pycache__/systemconfigs.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/db/__pycache__/userauth.cpython-310.pyc
Normal file
BIN
app/db/__pycache__/userauth.cpython-310.pyc
Normal file
Binary file not shown.
42
app/db/init.py
Normal file
42
app/db/init.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from alembic.command import upgrade
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
from app.db import Engine, SessionLocal
|
||||||
|
from app.db.models import Base
|
||||||
|
from app.db.models.user import User
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""
|
||||||
|
初始化数据库
|
||||||
|
"""
|
||||||
|
Base.metadata.create_all(bind=Engine)
|
||||||
|
# 初始化超级管理员
|
||||||
|
_db = SessionLocal()
|
||||||
|
user = User.get_by_email(db=_db, email=settings.SUPERUSER)
|
||||||
|
if not user:
|
||||||
|
user = User(
|
||||||
|
full_name="Admin",
|
||||||
|
email=settings.SUPERUSER,
|
||||||
|
hashed_password=get_password_hash(settings.SUPERUSER_PASSWORD),
|
||||||
|
is_superuser=True,
|
||||||
|
)
|
||||||
|
user.create(_db)
|
||||||
|
|
||||||
|
|
||||||
|
def update_db():
|
||||||
|
"""
|
||||||
|
更新数据库
|
||||||
|
"""
|
||||||
|
db_location = settings.CONFIG_PATH / 'user.db'
|
||||||
|
script_location = settings.ROOT_PATH / 'alembic'
|
||||||
|
try:
|
||||||
|
alembic_cfg = Config()
|
||||||
|
alembic_cfg.set_main_option('script_location', str(script_location))
|
||||||
|
alembic_cfg.set_main_option('sqlalchemy.url', f"sqlite:///{db_location}")
|
||||||
|
upgrade(alembic_cfg, 'head')
|
||||||
|
except Exception as e:
|
||||||
|
logger(f'数据库更新失败:{e}')
|
42
app/db/models/__init__.py
Normal file
42
app/db/models/__init__.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import as_declarative, declared_attr
|
||||||
|
|
||||||
|
|
||||||
|
@as_declarative()
|
||||||
|
class Base:
|
||||||
|
id: Any
|
||||||
|
__name__: str
|
||||||
|
|
||||||
|
def create(self, db):
|
||||||
|
db.add(self)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(self)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, db, rid: int):
|
||||||
|
return db.query(cls).filter(cls.id == rid).first()
|
||||||
|
|
||||||
|
def update(self, db, payload: dict):
|
||||||
|
payload = {k: v for k, v in payload.items() if v is not None}
|
||||||
|
for key, value in payload.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete(cls, db, rid):
|
||||||
|
db.query(cls).filter(cls.id == rid).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list(cls, db):
|
||||||
|
return db.query(cls).all()
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
|
||||||
|
|
||||||
|
@declared_attr
|
||||||
|
def __tablename__(self) -> str:
|
||||||
|
return self.__name__.lower()
|
BIN
app/db/models/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/db/models/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/db/models/__pycache__/site.cpython-310.pyc
Normal file
BIN
app/db/models/__pycache__/site.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/db/models/__pycache__/subscribe.cpython-310.pyc
Normal file
BIN
app/db/models/__pycache__/subscribe.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/db/models/__pycache__/systemconfig.cpython-310.pyc
Normal file
BIN
app/db/models/__pycache__/systemconfig.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/db/models/__pycache__/user.cpython-310.pyc
Normal file
BIN
app/db/models/__pycache__/user.cpython-310.pyc
Normal file
Binary file not shown.
28
app/db/models/site.py
Normal file
28
app/db/models/site.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Site(Base):
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
domain = Column(String, index=True)
|
||||||
|
url = Column(String, nullable=False)
|
||||||
|
pri = Column(Integer)
|
||||||
|
rss = Column(String)
|
||||||
|
cookie = Column(String)
|
||||||
|
ua = Column(String)
|
||||||
|
filter = Column(String)
|
||||||
|
note = Column(String)
|
||||||
|
limit_interval = Column(Integer)
|
||||||
|
limit_count = Column(Integer)
|
||||||
|
limit_seconds = Column(Integer)
|
||||||
|
is_active = Column(Boolean(), default=True)
|
||||||
|
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_domain(db: Session, domain: str):
|
||||||
|
return db.query(Site).filter(Site.domain == domain).first()
|
36
app/db/models/subscribe.py
Normal file
36
app/db/models/subscribe.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Sequence
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Subscribe(Base):
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False, index=True)
|
||||||
|
year = Column(String)
|
||||||
|
type = Column(String)
|
||||||
|
keyword = Column(String)
|
||||||
|
tmdbid = Column(String, index=True)
|
||||||
|
doubanid = Column(String)
|
||||||
|
season = Column(Integer)
|
||||||
|
image = Column(String)
|
||||||
|
description = Column(String)
|
||||||
|
filter = Column(String)
|
||||||
|
include = Column(String)
|
||||||
|
exclude = Column(String)
|
||||||
|
total_episode = Column(Integer)
|
||||||
|
start_episode = Column(Integer)
|
||||||
|
lack_episode = Column(Integer)
|
||||||
|
note = Column(String)
|
||||||
|
state = Column(String, nullable=False, index=True, default='N')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def exists(db: Session, tmdbid: str, season: int = None):
|
||||||
|
if season:
|
||||||
|
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||||
|
Subscribe.season == season).first()
|
||||||
|
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_state(db: Session, state: str):
|
||||||
|
return db.query(Subscribe).filter(Subscribe.state == state).all()
|
19
app/db/models/systemconfig.py
Normal file
19
app/db/models/systemconfig.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Sequence
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfig(Base):
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
key = Column(String, index=True)
|
||||||
|
value = Column(String, nullable=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_key(db: Session, key: str):
|
||||||
|
return db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_by_key(db: Session, key: str):
|
||||||
|
db.query(SystemConfig).filter(SystemConfig.key == key).delete()
|
||||||
|
db.commit()
|
27
app/db/models/user.py
Normal file
27
app/db/models/user.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.security import verify_password
|
||||||
|
from app.db.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||||
|
full_name = Column(String, index=True)
|
||||||
|
email = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String, nullable=False)
|
||||||
|
is_active = Column(Boolean(), default=True)
|
||||||
|
is_superuser = Column(Boolean(), default=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def authenticate(db: Session, email: str, password: str):
|
||||||
|
user = db.query(User).filter(User.email == email).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, str(user.hashed_password)):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_email(db: Session, email: str):
|
||||||
|
return db.query(User).filter(User.email == email).first()
|
56
app/db/sites.py
Normal file
56
app/db/sites.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from typing import Tuple, List
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.db.models.site import Site
|
||||||
|
|
||||||
|
|
||||||
|
class Sites:
|
||||||
|
"""
|
||||||
|
站点管理
|
||||||
|
"""
|
||||||
|
_db: Session = None
|
||||||
|
|
||||||
|
def __init__(self, _db=SessionLocal()):
|
||||||
|
self._db = _db
|
||||||
|
|
||||||
|
def add(self, **kwargs) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
新增站点
|
||||||
|
"""
|
||||||
|
site = Site(**kwargs)
|
||||||
|
if not site.get_by_domain(self._db, kwargs.get("domain")):
|
||||||
|
site.create(self._db)
|
||||||
|
return True, "新增站点成功"
|
||||||
|
return False, "站点已存在"
|
||||||
|
|
||||||
|
def list(self) -> List[Site]:
|
||||||
|
"""
|
||||||
|
获取站点列表
|
||||||
|
"""
|
||||||
|
return Site.list(self._db)
|
||||||
|
|
||||||
|
def get_by_domain(self, domain: str) -> Site:
|
||||||
|
"""
|
||||||
|
按域名获取站点
|
||||||
|
"""
|
||||||
|
return Site.get_by_domain(self._db, domain)
|
||||||
|
|
||||||
|
def exists(self, domain: str) -> bool:
|
||||||
|
"""
|
||||||
|
判断站点是否存在
|
||||||
|
"""
|
||||||
|
return Site.get_by_domain(self._db, domain) is not None
|
||||||
|
|
||||||
|
def update_cookie(self, domain: str, cookies: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
更新站点Cookie
|
||||||
|
"""
|
||||||
|
site = Site.get_by_domain(self._db, domain)
|
||||||
|
if not site:
|
||||||
|
return False, "站点不存在"
|
||||||
|
site.update(self._db, {
|
||||||
|
"cookie": cookies
|
||||||
|
})
|
||||||
|
return True, "更新站点Cookie成功"
|
76
app/db/subscribes.py
Normal file
76
app/db/subscribes.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from typing import Tuple, List
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core import MediaInfo
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.db.models.subscribe import Subscribe
|
||||||
|
from app.utils.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class Subscribes:
|
||||||
|
"""
|
||||||
|
订阅管理
|
||||||
|
"""
|
||||||
|
_db: Session = None
|
||||||
|
|
||||||
|
def __init__(self, _db=SessionLocal()):
|
||||||
|
self._db = _db
|
||||||
|
|
||||||
|
def add(self, mediainfo: MediaInfo, **kwargs) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
新增订阅
|
||||||
|
"""
|
||||||
|
# 总集数
|
||||||
|
if mediainfo.type == MediaType.TV:
|
||||||
|
if not kwargs.get('season'):
|
||||||
|
kwargs.update({
|
||||||
|
'season': 1
|
||||||
|
})
|
||||||
|
if not kwargs.get('total_episode'):
|
||||||
|
total_episode = len(mediainfo.seasons.get(kwargs.get('season')) or [])
|
||||||
|
if not total_episode:
|
||||||
|
return False, "未识别到总集数"
|
||||||
|
kwargs.update({
|
||||||
|
'total_episode': total_episode
|
||||||
|
})
|
||||||
|
subscribe = Subscribe(name=mediainfo.title,
|
||||||
|
year=mediainfo.year,
|
||||||
|
type=mediainfo.type.value,
|
||||||
|
tmdbid=mediainfo.tmdb_id,
|
||||||
|
image=mediainfo.get_poster_image(),
|
||||||
|
description=mediainfo.overview,
|
||||||
|
**kwargs)
|
||||||
|
if not subscribe.exists(self._db, tmdbid=mediainfo.tmdb_id, season=kwargs.get('season')):
|
||||||
|
subscribe.create(self._db)
|
||||||
|
return True, "新增订阅成功"
|
||||||
|
else:
|
||||||
|
return False, "订阅已存在"
|
||||||
|
|
||||||
|
def get(self, sid: int) -> Subscribe:
|
||||||
|
"""
|
||||||
|
获取订阅
|
||||||
|
"""
|
||||||
|
return Subscribe.get(self._db, rid=sid)
|
||||||
|
|
||||||
|
def list(self, state: str = None) -> List[Subscribe]:
|
||||||
|
"""
|
||||||
|
获取订阅列表
|
||||||
|
"""
|
||||||
|
if state:
|
||||||
|
return Subscribe.get_by_state(self._db, state)
|
||||||
|
return Subscribe.list(self._db)
|
||||||
|
|
||||||
|
def delete(self, sid: int):
|
||||||
|
"""
|
||||||
|
删除订阅
|
||||||
|
"""
|
||||||
|
Subscribe.delete(self._db, rid=sid)
|
||||||
|
|
||||||
|
def update(self, sid: int, payload: dict):
|
||||||
|
"""
|
||||||
|
更新订阅
|
||||||
|
"""
|
||||||
|
subscribe = self.get(sid)
|
||||||
|
subscribe.update(self._db, payload)
|
||||||
|
return subscribe
|
58
app/db/systemconfigs.py
Normal file
58
app/db/systemconfigs.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any, Union
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.db.models.systemconfig import SystemConfig
|
||||||
|
from app.utils.object import ObjectUtils
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
from app.utils.types import SystemConfigKey
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigs(metaclass=Singleton):
|
||||||
|
# 配置对象
|
||||||
|
__SYSTEMCONF: dict = {}
|
||||||
|
_db: Session = None
|
||||||
|
|
||||||
|
def __init__(self, _db=SessionLocal()):
|
||||||
|
"""
|
||||||
|
加载配置到内存
|
||||||
|
"""
|
||||||
|
self._db = _db
|
||||||
|
for item in SystemConfig.list(self._db):
|
||||||
|
if ObjectUtils.is_obj(item.value):
|
||||||
|
self.__SYSTEMCONF[item.key] = json.loads(item.value)
|
||||||
|
else:
|
||||||
|
self.__SYSTEMCONF[item.key] = item.value
|
||||||
|
|
||||||
|
def set(self, key: Union[str, SystemConfigKey], value: Any):
|
||||||
|
"""
|
||||||
|
设置系统设置
|
||||||
|
"""
|
||||||
|
if isinstance(key, SystemConfigKey):
|
||||||
|
key = key.value
|
||||||
|
# 更新内存
|
||||||
|
self.__SYSTEMCONF[key] = value
|
||||||
|
# 写入数据库
|
||||||
|
if ObjectUtils.is_obj(value):
|
||||||
|
if value is not None:
|
||||||
|
value = json.dumps(value)
|
||||||
|
else:
|
||||||
|
value = ''
|
||||||
|
conf = SystemConfig.get_by_key(self._db, key)
|
||||||
|
if conf:
|
||||||
|
conf.update(self._db, {"value": value})
|
||||||
|
else:
|
||||||
|
conf = SystemConfig(key=key, value=value)
|
||||||
|
conf.create(self._db)
|
||||||
|
|
||||||
|
def get(self, key: Union[str, SystemConfigKey] = None):
|
||||||
|
"""
|
||||||
|
获取系统设置
|
||||||
|
"""
|
||||||
|
if isinstance(key, SystemConfigKey):
|
||||||
|
key = key.value
|
||||||
|
if not key:
|
||||||
|
return self.__SYSTEMCONF
|
||||||
|
return self.__SYSTEMCONF.get(key)
|
46
app/db/userauth.py
Normal file
46
app/db/userauth.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import jwt
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.core import settings, security
|
||||||
|
from app.core.security import reusable_oauth2
|
||||||
|
from app.db import get_db
|
||||||
|
from app.db.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)
|
||||||
|
) -> User:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||||
|
)
|
||||||
|
token_data = schemas.TokenPayload(**payload)
|
||||||
|
except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="token校验不通过",
|
||||||
|
)
|
||||||
|
user = User.get(db, rid=token_data.sub)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_active_user(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> User:
|
||||||
|
if not current_user.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="用户未激活")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_active_superuser(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> User:
|
||||||
|
if not current_user.is_superuser:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="用户权限不足"
|
||||||
|
)
|
||||||
|
return current_user
|
1
app/helper/__init__.py
Normal file
1
app/helper/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .module import ModuleHelper
|
BIN
app/helper/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/helper/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/helper/__pycache__/cookiecloud.cpython-310.pyc
Normal file
BIN
app/helper/__pycache__/cookiecloud.cpython-310.pyc
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user