feat 手动整理API

This commit is contained in:
jxxghp 2023-08-26 22:47:41 +08:00
parent c593f6423c
commit fc2312a045
9 changed files with 324 additions and 15 deletions

View File

@ -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"])
@ -20,4 +20,5 @@ api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"])
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(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])

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

View File

@ -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:
"""

View File

@ -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):
"""
新增转移成功历史记录
"""

View File

@ -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,

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

View File

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

View File

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

View File

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