Compare commits

...

25 Commits

Author SHA1 Message Date
jxxghp
9ba9e8f41c v2.0.9 2024-11-22 08:11:07 +08:00
jxxghp
78fc5b7017 Merge pull request #3193 from wikrin/fix_any_files 2024-11-22 08:10:12 +08:00
Attente
fe07830b71 fix: 某些情况下误删媒体文件的问题 2024-11-22 07:45:01 +08:00
jxxghp
350f1faf2a Merge pull request #3189 from InfinityPacer/feature/module 2024-11-21 20:16:06 +08:00
InfinityPacer
103cfe0b47 fix(config): ensure accurate handling of env config updates 2024-11-21 20:08:18 +08:00
jxxghp
0953c1be16 Merge pull request #3187 from InfinityPacer/feature/scheduler 2024-11-21 17:43:29 +08:00
InfinityPacer
c299bf6f7c fix(auth): adjust auth to occur before module init 2024-11-21 17:37:48 +08:00
InfinityPacer
c0eb9d824c Revert "fix(auth): initialize plugin service only during retry auth"
This reverts commit 9f4cf530f8.
2024-11-21 16:41:56 +08:00
jxxghp
ebffdebdb2 refactor: 优化缓存策略 2024-11-21 15:52:08 +08:00
jxxghp
acd9e38477 Merge pull request #3186 from InfinityPacer/feature/scheduler 2024-11-21 14:54:01 +08:00
InfinityPacer
9f4cf530f8 fix(auth): initialize plugin service only during retry auth 2024-11-21 14:49:42 +08:00
jxxghp
84897aa592 fix #3162 2024-11-21 13:50:49 +08:00
jxxghp
23c5982f5a Merge pull request #3185 from InfinityPacer/feature/module 2024-11-21 12:42:05 +08:00
InfinityPacer
1849930b72 feat(qb): add support for ignoring category check via kwargs 2024-11-21 12:35:15 +08:00
jxxghp
4f1d3a7572 fix #3180 2024-11-21 12:13:44 +08:00
jxxghp
824c3ac5d6 fix #3176 2024-11-21 10:25:46 +08:00
jxxghp
1cec6ed6d1 v2.0.8
- 修复云盘扫码问题
2024-11-20 20:43:44 +08:00
jxxghp
fff75c7fe2 fix 115 2024-11-20 20:40:32 +08:00
jxxghp
81fecf1e07 fix alipan 2024-11-20 20:39:48 +08:00
jxxghp
ad8f687f8e fix alipan 2024-11-20 20:36:50 +08:00
jxxghp
a3172d7503 fix 扫码逻辑与底层模块解耦 2024-11-20 20:17:18 +08:00
jxxghp
8d5e0b26d5 fix:115支持Cookie 2024-11-20 13:14:37 +08:00
jxxghp
b1b980f550 Merge pull request #3171 from Sowevo/v2 2024-11-20 07:07:08 +08:00
Sowevo
8196589cff Merge branch 'jxxghp:v2' into v2 2024-11-19 22:43:31 +08:00
sowevo
cb9f41cb65 plex的item_id统一使用全路径
获取图片时兼容外网地址为Plex的官方转发地址https://app.plex.tv的情况
2024-11-19 22:41:55 +08:00
28 changed files with 506 additions and 311 deletions

View File

@@ -9,7 +9,9 @@ from app.core.context import MediaInfo, Context, TorrentInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user
from app.schemas.types import SystemConfigKey
router = APIRouter()
@@ -111,6 +113,17 @@ def stop(hashString: str,
return schemas.Response(success=True if ret else False)
@router.get("/clients", summary="查询可用下载器", response_model=List[dict])
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询可用下载器
"""
downloaders: List[dict] = SystemConfigOper().get(SystemConfigKey.Downloaders)
if downloaders:
return [{"name": d.get("name"), "type": d.get("type")} for d in downloaders if d.get("enabled")]
return []
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
def delete(hashString: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:

View File

@@ -12,8 +12,10 @@ from app.core.security import verify_token
from app.db import get_db
from app.db.mediaserver_oper import MediaServerOper
from app.db.models import MediaServerItem
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.mediaserver import MediaServerHelper
from app.schemas import MediaType, NotExistMediaInfo
from app.schemas.types import SystemConfigKey
router = APIRouter()
@@ -143,3 +145,14 @@ def library(server: str, hidden: bool = False,
获取媒体服务器媒体库列表
"""
return MediaServerChain().librarys(server=server, username=userinfo.username, hidden=hidden) or []
@router.get("/clients", summary="查询可用媒体服务器", response_model=List[dict])
def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询可用媒体服务器
"""
mediaservers: List[dict] = SystemConfigOper().get(SystemConfigKey.MediaServers)
if mediaservers:
return [{"name": d.get("name"), "type": d.get("type")} for d in mediaservers if d.get("enabled")]
return []

View File

@@ -8,6 +8,7 @@ from app import schemas
from app.chain.site import SiteChain
from app.chain.torrents import TorrentsChain
from app.core.event import EventManager
from app.core.plugin import PluginManager
from app.core.security import verify_token
from app.db import get_db
from app.db.models import User
@@ -351,6 +352,8 @@ def auth_site(
return schemas.Response(success=False, message="请输入认证站点和认证参数")
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.dict())
PluginManager().init_config()
Scheduler().init_plugin_jobs()
return schemas.Response(success=status, message=msg)

View File

@@ -135,9 +135,17 @@ class StorageChain(ChainBase):
if not self.delete_file(fileitem):
logger.warn(f"{fileitem.storage}{fileitem.path} 删除失败")
return False
# 处理上级目录
if mtype and mtype == MediaType.TV:
dir_item = self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path).parent.parent)
if mtype:
# 重命名格式
rename_format = settings.TV_RENAME_FORMAT \
if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
# 计算重命名中的文件夹层数
rename_format_level = len(rename_format.split("/")) - 1
if rename_format_level < 1:
return True
# 处理上级目录
dir_item = self.get_file_item(storage=fileitem.storage,
path=Path(fileitem.path).parents[rename_format_level - 1])
else:
dir_item = self.get_parent_item(fileitem)
if dir_item and len(Path(dir_item.path).parts) > 2:

View File

@@ -175,7 +175,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
# 按pubdate降序排列
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
# 取前N条
torrents = torrents[:settings.CACHE_CONF.get('refresh')]
torrents = torrents[:settings.CACHE_CONF["refresh"]]
if torrents:
# 过滤出没有处理过的种子
torrents = [torrent for torrent in torrents
@@ -215,8 +215,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
else:
torrents_cache[domain].append(context)
# 如果超过了限制条数则移除掉前面的
if len(torrents_cache[domain]) > settings.CACHE_CONF.get('torrents'):
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF.get('torrents'):]
if len(torrents_cache[domain]) > settings.CACHE_CONF["torrents"]:
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF["torrents"]:]
# 回收资源
del torrents
else:

View File

@@ -10,7 +10,7 @@ from app.chain.tmdb import TmdbChain
from app.core.config import settings, global_vars
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfoPath
from app.core.metainfo import MetaInfoPath, MetaInfo
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.models.downloadhistory import DownloadHistory
from app.db.models.transferhistory import TransferHistory
@@ -184,8 +184,6 @@ class TransferChain(ChainBase):
# 汇总季集清单
season_episodes: Dict[Tuple, List[int]] = {}
# 汇总元数据
metas: Dict[Tuple, MetaBase] = {}
# 汇总媒体信息
medias: Dict[Tuple, MediaInfo] = {}
# 汇总整理信息
@@ -393,10 +391,14 @@ class TransferChain(ChainBase):
if src_match:
# 按源目录匹配,以便找到更合适的目录配置
target_directory = self.directoryhelper.get_dir(file_mediainfo,
storage=file_item.storage, src_path=file_path)
storage=file_item.storage,
src_path=file_path,
target_storage=target_storage)
else:
# 未指定目标路径,根据媒体信息获取目标目录
target_directory = self.directoryhelper.get_dir(file_mediainfo)
target_directory = self.directoryhelper.get_dir(file_mediainfo,
storage=target_storage,
target_storage=target_storage)
# 执行整理
transferinfo: TransferInfo = self.transfer(fileitem=file_item,
@@ -443,7 +445,6 @@ class TransferChain(ChainBase):
mkey = (file_mediainfo.tmdb_id, file_meta.begin_season)
if mkey not in medias:
# 新增信息
metas[mkey] = file_meta
medias[mkey] = file_mediainfo
season_episodes[mkey] = file_meta.episode_list
transfers[mkey] = transferinfo
@@ -467,6 +468,14 @@ class TransferChain(ChainBase):
transferinfo=transferinfo
)
# 整理完成事件
self.eventmanager.send_event(EventType.TransferComplete, {
'meta': file_meta,
'mediainfo': file_mediainfo,
'transferinfo': transferinfo,
'download_hash': download_hash,
})
# 更新进度
processed_num += 1
self.progress.update(value=processed_num / total_num * 100,
@@ -479,8 +488,9 @@ class TransferChain(ChainBase):
# 执行后续处理
for mkey, media in medias.items():
transfer_meta = metas[mkey]
transfer_info = transfers[mkey]
transfer_meta = MetaInfo(transfer_info.target_diritem.name)
transfer_meta.begin_season = mkey[1]
# 发送通知
if transfer_info.need_notify:
se_str = None
@@ -497,13 +507,6 @@ class TransferChain(ChainBase):
'mediainfo': media,
'fileitem': transfer_info.target_diritem
})
# 整理完成事件
self.eventmanager.send_event(EventType.TransferComplete, {
'meta': transfer_meta,
'mediainfo': media,
'transferinfo': transfer_info,
'download_hash': download_hash,
})
# 移动模式处理
if all_success and current_transfer_type in ["move"]:

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Type
from dotenv import set_key
from pydantic import BaseModel, BaseSettings, validator
from pydantic import BaseModel, BaseSettings, validator, Field
from app.log import logger
from app.utils.system import SystemUtils
@@ -36,7 +36,7 @@ class ConfigModel(BaseModel):
# RESOURCE密钥
RESOURCE_SECRET_KEY: str = secrets.token_urlsafe(32)
# 允许的域名
ALLOWED_HOSTS: list = ["*"]
ALLOWED_HOSTS: list = Field(default_factory=lambda: ["*"])
# TOKEN过期时间
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
# RESOURCE_TOKEN过期时间
@@ -114,29 +114,39 @@ class ConfigModel(BaseModel):
# 是否启用DOH解析域名
DOH_ENABLE: bool = True
# 使用 DOH 解析的域名列表
DOH_DOMAINS: str = "api.themoviedb.org,api.tmdb.org,webservice.fanart.tv,api.github.com,github.com,raw.githubusercontent.com,api.telegram.org"
DOH_DOMAINS: str = ("api.themoviedb.org,"
"api.tmdb.org,"
"webservice.fanart.tv,"
"api.github.com,"
"github.com,"
"raw.githubusercontent.com,"
"api.telegram.org")
# DOH 解析服务器列表
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
# 支持的后缀格式
RMT_MEDIAEXT: list = ['.mp4', '.mkv', '.ts', '.iso',
'.rmvb', '.avi', '.mov', '.mpeg',
'.mpg', '.wmv', '.3gp', '.asf',
'.m4v', '.flv', '.m2ts', '.strm',
'.tp', '.f4v']
RMT_MEDIAEXT: list = Field(
default_factory=lambda: ['.mp4', '.mkv', '.ts', '.iso',
'.rmvb', '.avi', '.mov', '.mpeg',
'.mpg', '.wmv', '.3gp', '.asf',
'.m4v', '.flv', '.m2ts', '.strm',
'.tp', '.f4v']
)
# 支持的字幕文件后缀格式
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
RMT_SUBEXT: list = Field(default_factory=lambda: ['.srt', '.ass', '.ssa', '.sup'])
# 支持的音轨文件后缀格式
RMT_AUDIO_TRACK_EXT: list = ['.mka']
RMT_AUDIO_TRACK_EXT: list = Field(default_factory=lambda: ['.mka'])
# 音轨文件后缀格式
RMT_AUDIOEXT: list = ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf',
'.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3',
'.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg',
'.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak',
'.tta', '.vqf', '.wav', '.wma',
'.aifc', '.aiff', '.alac', '.adif', '.adts',
'.flac', '.midi', '.opus', '.sfalc']
RMT_AUDIOEXT: list = Field(
default_factory=lambda: ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf',
'.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3',
'.mid', '.mod', '.mka', '.mpc', '.nsf', '.ogg',
'.pcm', '.rmi', '.s3m', '.snd', '.spx', '.tak',
'.tta', '.vqf', '.wav', '.wma',
'.aifc', '.aiff', '.alac', '.adif', '.adts',
'.flac', '.midi', '.opus', '.sfalc']
)
# 下载器临时文件后缀
DOWNLOAD_TMPEXT: list = ['.!qb', '.part']
DOWNLOAD_TMPEXT: list = Field(default_factory=lambda: ['.!qb', '.part'])
# 媒体服务器同步间隔(小时)
MEDIASERVER_SYNC_INTERVAL: int = 6
# 订阅模式
@@ -189,7 +199,10 @@ class ConfigModel(BaseModel):
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
MP_SERVER_HOST: str = "https://movie-pilot.org"
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins,https://github.com/thsrite/MoviePilot-Plugins,https://github.com/honue/MoviePilot-Plugins,https://github.com/InfinityPacer/MoviePilot-Plugins"
PLUGIN_MARKET: str = ("https://github.com/jxxghp/MoviePilot-Plugins,"
"https://github.com/thsrite/MoviePilot-Plugins,"
"https://github.com/honue/MoviePilot-Plugins,"
"https://github.com/InfinityPacer/MoviePilot-Plugins")
# 插件安装数据共享
PLUGIN_STATISTIC_SHARE: bool = True
# 是否开启插件热加载
@@ -207,10 +220,18 @@ class ConfigModel(BaseModel):
# 全局图片缓存,将媒体图片缓存到本地
GLOBAL_IMAGE_CACHE: bool = False
# 允许的图片缓存域名
SECURITY_IMAGE_DOMAINS: List[str] = ["image.tmdb.org", "static-mdb.v.geilijiasu.com", "doubanio.com", "lain.bgm.tv",
"raw.githubusercontent.com", "github.com"]
SECURITY_IMAGE_DOMAINS: List[str] = Field(
default_factory=lambda: ["image.tmdb.org",
"static-mdb.v.geilijiasu.com",
"doubanio.com",
"lain.bgm.tv",
"raw.githubusercontent.com",
"github.com"]
)
# 允许的图片文件后缀格式
SECURITY_IMAGE_SUFFIXES: List[str] = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]
SECURITY_IMAGE_SUFFIXES: List[str] = Field(
default_factory=lambda: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"]
)
class Settings(BaseSettings, ConfigModel):
@@ -345,10 +366,9 @@ class Settings(BaseSettings, ConfigModel):
logger.warning(message)
if field.name in os.environ:
if is_converted:
message = f"配置项 '{field.name}' 已在环境变量中设置,请手动更新以保持一致性"
logger.warning(message)
return False, message
message = f"配置项 '{field.name}' 已在环境变量中设置,请手动更新以保持一致性"
logger.warning(message)
return False, message
else:
set_key(SystemUtils.get_env_path(), field.name, str(converted_value) if converted_value is not None else "")
if is_converted:
@@ -372,7 +392,7 @@ class Settings(BaseSettings, ConfigModel):
field.default, key)
# 如果没有抛出异常,则统一使用 converted_value 进行更新
if needs_update or str(value) != str(converted_value):
success, message = self.update_env_config(field, original_value, converted_value)
success, message = self.update_env_config(field, value, converted_value)
# 仅成功更新配置时,才更新内存
if success:
setattr(self, key, converted_value)
@@ -437,22 +457,32 @@ class Settings(BaseSettings, ConfigModel):
@property
def CACHE_CONF(self):
"""
{
"torrents": "缓存种子数量",
"refresh": "订阅刷新处理数量",
"tmdb": "TMDB请求缓存数量",
"douban": "豆瓣请求缓存数量",
"fanart": "Fanart请求缓存数量",
"meta": "元数据缓存过期时间(秒)"
}
"""
if self.BIG_MEMORY_MODE:
return {
"torrents": 200,
"refresh": 100,
"tmdb": 1024,
"refresh": 50,
"torrents": 100,
"douban": 512,
"fanart": 512,
"meta": (self.META_CACHE_EXPIRE or 168) * 3600
"meta": (self.META_CACHE_EXPIRE or 24) * 3600
}
return {
"torrents": 100,
"refresh": 50,
"tmdb": 256,
"refresh": 30,
"torrents": 50,
"douban": 256,
"fanart": 128,
"meta": (self.META_CACHE_EXPIRE or 72) * 3600
"meta": (self.META_CACHE_EXPIRE or 2) * 3600
}
@property

View File

@@ -48,16 +48,18 @@ class DirectoryHelper:
"""
return [d for d in self.get_library_dirs() if d.library_storage == "local"]
def get_dir(self, media: MediaInfo, storage: str = "local",
src_path: Path = None, dest_path: Path = None, fileitem: schemas.FileItem = None
def get_dir(self, media: MediaInfo,
storage: str = "local", fileitem: schemas.FileItem = None, src_path: Path = None,
target_storage: str = "local", dest_path: Path = None
) -> Optional[schemas.TransferDirectoryConf]:
"""
根据媒体信息获取下载目录、媒体库目录配置
:param media: 媒体信息
:param storage: 存储类型
:param storage: 存储类型
:param target_storage: 目标存储类型
:param fileitem: 文件项,使用文件路径匹配
:param src_path: 源目录,有值时直接匹配
:param dest_path: 目标目录,有值时直接匹配
:param fileitem: 文件项,使用文件路径匹配
"""
# 处理类型
if not media:
@@ -70,21 +72,23 @@ class DirectoryHelper:
# 没有启用整理的目录
if not d.monitor_type:
continue
# 存储类型不匹配
# 存储类型不匹配
if storage and d.storage != storage:
continue
# 下载目录
download_path = Path(d.download_path)
# 媒体库目录
library_path = Path(d.library_path)
# 有源目录时,源目录不匹配下载目录
if src_path and not src_path.is_relative_to(download_path):
# 目标存储类型不匹配
if target_storage and d.library_storage != target_storage:
continue
# 有文件项时,源存储不匹配
if fileitem and fileitem.storage != d.storage:
continue
# 有文件项时,文件项不匹配下载目录
if fileitem and not Path(fileitem.path).is_relative_to(download_path):
if fileitem and not Path(fileitem.path).is_relative_to(d.download_path):
continue
# 有源目录时,源目录不匹配下载目录
if src_path and not src_path.is_relative_to(d.download_path):
continue
# 有目标目录时,目标目录不匹配媒体库目录
if dest_path and not dest_path.is_relative_to(library_path):
if dest_path and not dest_path.is_relative_to(d.library_path):
continue
# 目录类型为全部的,符合条件
if not d.media_type:

View File

@@ -3,11 +3,11 @@ import base64
import hashlib
import hmac
from datetime import datetime
from functools import lru_cache
from random import choice
from urllib import parse
import requests
from cachetools import TTLCache, cached
from app.core.config import settings
from app.utils.http import RequestUtils
@@ -160,12 +160,12 @@ class DoubanApi(metaclass=Singleton):
self._session = requests.Session()
@classmethod
def __sign(cls, url: str, ts: int, method='GET') -> str:
def __sign(cls, url: str, ts: str, method='GET') -> str:
"""
签名
"""
url_path = parse.urlparse(url).path
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), str(ts)])
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), ts])
return base64.b64encode(
hmac.new(
cls._api_secret_key.encode(),
@@ -174,7 +174,7 @@ class DoubanApi(metaclass=Singleton):
).digest()
).decode()
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
def __invoke(self, url: str, **kwargs) -> dict:
"""
GET请求
@@ -203,7 +203,7 @@ class DoubanApi(metaclass=Singleton):
return resp.json()
return resp.json() if resp else {}
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
def __post(self, url: str, **kwargs) -> dict:
"""
POST请求

View File

@@ -16,7 +16,7 @@ from app.schemas.types import MediaType
lock = RLock()
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
EXPIRE_TIMESTAMP = settings.CACHE_CONF.get('meta')
EXPIRE_TIMESTAMP = settings.CACHE_CONF["meta"]
class DoubanCache(metaclass=Singleton):
@@ -77,7 +77,7 @@ class DoubanCache(metaclass=Singleton):
@return: 被删除的缓存内容
"""
with lock:
return self._meta_data.pop(key, None)
return self._meta_data.pop(key, {})
def delete_by_doubanid(self, doubanid: str) -> None:
"""

View File

@@ -1,12 +1,13 @@
import re
from functools import lru_cache
from typing import Optional, Tuple, Union
from cachetools import TTLCache, cached
from app.core.context import MediaInfo, settings
from app.log import logger
from app.modules import _ModuleBase
from app.utils.http import RequestUtils
from app.schemas.types import MediaType, ModuleType
from app.utils.http import RequestUtils
class FanartModule(_ModuleBase):
@@ -404,7 +405,7 @@ class FanartModule(_ModuleBase):
return result
@classmethod
@lru_cache(maxsize=settings.CACHE_CONF.get('fanart'))
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["fanart"], ttl=settings.CACHE_CONF["meta"]))
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
if media_type == MediaType.MOVIE:
image_url = cls._movie_url % queryid

View File

@@ -1,4 +1,3 @@
import copy
import re
from pathlib import Path
from threading import Lock
@@ -131,7 +130,6 @@ class FileManagerModule(_ModuleBase):
)
return str(path)
pass
def save_config(self, storage: str, conf: Dict) -> None:
"""
@@ -143,7 +141,7 @@ class FileManagerModule(_ModuleBase):
return
storage_oper.set_config(conf)
def generate_qrcode(self, storage: str) -> Optional[Dict[str, str]]:
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
"""
生成二维码
"""
@@ -219,7 +217,8 @@ class FileManagerModule(_ModuleBase):
and f".{t.extension.lower()}" in extensions):
return True
elif t.type == "dir":
return __any_file(t)
if __any_file(t):
return True
return False
# 返回结果
@@ -463,9 +462,9 @@ class FileManagerModule(_ModuleBase):
target_file.parent.mkdir(parents=True)
# 本地到本地
if transfer_type == "copy":
state = source_oper.copy(fileitem, target_file)
state = source_oper.copy(fileitem, target_file.parent, target_file.name)
elif transfer_type == "move":
state = source_oper.move(fileitem, target_file)
state = source_oper.move(fileitem, target_file.parent, target_file.name)
elif transfer_type == "link":
state = source_oper.link(fileitem, target_file)
elif transfer_type == "softlink":
@@ -493,7 +492,7 @@ class FileManagerModule(_ModuleBase):
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return None, f"{target_file.parent} {target_storage} 目录获取失败"
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif transfer_type == "move":
# 移动
# 根据目的路径获取文件夹
@@ -508,7 +507,7 @@ class FileManagerModule(_ModuleBase):
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return None, f"{target_file.parent} {target_storage} 目录获取失败"
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif fileitem.storage != "local" and target_storage == "local":
# 网盘到本地
if target_file.exists():
@@ -532,25 +531,20 @@ class FileManagerModule(_ModuleBase):
return None, f"{fileitem.path} {fileitem.storage} 下载失败"
elif fileitem.storage == target_storage:
# 同一网盘
# 根据目的路径获取文件夹
target_diritem = target_oper.get_folder(target_file.parent)
if target_diritem:
# 重命名文件
if target_oper.rename(fileitem, target_file.name):
# 移动文件到新目录
if source_oper.move(fileitem, target_diritem):
ret_fileitem = copy.deepcopy(fileitem)
ret_fileitem.path = target_diritem.path + "/" + target_file.name
ret_fileitem.name = target_file.name
ret_fileitem.basename = target_file.stem
ret_fileitem.parent_fileid = target_diritem.fileid
return ret_fileitem, ""
else:
return None, f"{fileitem.path} {target_storage} 移动文件失败"
if transfer_type == "copy":
# 移动文件到新目录
if source_oper.move(fileitem, target_file.parent, target_file.name):
return target_oper.get_item(target_file), ""
else:
return None, f"{fileitem.path} {target_storage} 重命名文件失败"
return None, f"{target_storage}{fileitem.path} 移动文件失败"
elif transfer_type == "move":
# 移动文件到新目录
if source_oper.move(fileitem, target_file.parent, target_file.name):
return target_oper.get_item(target_file), ""
else:
return None, f"{target_storage}{fileitem.path} 移动文件失败"
else:
return None, f"{target_file.parent} {target_storage} 目录获取失败"
return None, f"不支持的整理方式:{transfer_type}"
return None, "未知错误"
@@ -815,7 +809,8 @@ class FileManagerModule(_ModuleBase):
else:
logger.info(f"正在删除已存在的文件:{target_file}")
target_file.unlink()
logger.info(f"正在整理文件:【{fileitem.storage}{fileitem.path} 到 【{target_storage}{target_file}")
logger.info(f"正在整理文件:【{fileitem.storage}{fileitem.path} 到 【{target_storage}{target_file}"
f"操作类型:{transfer_type}")
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
target_storage=target_storage,
target_file=target_file,
@@ -889,6 +884,18 @@ class FileManagerModule(_ModuleBase):
rename_format = settings.TV_RENAME_FORMAT \
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
# 计算重命名中的文件夹层数
rename_format_level = len(rename_format.split("/")) - 1
if rename_format_level < 1:
# 重命名格式不合法
logger.error(f"重命名格式不合法:{rename_format}")
return TransferInfo(success=False,
message=f"重命名格式不合法",
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=need_notify)
# 判断是否为文件夹
if fileitem.type == "dir":
# 整理整个目录,一般为蓝光原盘
@@ -969,9 +976,15 @@ class FileManagerModule(_ModuleBase):
# 目的操作对象
target_oper: StorageBase = self.__get_storage_oper(target_storage)
# 目标目录
target_diritem = target_oper.get_folder(
new_file.parent) if mediainfo.type == MediaType.MOVIE else target_oper.get_folder(
new_file.parent.parent)
target_diritem = target_oper.get_folder(new_file.parents[rename_format_level - 1])
if not target_diritem:
logger.error(f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败")
return TransferInfo(success=False,
message=f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败",
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
# 目标文件
target_item = target_oper.get_item(new_file)
if target_item:

View File

@@ -1,6 +1,6 @@
from abc import ABCMeta, abstractmethod
from pathlib import Path
from typing import Optional, List, Union, Dict
from typing import Optional, List, Union, Dict, Tuple
from app import schemas
from app.helper.storage import StorageHelper
@@ -16,7 +16,14 @@ class StorageBase(metaclass=ABCMeta):
def __init__(self):
self.storagehelper = StorageHelper()
def generate_qrcode(self, *args, **kwargs) -> Optional[Dict[str, str]]:
@abstractmethod
def init_storage(self):
"""
初始化
"""
pass
def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:
pass
def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]:
@@ -40,6 +47,7 @@ class StorageBase(metaclass=ABCMeta):
设置配置
"""
self.storagehelper.set_storage(self.schema.value, conf)
self.init_storage()
def support_transtype(self) -> dict:
"""
@@ -114,7 +122,6 @@ class StorageBase(metaclass=ABCMeta):
下载文件,保存到本地,返回本地临时文件地址
:param fileitem: 文件项
:param path: 文件保存路径
"""
pass
@@ -136,16 +143,22 @@ class StorageBase(metaclass=ABCMeta):
pass
@abstractmethod
def copy(self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]) -> bool:
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
复制文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
pass
@abstractmethod
def move(self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]) -> bool:
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
移动文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
pass

View File

@@ -16,10 +16,11 @@ from app.schemas.types import StorageSchema
from app.utils.http import RequestUtils
from aligo import Aligo, BaseFile
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class AliPan(StorageBase):
class AliPan(StorageBase, metaclass=Singleton):
"""
阿里云相关操作
"""
@@ -54,17 +55,27 @@ class AliPan(StorageBase):
except FileNotFoundError:
logger.debug('未发现 aria2c')
self._has_aria2c = False
self.init_storage()
self.__init_aligo()
def __init_aligo(self):
def init_storage(self):
"""
初始化 aligo
"""
def show_qrcode(qr_link: str):
"""
显示二维码
"""
logger.info(f"请用阿里云盘 App 扫码登录:{qr_link}")
refresh_token = self.__auth_params.get("refreshToken")
if refresh_token:
self.aligo = Aligo(refresh_token=refresh_token, use_aria2=self._has_aria2c,
name="MoviePilot V2", level=logging.ERROR)
try:
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c,
name="MoviePilot V2", level=logging.ERROR, re_login=False)
except Exception as err:
logger.error(f"初始化阿里云盘失败:{str(err)}")
self.__clear_params()
@property
def __auth_params(self):
@@ -160,7 +171,7 @@ class AliPan(StorageBase):
})
self.__update_params(data)
self.__update_drives()
self.__init_aligo()
self.init_storage()
except Exception as e:
return {}, f"bizExt 解码失败:{str(e)}"
return data, ""
@@ -180,12 +191,16 @@ class AliPan(StorageBase):
"""
获取用户信息drive_id等
"""
if not self.aligo:
return {}
return self.aligo.get_user()
def __update_drives(self):
"""
更新用户存储根目录
"""
if not self.aligo:
return
drivers = self.aligo.list_my_drives()
for driver in drivers:
if driver.category == "resource":
@@ -356,6 +371,9 @@ class AliPan(StorageBase):
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: str = None) -> Optional[schemas.FileItem]:
"""
上传文件,并标记完成
:param fileitem: 上传目录项
:param path: 目标目录
:param new_name: 新文件名
"""
if not self.aligo:
return None
@@ -369,19 +387,41 @@ class AliPan(StorageBase):
return self.__get_fileitem(item)
return None
def move(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool:
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
移动文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
if not self.aligo:
return False
target = self.get_folder(path)
if not target:
return False
if self.aligo.move_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id,
to_parent_file_id=target.fileid, to_drive_id=target.drive_id):
to_parent_file_id=target.fileid, to_drive_id=target.drive_id,
new_name=new_name):
return True
return False
def copy(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool:
pass
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
复制文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
if not self.aligo:
return False
target = self.get_folder(path)
if not target:
return False
if self.aligo.copy_file(file_id=fileitem.fileid, drive_id=fileitem.drive_id,
to_parent_file_id=target.fileid, to_drive_id=target.drive_id,
new_name=new_name):
return True
return False
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
"""

View File

@@ -2,10 +2,10 @@ import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple, List, Dict, Union
from typing import Optional, List, Dict
from requests import Response
from cachetools import cached, TTLCache
from requests import Response
from app import schemas
from app.core.config import settings
@@ -13,10 +13,11 @@ from app.log import logger
from app.modules.filemanager.storages import StorageBase
from app.schemas.types import StorageSchema
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.url import UrlUtils
class Alist(StorageBase):
class Alist(StorageBase, metaclass=Singleton):
"""
Alist相关操作
api文档https://alist.nn.ci/zh/guide/api
@@ -34,6 +35,12 @@ class Alist(StorageBase):
def __init__(self):
super().__init__()
def init_storage(self):
"""
初始化
"""
pass
@property
def __get_base_url(self) -> str:
"""
@@ -342,7 +349,7 @@ class Alist(StorageBase):
result = resp.json()
if result["code"] != 200:
logging.warning(f'获取文件 {path} 失败,错误信息:{result["message"]}')
logging.debug(f'获取文件 {path} 失败,错误信息:{result["message"]}')
return
return schemas.FileItem(
@@ -570,51 +577,21 @@ class Alist(StorageBase):
"""
return self.get_item(Path(fileitem.path))
@staticmethod
def __get_copy_and_move_data(
fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]
) -> Tuple[str, str, List[str], bool]:
"""
获取复制或移动文件需要的数据
:param fileitem: 文件项
:param target: 目标文件项或目标路径
:return: 源目录,目标目录,文件名列表,是否有效
"""
name = Path(target).name
if fileitem.name != name:
return "", "", [], False
src_dir = Path(fileitem.path).parent.as_posix()
if isinstance(target, schemas.FileItem):
traget_dir = Path(target.path).parent.as_posix()
else:
traget_dir = target.parent.as_posix()
return src_dir, traget_dir, [name], True
def copy(
self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]
) -> bool:
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
复制文件
源文件名和目标文件名必须相同
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
src_dir, dst_dir, names, is_valid = self.__get_copy_and_move_data(
fileitem, target
)
if not is_valid:
return False
resp: Response = RequestUtils(
headers=self.__get_header_with_token()
).post_res(
self.__get_api_url("/api/fs/copy"),
json={
"src_dir": src_dir,
"dst_dir": dst_dir,
"names": names,
"src_dir": Path(fileitem.path).parent.as_posix(),
"dst_dir": path.as_posix(),
"names": [fileitem.name],
},
)
"""
@@ -649,28 +626,31 @@ class Alist(StorageBase):
f'复制文件 {fileitem.path} 失败,错误信息:{result["message"]}'
)
return False
# 重命名
if fileitem.name != new_name:
self.rename(
self.get_item(path / fileitem.name), new_name
)
return True
def move(
self, fileitem: schemas.FileItem, target: Union[schemas.FileItem, Path]
) -> bool:
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
移动文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
src_dir, dst_dir, names, is_valid = self.__get_copy_and_move_data(
fileitem, target
)
if not is_valid:
return False
# 先重命名
if fileitem.name != new_name:
self.rename(fileitem, new_name)
resp: Response = RequestUtils(
headers=self.__get_header_with_token()
).post_res(
self.__get_api_url("/api/fs/move"),
json={
"src_dir": src_dir,
"dst_dir": dst_dir,
"names": names,
"src_dir": Path(fileitem.path).parent.as_posix(),
"dst_dir": path.as_posix(),
"names": [new_name],
},
)
"""
@@ -751,15 +731,7 @@ class Alist(StorageBase):
@staticmethod
def __parse_timestamp(time_str: str) -> float:
# try:
# # 尝试解析带微秒的时间格式
# dt = datetime.strptime(time_str[:26], '%Y-%m-%dT%H:%M:%S.%f')
# except ValueError:
# # 如果失败,尝试解析不带微秒的时间格式
# dt = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ')
# 直接使用 ISO 8601 格式解析时间
dt = datetime.fromisoformat(time_str)
# 返回时间戳
return dt.timestamp()
"""
直接使用 ISO 8601 格式解析时间
"""
return datetime.fromisoformat(time_str).timestamp()

View File

@@ -25,13 +25,19 @@ class LocalStorage(StorageBase):
"softlink": "软链接"
}
def init_storage(self):
"""
初始化
"""
pass
def check(self) -> bool:
"""
检查存储是否可用
"""
return True
def __get_fileitem(self, path: Path):
def __get_fileitem(self, path: Path) -> schemas.FileItem:
"""
获取文件项
"""
@@ -46,7 +52,7 @@ class LocalStorage(StorageBase):
modify_time=path.stat().st_mtime,
)
def __get_diritem(self, path: Path):
def __get_diritem(self, path: Path) -> schemas.FileItem:
"""
获取目录项
"""
@@ -195,17 +201,6 @@ class LocalStorage(StorageBase):
return None
return self.get_item(target_path)
def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
"""
复制文件
"""
file_path = Path(fileitem.path)
code, message = SystemUtils.copy(file_path, target_file)
if code != 0:
logger.error(f"复制文件失败:{message}")
return False
return True
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
"""
硬链接文件
@@ -228,12 +223,29 @@ class LocalStorage(StorageBase):
return False
return True
def move(self, fileitem: schemas.FileItem, target: Path) -> bool:
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
移动文件
复制文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
file_path = Path(fileitem.path)
code, message = SystemUtils.move(file_path, target)
code, message = SystemUtils.copy(file_path, path / new_name)
if code != 0:
logger.error(f"复制文件失败:{message}")
return False
return True
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
移动文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
file_path = Path(fileitem.path)
code, message = SystemUtils.move(file_path, path / new_name)
if code != 0:
logger.error(f"移动文件失败:{message}")
return False

View File

@@ -27,6 +27,12 @@ class Rclone(StorageBase):
"copy": "复制"
}
def init_storage(self):
"""
初始化
"""
pass
def set_config(self, conf: dict):
"""
设置配置
@@ -300,16 +306,19 @@ class Rclone(StorageBase):
logger.error(f"rclone获取文件详情失败{err}")
return None
def move(self, fileitem: schemas.FileItem, target: Path) -> bool:
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
移动文件target_file格式rclone:path
移动文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
try:
retcode = subprocess.run(
[
'rclone', 'moveto',
f'MP:{fileitem.path}',
f'MP:{target}'
f'MP:{path / new_name}'
],
startupinfo=self.__get_hidden_shell()
).returncode
@@ -319,8 +328,27 @@ class Rclone(StorageBase):
logger.error(f"rclone移动文件失败{err}")
return False
def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
pass
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
复制文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
try:
retcode = subprocess.run(
[
'rclone', 'copyto',
f'MP:{fileitem.path}',
f'MP:{path / new_name}'
],
startupinfo=self.__get_hidden_shell()
).returncode
if retcode == 0:
return True
except Exception as err:
logger.error(f"rclone复制文件失败{err}")
return False
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
pass

View File

@@ -1,13 +1,14 @@
from pathlib import Path
from typing import Optional, Tuple, List
from p115 import P115Client, P115FileSystem, P115Path
from p115 import P115Client, P115Path
from app import schemas
from app.core.config import settings
from app.log import logger
from app.modules.filemanager.storages import StorageBase
from app.schemas.types import StorageSchema
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
@@ -25,24 +26,32 @@ class U115Pan(StorageBase, metaclass=Singleton):
"copy": "复制"
}
# 115二维码登录地址
qrcode_url = "https://qrcodeapi.115.com/api/1.0/web/1.0/token/"
# 115登录状态检查
login_check_url = "https://qrcodeapi.115.com/get/status/"
# 115登录完成 alipaymini
login_done_api = f"https://passportapi.115.com/app/1.0/alipaymini/1.0/login/qrcode/"
client: P115Client = None
fs: P115FileSystem = None
session_info: dict = None
def __init_cloud(self, force: bool = False) -> bool:
def __init__(self):
super().__init__()
self.init_storage()
def init_storage(self):
"""
初始化Cloud
"""
if not self.__credential:
return
try:
if not self.client or not self.client.cookies or force:
self.client = P115Client(self.__credential,
check_for_relogin=True, app="alipaymini", console_qrcode=False)
self.fs = P115FileSystem(self.client)
self.client = P115Client(self.__credential, app="alipaymini",
check_for_relogin=False, console_qrcode=False)
except Exception as err:
logger.error(f"115连接失败请重新扫码登录:{str(err)}")
logger.error(f"115连接失败请重新登录{str(err)}")
self.__clear_credential()
return False
return True
@property
def __credential(self) -> Optional[str]:
@@ -54,8 +63,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
return None
if not conf.config:
return None
# 将dict转换为cookie字符串格式
return "; ".join([f"{k}={v}" for k, v in conf.config.items()])
return conf.config.get("cookie")
def __save_credential(self, credential: dict):
"""
@@ -73,21 +81,19 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
生成二维码
"""
if not self.__init_cloud():
return None
try:
resp = self.client.login_qrcode_token()
self.session_info = resp["data"]
res = RequestUtils(timeout=10).get_res(self.qrcode_url)
if res:
self.session_info = res.json().get("data")
qrcode_content = self.session_info.pop("qrcode")
if not qrcode_content:
logger.warn("115生成二维码失败未获取到二维码数据")
return None
return {}, ""
return {
"codeContent": qrcode_content
}, ""
except Exception as e:
logger.warn(f"115生成二维码失败{str(e)}")
return {}, f"115生成二维码失败{str(e)}"
elif res is not None:
return {}, f"115生成二维码失败{res.status_code} - {res.reason}"
return {}, f"115生成二维码失败无法连接!"
def check_login(self) -> Optional[Tuple[dict, str]]:
"""
@@ -96,8 +102,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
if not self.session_info:
return {}, "请先生成二维码!"
try:
resp = self.client.login_qrcode_scan_status(self.session_info)
match resp["data"].get("status"):
resp = RequestUtils(timeout=10).get_res(self.login_check_url, params=self.session_info)
if not resp:
return {}, "115登录确认失败无法连接"
result = resp.json()
match result["data"].get("status"):
case 0:
result = {
"status": 0,
@@ -110,12 +119,18 @@ class U115Pan(StorageBase, metaclass=Singleton):
}
case 2:
# 确认完成,保存认证信息
resp = self.client.login_qrcode_scan_result(uid=self.session_info.get("uid"),
app="alipaymini")
resp = RequestUtils(timeout=10).post_res(self.login_done_api,
data={"account": self.session_info.get("uid")})
if not resp:
return {}, "115登录确认失败无法连接"
if resp:
# 保存认证信息
self.__save_credential(resp["data"]["cookie"])
self.__init_cloud(force=True)
result = resp.json()
cookie_dict = result["data"]["cookie"]
cookie_str = "; ".join([f"{k}={v}" for k, v in cookie_dict.items()])
cookie_dict.update({"cookie": cookie_str})
self.__save_credential(cookie_dict)
self.init_storage()
result = {
"status": 2,
"tip": "登录成功!"
@@ -143,10 +158,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
获取存储空间
"""
if not self.__init_cloud():
if not self.client:
return None
try:
usage = self.fs.space_summury()
usage = self.client.fs.space_summury()
if usage:
return usage['rt_space_info']['all_total']['size'], usage['rt_space_info']['all_remain']['size']
except Exception as e:
@@ -163,12 +178,12 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
浏览文件
"""
if not self.__init_cloud():
if not self.client:
return []
try:
if fileitem.type == "file":
return [fileitem]
items: List[P115Path] = self.fs.list(fileitem.path)
items: List[P115Path] = self.client.fs.list(fileitem.path)
return [schemas.FileItem(
storage=self.schema.value,
type="dir" if item.is_dir() else "file",
@@ -187,10 +202,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
创建目录
"""
if not self.__init_cloud():
if not self.client:
return None
try:
result = self.fs.makedirs(Path(fileitem.path) / name, exist_ok=True)
result = self.client.fs.makedirs(Path(fileitem.path) / name, exist_ok=True)
if result:
return schemas.FileItem(
storage=self.schema.value,
@@ -208,10 +223,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
根据文件路程获取目录,不存在则创建
"""
if not self.__init_cloud():
if not self.client:
return None
try:
result = self.fs.makedirs(path, exist_ok=True)
result = self.client.fs.makedirs(path, exist_ok=True)
if result:
return schemas.FileItem(
storage=self.schema.value,
@@ -229,11 +244,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
获取文件或目录不存在返回None
"""
if not self.__init_cloud():
if not self.client:
return None
try:
try:
item = self.fs.attr(path)
item = self.client.fs.attr(path)
except FileNotFoundError:
return None
if item:
@@ -255,11 +270,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
获取文件详情
"""
if not self.__init_cloud():
if not self.client:
return None
try:
try:
item = self.fs.attr(fileitem.path)
item = self.client.fs.attr(fileitem.path)
except FileNotFoundError:
return None
if item:
@@ -281,10 +296,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
删除文件
"""
if not self.__init_cloud():
if not self.client:
return False
try:
self.fs.remove(fileitem.path)
self.client.fs.remove(fileitem.path)
return True
except Exception as e:
logger.error(f"115删除文件失败{str(e)}")
@@ -294,10 +309,10 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
重命名文件
"""
if not self.__init_cloud():
if not self.client:
return False
try:
self.fs.rename(fileitem.path, Path(fileitem.path).with_name(name))
self.client.fs.rename(fileitem.path, Path(fileitem.path).with_name(name))
return True
except Exception as e:
logger.error(f"115重命名文件失败{str(e)}")
@@ -307,11 +322,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
获取下载链接
"""
if not self.__init_cloud():
if not self.client:
return None
local_file = (path or settings.TEMP_PATH) / fileitem.name
try:
task = self.fs.download(fileitem.path, file=local_file)
task = self.client.fs.download(fileitem.path, file=local_file)
if task:
return local_file
except Exception as e:
@@ -322,12 +337,12 @@ class U115Pan(StorageBase, metaclass=Singleton):
"""
上传文件
"""
if not self.__init_cloud():
if not self.client:
return None
try:
new_path = Path(fileitem.path) / (new_name or path.name)
with open(path, "rb") as f:
result = self.fs.upload(f, new_path)
result = self.client.fs.upload(f, new_path)
if result:
return schemas.FileItem(
storage=self.schema.value,
@@ -343,32 +358,38 @@ class U115Pan(StorageBase, metaclass=Singleton):
logger.error(f"115上传文件失败{str(e)}")
return None
def move(self, fileitem: schemas.FileItem, target: schemas.FileItem) -> bool:
"""
移动文件
"""
if not self.__init_cloud():
return False
try:
self.fs.move(fileitem.path, target.path)
return True
except Exception as e:
logger.error(f"115移动文件失败{str(e)}")
return False
def copy(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
复制文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
if not self.__init_cloud():
if not self.client:
return False
try:
self.fs.copy(fileitem.path, target_file)
self.client.fs.copy(fileitem.path, path / new_name)
return True
except Exception as e:
logger.error(f"115复制文件失败{str(e)}")
return False
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
"""
移动文件
:param fileitem: 文件项
:param path: 目标目录
:param new_name: 新文件名
"""
if not self.client:
return False
try:
self.client.fs.move(fileitem.path, path / new_name)
return True
except Exception as e:
logger.error(f"115移动文件失败{str(e)}")
return False
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
pass

View File

@@ -294,7 +294,7 @@ class Plex:
return videos.key, season_episodes
def get_remote_image_by_id(self,
item_id: str,
item_id: str,
image_type: str,
depth: int = 0,
plex_url: bool = True) -> Optional[str]:
@@ -310,12 +310,16 @@ class Plex:
return None
try:
image_url = None
ekey = f"/library/metadata/{item_id}"
ekey = item_id
item = self._plex.fetchItem(ekey=ekey)
if not item:
return None
# 如果配置了外网播放地址以及Token则默认从Plex媒体服务器获取图片否则返回有外网地址的图片资源
if self._playhost and self._token and plex_url:
# Plex外网播放地址这个框里目前可以填两种地址
# 1. Plex的官方转发地址https://app.plex.tv, 2. 自己处理的端口转发地址
# 如果使用的是1的官方转发地址,那么就不能走这个逻辑,因为官方转发地址无法获取到图片
if (self._playhost and "app.plex.tv" not in self._playhost
and self._token and plex_url):
query = {"X-Plex-Token": self._token}
if image_type == "Poster":
if item.thumb:
@@ -346,8 +350,8 @@ class Plex:
image_url = image.key
break
# 如果最后还是找不到,则递归父级进行查找
if not image_url and hasattr(item, "parentRatingKey"):
return self.get_remote_image_by_id(item_id=item.parentRatingKey,
if not image_url and hasattr(item, "parentKey"):
return self.get_remote_image_by_id(item_id=item.parentKey,
image_type=image_type,
depth=depth + 1)
return image_url
@@ -665,7 +669,7 @@ class Plex:
"S" + str(message.get('Metadata', {}).get('parentIndex')),
"E" + str(message.get('Metadata', {}).get('index')),
message.get('Metadata', {}).get('title'))
eventItem.item_id = message.get('Metadata', {}).get('ratingKey')
eventItem.item_id = message.get('Metadata', {}).get('key')
eventItem.season_id = message.get('Metadata', {}).get('parentIndex')
eventItem.episode_id = message.get('Metadata', {}).get('index')
@@ -680,7 +684,7 @@ class Plex:
eventItem.item_name = "%s %s" % (
message.get('Metadata', {}).get('title'),
"(" + str(message.get('Metadata', {}).get('year')) + ")")
eventItem.item_id = message.get('Metadata', {}).get('ratingKey')
eventItem.item_id = message.get('Metadata', {}).get('key')
if len(message.get('Metadata', {}).get('summary')) > 100:
eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + "..."
else:

View File

@@ -124,7 +124,8 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
is_paused=is_paused,
tag=tags,
cookie=cookie,
category=category
category=category,
ignore_category_check=False
)
if not state:
# 读取种子的名称

View File

@@ -251,6 +251,7 @@ class Qbittorrent:
:param category: 种子分类
:param download_dir: 下载路径
:param cookie: 站点Cookie用于辅助下载种子
:param kwargs: 可选参数,如 ignore_category_check 以及 QB相关参数
:return: bool
"""
if not self.qbc or not content:
@@ -276,13 +277,16 @@ class Qbittorrent:
else:
tags = None
# 分类自动管理
if category and self._category:
is_auto = True
# 如果忽略分类检查,则直接使用传入的分类值,否则,仅在分类存在且启用了自动管理时才传递参数
ignore_category_check = kwargs.pop("ignore_category_check", True)
if ignore_category_check:
is_auto = self._category
else:
is_auto = False
category = None
if category and self._category:
is_auto = True
else:
is_auto = False
category = None
try:
# 添加下载
qbc_ret = self.qbc.torrents_add(urls=urls,

View File

@@ -15,7 +15,7 @@ from app.schemas.types import MediaType
lock = RLock()
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
EXPIRE_TIMESTAMP = settings.CACHE_CONF.get('meta')
EXPIRE_TIMESTAMP = settings.CACHE_CONF["meta"]
class TmdbCache(metaclass=Singleton):
@@ -75,7 +75,7 @@ class TmdbCache(metaclass=Singleton):
@return: 被删除的缓存内容
"""
with lock:
return self._meta_data.pop(key, None)
return self._meta_data.pop(key, {})
def delete_by_tmdbid(self, tmdbid: int) -> None:
"""
@@ -138,14 +138,14 @@ class TmdbCache(metaclass=Singleton):
if cache_year:
cache_year = cache_year[:4]
self._meta_data[self.__get_key(meta)] = {
"id": info.get("id"),
"type": info.get("media_type"),
"year": cache_year,
"title": cache_title,
"poster_path": info.get("poster_path"),
"backdrop_path": info.get("backdrop_path"),
CACHE_EXPIRE_TIMESTAMP_STR: int(time.time()) + EXPIRE_TIMESTAMP
}
"id": info.get("id"),
"type": info.get("media_type"),
"year": cache_year,
"title": cache_title,
"poster_path": info.get("poster_path"),
"backdrop_path": info.get("backdrop_path"),
CACHE_EXPIRE_TIMESTAMP_STR: int(time.time()) + EXPIRE_TIMESTAMP
}
elif info is not None:
# None时不缓存此时代表网络错误允许重复请求
self._meta_data[self.__get_key(meta)] = {'id': 0}
@@ -164,7 +164,7 @@ class TmdbCache(metaclass=Singleton):
return
with open(self._meta_path, 'wb') as f:
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # type: ignore
def _random_sample(self, new_meta_data: dict) -> bool:
"""

View File

@@ -1,9 +1,9 @@
import traceback
from functools import lru_cache
from typing import Optional, List
from urllib.parse import quote
import zhconv
from cachetools import TTLCache, cached
from lxml import etree
from app.core.config import settings
@@ -27,8 +27,6 @@ class TmdbApi:
self.tmdb.domain = settings.TMDB_API_DOMAIN
# 开启缓存
self.tmdb.cache = True
# 缓存大小
self.tmdb.REQUEST_CACHE_MAXSIZE = settings.CACHE_CONF.get('tmdb')
# APIKEY
self.tmdb.api_key = settings.TMDB_API_KEY
# 语种
@@ -466,7 +464,7 @@ class TmdbApi:
return ret_info
@lru_cache(maxsize=settings.CACHE_CONF.get('tmdb'))
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:
"""
搜索TMDB网站直接抓取结果结果只有一条时才返回

View File

@@ -4,11 +4,12 @@ import logging
import os
import time
from datetime import datetime
from functools import lru_cache
import requests
import requests.exceptions
from cachetools import TTLCache, cached
from app.core.config import settings
from app.utils.http import RequestUtils
from .exceptions import TMDbException
@@ -24,7 +25,6 @@ class TMDb(object):
TMDB_CACHE_ENABLED = "TMDB_CACHE_ENABLED"
TMDB_PROXIES = "TMDB_PROXIES"
TMDB_DOMAIN = "TMDB_DOMAIN"
REQUEST_CACHE_MAXSIZE = None
_req = None
_session = None
@@ -137,7 +137,7 @@ class TMDb(object):
def cache(self, cache):
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
@lru_cache(maxsize=REQUEST_CACHE_MAXSIZE)
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
def cached_request(self, method, url, data, json,
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""

View File

@@ -280,9 +280,9 @@ class Monitor(metaclass=Singleton):
"""
获取BDMV目录的上级目录
"""
for parent in _path.parents:
if parent.name == "BDMV":
return parent.parent
for p in _path.parents:
if p.name == "BDMV":
return p.parent
return None
# 全程加锁

View File

@@ -174,9 +174,6 @@ class Scheduler(metaclass=Singleton):
# 停止定时服务
self.stop()
# 用户认证立即执行一次
user_auth()
# 调试模式不启动定时服务
if settings.DEV:
return
@@ -329,7 +326,7 @@ class Scheduler(metaclass=Singleton):
"interval",
id="clear_cache",
name="缓存清理",
hours=settings.CACHE_CONF.get("meta") / 3600,
hours=settings.CACHE_CONF["meta"] / 3600,
kwargs={
'job_id': 'clear_cache'
}

View File

@@ -23,7 +23,9 @@ from app.helper.message import MessageHelper
from app.scheduler import Scheduler
from app.monitor import Monitor
from app.schemas import Notification, NotificationType
from app.schemas.types import SystemConfigKey
from app.db import close_database
from app.db.systemconfig_oper import SystemConfigOper
from app.chain.command import CommandChain
@@ -72,6 +74,19 @@ def clear_temp():
SystemUtils.clear(settings.CACHE_PATH / "images", days=7)
def user_auth():
"""
用户认证检查
"""
if SitesHelper().auth_level >= 2:
return
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
if auth_conf:
SitesHelper().check_user(**auth_conf)
else:
SitesHelper().check_user()
def check_auth():
"""
检查认证状态
@@ -128,6 +143,8 @@ def start_modules(_: FastAPI):
SitesHelper()
# 资源包检测
ResourceHelper()
# 用户认证
user_auth()
# 加载模块
ModuleManager()
# 启动事件消费

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.0.7'
FRONTEND_VERSION = 'v2.0.7'
APP_VERSION = 'v2.0.9'
FRONTEND_VERSION = 'v2.0.9'