Compare commits

..

5 Commits

8 changed files with 184 additions and 8 deletions

14
.github/workflows/build-v3.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
name: MoviePilot Builder v3
on:
workflow_dispatch:
jobs:
select-v3:
runs-on: ubuntu-latest
steps:
# GitHub 仅从默认分支登记手动工作流;选择 v3 后会加载 v3 分支的完整构建配置。
- name: Require v3 branch
run: |
echo "::error::请在 Run workflow 中选择 v3 分支"
exit 1

View File

@@ -1274,9 +1274,17 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
cache_key, cached_value, cache_region
)
async def cache_delete(*args, **kwargs) -> None:
"""
删除当前参数对应的缓存。
"""
cache_key = __get_cache_key(args, kwargs)
await cache_backend.delete(cache_key, region=cache_region)
async_wrapper.cache_region = cache_region
async_wrapper.cache_clear = cache_clear
async_wrapper.cache_exists = cache_exists
async_wrapper.cache_delete = cache_delete
return async_wrapper
else:
# 同步函数使用同步缓存后端
@@ -1317,9 +1325,17 @@ def cached(region: Optional[str] = None, maxsize: Optional[int] = 1024, ttl: Opt
cache_key, cached_value, cache_region
)
def cache_delete(*args, **kwargs) -> None:
"""
删除当前参数对应的缓存。
"""
cache_key = __get_cache_key(args, kwargs)
cache_backend.delete(cache_key, region=cache_region)
wrapper.cache_region = cache_region
wrapper.cache_clear = cache_clear
wrapper.cache_exists = cache_exists
wrapper.cache_delete = cache_delete
return wrapper
return decorator

View File

@@ -47,7 +47,10 @@ class Alist(StorageBase, metaclass=WeakSingleton):
"""
初始化
"""
self.__generate_token.cache_clear() # noqa
conf = self.get_conf()
self.__login_token.cache_delete( # noqa
self, self.__get_base_url, conf.get("username"), conf.get("password")
)
def _delay_get_item(
self, path: Path, /, refresh: bool = False
@@ -117,22 +120,32 @@ class Alist(StorageBase, metaclass=WeakSingleton):
"""
return self.__generate_token()
@cached(maxsize=1, ttl=60 * 60 * 24 * 2 - 60 * 5, skip_empty=True)
def __generate_token(self) -> str:
"""
如果设置永久令牌则返回永久令牌,否则使用账号密码生成一个临时 token
缓存2天提前5分钟更新
"""
conf = self.get_conf()
token = conf.get("token")
if token:
return str(token)
return self.__login_token(
self.__get_base_url, conf.get("username"), conf.get("password")
)
@cached(maxsize=8, ttl=60 * 60 * 24 * 2 - 60 * 5, skip_empty=True)
def __login_token(
self, base_url: str, username: Optional[str], password: Optional[str]
) -> str:
"""
使用账号密码生成一个临时 token
缓存2天提前5分钟更新
"""
resp = RequestUtils(headers={"Content-Type": "application/json"}).post_res(
self.__get_api_url("/api/auth/login"),
UrlUtils.adapt_request_url(base_url, "/api/auth/login"),
data=json.dumps(
{
"username": conf.get("username"),
"password": conf.get("password"),
"username": username,
"password": password,
}
),
)

View File

@@ -0,0 +1,12 @@
from app.modules.filemanager.storages.alist import Alist
from app.schemas.types import StorageSchema
class AlistGo(Alist):
"""
AList相关操作
API 文档https://docs.alistgo.com/
"""
schema = StorageSchema.AlistGo

View File

@@ -190,6 +190,21 @@ class TransHandler:
return True
return False
def __is_special_extra_file(_fileitem: FileItem) -> bool:
"""
判断是否为特典/附加视频文件(如 NCOP/NCED/Menu/CM/PV/Event/Logo 等无集数编号的视频/样本)
"""
file_name = _fileitem.name or ""
return bool(
re.search(
r"(?:^|[\s_.\-\[【(])("
r"NC(?:OP|ED)|NCOP|NCED|OP|ED|MENU|PV|CM|TRAILER|TV\s*SPOT|SP|OVA|OAD|EVENT|IV|INTERVIEW|LOGO|PRODUCER\s*LOGO|BEHIND\s*THE\s*SCENES|FEATURETTE"
r")(?:\d*|[\s_.\-\]】)]|$)",
file_name,
re.IGNORECASE,
)
)
# 整理结果
result = TransferInfo()
@@ -299,6 +314,17 @@ class TransHandler:
if mediainfo.type == MediaType.TV:
# 电视剧
if in_meta.begin_episode is None:
if __is_special_extra_file(fileitem):
logger.info(f"文件 {fileitem.path} 未识别到文件集数,识别为特典/附加视频文件,跳过正片集数整理")
self.__update_result(
result=result,
success=True,
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=False,
)
return result
logger.warn(f"文件 {fileitem.path} 整理失败:未识别到文件集数")
self.__update_result(
result=result,

View File

@@ -413,6 +413,7 @@ class StorageSchema(Enum):
U115 = "u115"
Rclone = "rclone"
Alist = "alist"
AlistGo = "alistgo"
SMB = "smb"

View File

@@ -0,0 +1,94 @@
from unittest.mock import MagicMock, patch
import pytest
from app.modules.filemanager.storages import alist as alist_module
from app.modules.filemanager.storages.alist import Alist
from app.modules.filemanager.storages.alistgo import AlistGo
from app.schemas.types import StorageSchema
class _FakeResponse:
def __init__(self, payload: dict, status_code: int = 200):
self._payload = payload
self.status_code = status_code
def json(self):
return self._payload
@pytest.fixture
def clear_token_cache():
Alist._Alist__login_token.cache_clear() # noqa
yield
Alist._Alist__login_token.cache_clear() # noqa
def test_alistgo_schema_registered():
assert AlistGo.schema == StorageSchema.AlistGo
assert StorageSchema.AlistGo.value == "alistgo"
def test_alistgo_singleton_isolated_from_alist():
alist = Alist()
alistgo = AlistGo()
assert alistgo is not alist
assert isinstance(alistgo, Alist)
def test_alistgo_token_isolated_from_alist(clear_token_cache):
def _conf(storage):
return {
"url": f"http://{storage.schema.value}.test",
"username": "user",
"password": "pass",
}
responses = [
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alist"}}),
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alistgo"}}),
]
request_utils = MagicMock()
request_utils.post_res.side_effect = responses
alist = Alist()
alistgo = AlistGo()
with patch.object(Alist, "get_conf", _conf):
with patch.object(alist_module, "RequestUtils", return_value=request_utils):
assert alist._Alist__generate_token() == "token-alist" # noqa
assert alistgo._Alist__generate_token() == "token-alistgo" # noqa
assert alist._Alist__generate_token() == "token-alist" # noqa
assert alistgo._Alist__generate_token() == "token-alistgo" # noqa
assert request_utils.post_res.call_count == 2
def test_init_storage_keeps_other_storage_token(clear_token_cache):
def _conf(storage):
return {
"url": f"http://{storage.schema.value}.test",
"username": "user",
"password": "pass",
}
responses = [
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alist"}}),
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alistgo"}}),
_FakeResponse({"code": 200, "message": "success", "data": {"token": "token-alistgo-new"}}),
]
request_utils = MagicMock()
request_utils.post_res.side_effect = responses
alist = Alist()
alistgo = AlistGo()
with patch.object(Alist, "get_conf", _conf):
with patch.object(alist_module, "RequestUtils", return_value=request_utils):
assert alist._Alist__generate_token() == "token-alist" # noqa
assert alistgo._Alist__generate_token() == "token-alistgo" # noqa
alistgo.init_storage()
assert alist._Alist__generate_token() == "token-alist" # noqa
assert alistgo._Alist__generate_token() == "token-alistgo-new" # noqa
assert request_utils.post_res.call_count == 3

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.15.5'
FRONTEND_VERSION = 'v2.15.5'
APP_VERSION = 'v2.15.6'
FRONTEND_VERSION = 'v2.15.6'