This commit is contained in:
jxxghp 2023-06-06 07:15:17 +08:00
commit 4d06f86e62
217 changed files with 13959 additions and 0 deletions

47
.github/workflows/build-docker.yml vendored Normal file
View 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
View File

@ -0,0 +1,5 @@
.idea/
*.c
build/
test.py
app/helper/sites.py

Binary file not shown.

80
alembic/env.py Normal file
View 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
View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

0
app/api/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

12
app/api/apiv1.py Normal file
View 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"])

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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",
}

View 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()

View 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

View 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}

View 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}

View 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

View 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

383
app/chain/common.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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
View 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

182
app/core/config.py Normal file
View 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
View 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
View 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()

View File

@ -0,0 +1,3 @@
from .metabase import MetaBase
from .metavideo import MetaVideo
from .metaanime import MetaAnime

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

218
app/core/meta/metaanime.py Normal file
View 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
View 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
View 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

View 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
View 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: MetaAnimeMetaVideo
"""
# 判断是否处理文件
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

View 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
View 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
View 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
View 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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

42
app/db/init.py Normal file
View 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
View 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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

28
app/db/models/site.py Normal file
View 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()

View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
from .module import ModuleHelper

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More