feat 手动整理API
This commit is contained in:
parent
c593f6423c
commit
fc2312a045
@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, rss, filebrowser
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, rss, filebrowser, transfer
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||
@ -21,3 +21,4 @@ api_router.include_router(download.router, prefix="/download", tags=["download"]
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(rss.router, prefix="/rss", tags=["rss"])
|
||||
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
|
||||
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
|
||||
|
89
app/api/endpoints/transfer.py
Normal file
89
app/api/endpoints/transfer.py
Normal file
@ -0,0 +1,89 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||
def manual_transfer(path: str,
|
||||
tmdbid: int,
|
||||
type_name: str,
|
||||
target: str = None,
|
||||
season: int = None,
|
||||
transfer_type: str = settings.TRANSFER_TYPE,
|
||||
episode_format: str = None,
|
||||
episode_detail: str = None,
|
||||
episode_part: str = None,
|
||||
episode_offset: int = None,
|
||||
min_filesize: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
手动转移,支持自定义剧集识别格式
|
||||
:param path: 转移路径或文件
|
||||
:param target: 目标路径
|
||||
:param type_name: 媒体类型、电影/电视剧
|
||||
:param tmdbid: tmdbid
|
||||
:param season: 剧集季号
|
||||
:param transfer_type: 转移类型,move/copy
|
||||
:param episode_format: 剧集识别格式
|
||||
:param episode_detail: 剧集识别详细信息
|
||||
:param episode_part: 剧集识别分集信息
|
||||
:param episode_offset: 剧集识别偏移量
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
:param db: 数据库
|
||||
:param _: Token校验
|
||||
"""
|
||||
in_path = Path(path)
|
||||
if target:
|
||||
target = Path(target)
|
||||
if not target.exists():
|
||||
return schemas.Response(success=False, message=f"目标路径不存在")
|
||||
# 识别元数据
|
||||
meta = MetaInfo(in_path.stem)
|
||||
mtype = MediaType(type_name)
|
||||
# 整合数据
|
||||
meta.type = mtype
|
||||
if season:
|
||||
meta.begin_season = season
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = MediaChain(db).recognize_media(tmdbid=tmdbid, mtype=mtype)
|
||||
if not mediainfo:
|
||||
return schemas.Response(success=False, message=f"媒体信息识别失败,tmdbid: {tmdbid}")
|
||||
# 自定义格式
|
||||
epformat = None
|
||||
if episode_offset or episode_part or episode_detail or episode_format:
|
||||
epformat = schemas.EpisodeFormat(
|
||||
format=episode_format,
|
||||
detail=episode_detail,
|
||||
part=episode_part,
|
||||
offset=episode_offset,
|
||||
)
|
||||
# 开始转移
|
||||
state, errormsg = TransferChain(db).manual_transfer(
|
||||
in_path=in_path,
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=transfer_type,
|
||||
target=target,
|
||||
meta=meta,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize
|
||||
)
|
||||
# 失败
|
||||
if not state:
|
||||
return schemas.Response(success=False, message=errormsg)
|
||||
# 成功
|
||||
return schemas.Response(success=True)
|
@ -18,7 +18,7 @@ from app.core.meta import MetaBase
|
||||
from app.core.module import ModuleManager
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||
WebhookEventInfo
|
||||
WebhookEventInfo, EpisodeFormat
|
||||
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
@ -274,7 +274,9 @@ class ChainBase(metaclass=ABCMeta):
|
||||
def transfer(self, path: Path, mediainfo: MediaInfo,
|
||||
transfer_type: str,
|
||||
target: Path = None,
|
||||
meta: MetaBase = None) -> Optional[TransferInfo]:
|
||||
meta: MetaBase = None,
|
||||
epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0) -> Optional[TransferInfo]:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
@ -282,10 +284,13 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param transfer_type: 转移模式
|
||||
:param target: 转移目标路径
|
||||
:param meta: 预识别的元数据,仅单文件转移时传递
|
||||
:param epformat: 自定义剧集识别格式
|
||||
:param min_filesize: 最小文件大小
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
return self.run_module("transfer", path=path, mediainfo=mediainfo,
|
||||
transfer_type=transfer_type, target=target, meta=meta)
|
||||
transfer_type=transfer_type, target=target, meta=meta,
|
||||
epformat=epformat, min_filesize=min_filesize)
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list], transinfo: TransferInfo = None) -> None:
|
||||
"""
|
||||
|
@ -17,7 +17,7 @@ from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, Notification
|
||||
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat
|
||||
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
@ -220,6 +220,7 @@ class TransferChain(ChainBase):
|
||||
"""
|
||||
远程重新转移,参数 历史记录ID TMDBID|类型
|
||||
"""
|
||||
|
||||
def args_error():
|
||||
self.post_message(Notification(channel=channel,
|
||||
title="请输入正确的命令格式:/redo [id] [tmdbid]|[类型],"
|
||||
@ -331,8 +332,62 @@ class TransferChain(ChainBase):
|
||||
|
||||
return True, ""
|
||||
|
||||
def __insert_sucess_history(self, src_path: Path, download_hash: str, meta: MetaBase,
|
||||
mediainfo: MediaInfo, transferinfo: TransferInfo):
|
||||
def manual_transfer(self, in_path: Path,
|
||||
mediainfo: MediaInfo,
|
||||
transfer_type: str = settings.TRANSFER_TYPE,
|
||||
target: Path = None,
|
||||
meta: MetaBase = None,
|
||||
epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0) -> Tuple[bool, str]:
|
||||
"""
|
||||
手动转移
|
||||
:param in_path: 源文件路径
|
||||
:param mediainfo: 媒体信息
|
||||
:param transfer_type: 转移类型
|
||||
:param target: 目标路径
|
||||
:param meta: 元数据
|
||||
:param epformat: 剧集格式
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
"""
|
||||
# 开始转移
|
||||
transferinfo: TransferInfo = self.transfer(
|
||||
path=in_path,
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=transfer_type,
|
||||
target=target,
|
||||
meta=meta,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize
|
||||
)
|
||||
if not transferinfo:
|
||||
return False, "文件转移模块运行失败"
|
||||
if not transferinfo.target_path:
|
||||
return False, transferinfo.message
|
||||
|
||||
# 新增转移成功历史记录
|
||||
self.__insert_sucess_history(
|
||||
src_path=in_path,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo
|
||||
)
|
||||
# 刮削元数据
|
||||
self.scrape_metadata(path=transferinfo.target_path, mediainfo=mediainfo)
|
||||
# 刷新媒体库
|
||||
self.refresh_mediaserver(mediainfo=mediainfo, file_path=transferinfo.target_path)
|
||||
# 发送通知
|
||||
self.send_transfer_message(meta=meta, mediainfo=mediainfo, transferinfo=transferinfo)
|
||||
# 广播事件
|
||||
self.eventmanager.send_event(EventType.TransferComplete, {
|
||||
'meta': meta,
|
||||
'mediainfo': mediainfo,
|
||||
'transferinfo': transferinfo
|
||||
})
|
||||
return True, ""
|
||||
|
||||
def __insert_sucess_history(self, src_path: Path, meta: MetaBase,
|
||||
mediainfo: MediaInfo, transferinfo: TransferInfo,
|
||||
download_hash: str = None):
|
||||
"""
|
||||
新增转移成功历史记录
|
||||
"""
|
||||
|
@ -11,7 +11,8 @@ from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas import TransferInfo
|
||||
from app.modules.filetransfer.format_parser import FormatParser
|
||||
from app.schemas import TransferInfo, EpisodeFormat
|
||||
from app.utils.system import SystemUtils
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
@ -30,7 +31,10 @@ class FileTransferModule(_ModuleBase):
|
||||
pass
|
||||
|
||||
def transfer(self, path: Path, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None, meta: MetaBase = None) -> TransferInfo:
|
||||
transfer_type: str, target: Path = None,
|
||||
meta: MetaBase = None,
|
||||
epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0) -> TransferInfo:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
@ -38,6 +42,8 @@ class FileTransferModule(_ModuleBase):
|
||||
:param transfer_type: 转移方式
|
||||
:param target: 目标路径
|
||||
:param meta: 预识别的元数据,仅单文件转移时传递
|
||||
:param epformat: 集识别格式
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
# 获取目标路径
|
||||
@ -51,7 +57,9 @@ class FileTransferModule(_ModuleBase):
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=transfer_type,
|
||||
target_dir=target,
|
||||
in_meta=meta)
|
||||
in_meta=meta,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize)
|
||||
|
||||
@staticmethod
|
||||
def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int:
|
||||
@ -316,7 +324,9 @@ class FileTransferModule(_ModuleBase):
|
||||
mediainfo: MediaInfo,
|
||||
transfer_type: str,
|
||||
target_dir: Path = None,
|
||||
in_meta: MetaBase = None
|
||||
in_meta: MetaBase = None,
|
||||
epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0
|
||||
) -> TransferInfo:
|
||||
"""
|
||||
识别并转移一个文件、多个文件或者目录
|
||||
@ -325,6 +335,8 @@ class FileTransferModule(_ModuleBase):
|
||||
:param target_dir: 目的文件夹,非空的转移到该文件夹,为空时则按类型转移到配置文件中的媒体库文件夹
|
||||
:param transfer_type: 文件转移方式
|
||||
:param in_meta:预识别元数,为空则重新识别
|
||||
:param epformat: 识别的剧集格式
|
||||
:param min_filesize: 最小文件大小(MB),小于该值的文件不转移
|
||||
:return: TransferInfo、错误信息
|
||||
"""
|
||||
# 检查目录路径
|
||||
@ -397,9 +409,24 @@ class FileTransferModule(_ModuleBase):
|
||||
file_list_new=[])
|
||||
else:
|
||||
# 获取文件清单
|
||||
transfer_files: List[Path] = SystemUtils.list_files(in_path, settings.RMT_MEDIAEXT)
|
||||
transfer_files: List[Path] = SystemUtils.list_files(
|
||||
directory=in_path,
|
||||
extensions=settings.RMT_MEDIAEXT,
|
||||
min_filesize=min_filesize
|
||||
)
|
||||
if len(transfer_files) == 0:
|
||||
return TransferInfo(message=f"{in_path} 目录下没有找到可转移的文件")
|
||||
# 有集自定义格式
|
||||
formaterHandler = FormatParser(eformat=epformat.format,
|
||||
details=epformat.detail,
|
||||
part=epformat.part,
|
||||
offset=epformat.offset) if epformat else None
|
||||
# 过滤出符合自定义剧集格式的文件
|
||||
if formaterHandler:
|
||||
transfer_files = [x for x in transfer_files if formaterHandler.match(x.name)]
|
||||
if len(transfer_files) == 0:
|
||||
return TransferInfo(message=f"{in_path} 目录下没有找到符合自定义剧集格式的文件")
|
||||
|
||||
if not in_meta:
|
||||
# 识别目录名称,不包括后缀
|
||||
meta = MetaInfo(in_path.stem)
|
||||
@ -431,6 +458,16 @@ class FileTransferModule(_ModuleBase):
|
||||
file_meta.total_episode = 1
|
||||
file_meta.end_episode = None
|
||||
|
||||
# 自定义识别
|
||||
if formaterHandler:
|
||||
# 开始集、结束集、PART
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(transfer_file.stem)
|
||||
if begin_ep is not None:
|
||||
file_meta.begin_episode = begin_ep
|
||||
file_meta.part = part
|
||||
if end_ep is not None:
|
||||
file_meta.end_episode = end_ep
|
||||
|
||||
# 目的文件名
|
||||
new_file = self.get_rename_path(
|
||||
path=target_dir,
|
||||
@ -446,6 +483,7 @@ class FileTransferModule(_ModuleBase):
|
||||
if new_file.stat().st_size < transfer_file.stat().st_size:
|
||||
logger.info(f"目标文件已存在,但文件大小更小,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
|
||||
# 转移文件
|
||||
retcode = self.__transfer_file(file_item=transfer_file,
|
||||
new_file=new_file,
|
||||
|
108
app/modules/filetransfer/format_parser.py
Normal file
108
app/modules/filetransfer/format_parser.py
Normal file
@ -0,0 +1,108 @@
|
||||
import re
|
||||
from typing import Tuple, Optional
|
||||
|
||||
import parse
|
||||
|
||||
|
||||
class FormatParser(object):
|
||||
_key = ""
|
||||
_split_chars = r"\.|\s+|\(|\)|\[|]|-|\+|【|】|/|~|;|&|\||#|_|「|」|~"
|
||||
|
||||
def __init__(self, eformat: str, details: str = None, part: str = None,
|
||||
offset: int = None, key: str = "ep"):
|
||||
"""
|
||||
:params eformat: 格式化字符串
|
||||
:params details: 格式化详情
|
||||
:params part: 分集
|
||||
:params offset: 偏移量
|
||||
:prams key: EP关键字
|
||||
"""
|
||||
self._format = eformat
|
||||
self._start_ep = None
|
||||
self._end_ep = None
|
||||
self._part = None
|
||||
if part:
|
||||
self._part = part
|
||||
if details:
|
||||
if re.compile("\\d{1,4}-\\d{1,4}").match(details):
|
||||
self._start_ep = details
|
||||
self._end_ep = details
|
||||
else:
|
||||
tmp = details.split(",")
|
||||
if len(tmp) > 1:
|
||||
self._start_ep = int(tmp[0])
|
||||
self._end_ep = int(tmp[0]) if int(tmp[0]) > int(tmp[1]) else int(tmp[1])
|
||||
else:
|
||||
self._start_ep = self._end_ep = int(tmp[0])
|
||||
self.__offset = int(offset) if offset else 0
|
||||
self._key = key
|
||||
|
||||
@property
|
||||
def format(self):
|
||||
return self._format
|
||||
|
||||
@property
|
||||
def start_ep(self):
|
||||
return self._start_ep
|
||||
|
||||
@property
|
||||
def end_ep(self):
|
||||
return self._end_ep
|
||||
|
||||
@property
|
||||
def part(self):
|
||||
return self._part
|
||||
|
||||
@property
|
||||
def offset(self):
|
||||
return self.__offset
|
||||
|
||||
def match(self, file: str) -> bool:
|
||||
if not self._format:
|
||||
return True
|
||||
s, e = self.__handle_single(file)
|
||||
if not s:
|
||||
return False
|
||||
if self._start_ep is None:
|
||||
return True
|
||||
if self._start_ep <= s <= self._end_ep:
|
||||
return True
|
||||
return False
|
||||
|
||||
def split_episode(self, file_name: str) -> Tuple[Optional[int], Optional[int], Optional[str]]:
|
||||
"""
|
||||
拆分集数,返回开始集数,结束集数,Part信息
|
||||
"""
|
||||
# 指定的具体集数,直接返回
|
||||
if self._start_ep is not None and self._start_ep == self._end_ep:
|
||||
if isinstance(self._start_ep, str):
|
||||
s, e = self._start_ep.split("-")
|
||||
if int(s) == int(e):
|
||||
return int(s) + self.__offset, None, self.part
|
||||
return int(s) + self.__offset, int(e) + self.__offset, self.part
|
||||
return self._start_ep + self.__offset, None, self.part
|
||||
if not self._format:
|
||||
return None, None, None
|
||||
s, e = self.__handle_single(file_name)
|
||||
return s + self.__offset if s is not None else None, \
|
||||
e + self.__offset if e is not None else None, self.part
|
||||
|
||||
def __handle_single(self, file: str) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""
|
||||
处理单集,返回单集的开始和结束集数
|
||||
"""
|
||||
if not self._format:
|
||||
return None, None
|
||||
ret = parse.parse(self._format, file)
|
||||
if not ret or not ret.__contains__(self._key):
|
||||
return None, None
|
||||
episodes = ret.__getitem__(self._key)
|
||||
if not re.compile(r"^(EP)?(\d{1,4})(-(EP)?(\d{1,4}))?$", re.IGNORECASE).match(episodes):
|
||||
return None, None
|
||||
episode_splits = list(filter(lambda x: re.compile(r'[a-zA-Z]*\d{1,4}', re.IGNORECASE).match(x),
|
||||
re.split(r'%s' % self._split_chars, episodes)))
|
||||
if len(episode_splits) == 1:
|
||||
return int(re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub("", episode_splits[0])), None
|
||||
else:
|
||||
return int(re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub("", episode_splits[0])), int(
|
||||
re.compile(r'[a-zA-Z]*', re.IGNORECASE).sub("", episode_splits[1]))
|
@ -62,3 +62,13 @@ class TransferInfo(BaseModel):
|
||||
dicts["path"] = str(self.path) if self.path else None
|
||||
dicts["target_path"] = str(self.target_path) if self.target_path else None
|
||||
return dicts
|
||||
|
||||
|
||||
class EpisodeFormat(BaseModel):
|
||||
"""
|
||||
剧集自定义识别格式
|
||||
"""
|
||||
format: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
part: Optional[str] = None
|
||||
offset: Optional[int] = None
|
||||
|
@ -91,7 +91,7 @@ class SystemUtils:
|
||||
return -1, str(err)
|
||||
|
||||
@staticmethod
|
||||
def list_files(directory: Path, extensions: list) -> List[Path]:
|
||||
def list_files(directory: Path, extensions: list, min_filesize: int = 0) -> List[Path]:
|
||||
"""
|
||||
获取目录下所有指定扩展名的文件(包括子目录)
|
||||
"""
|
||||
@ -106,7 +106,9 @@ class SystemUtils:
|
||||
|
||||
# 遍历目录及子目录
|
||||
for path in directory.rglob('**/*'):
|
||||
if path.is_file() and re.match(pattern, path.name, re.IGNORECASE):
|
||||
if path.is_file() \
|
||||
and re.match(pattern, path.name, re.IGNORECASE) \
|
||||
and path.stat().st_size >= min_filesize * 1024 * 1024:
|
||||
files.append(path)
|
||||
|
||||
return files
|
||||
|
@ -49,3 +49,4 @@ openai~=0.27.2
|
||||
cacheout~=0.14.1
|
||||
click~=8.1.6
|
||||
requests_cache~=0.5.2
|
||||
parse==1.19.0
|
Loading…
x
Reference in New Issue
Block a user