mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-25 10:10:38 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a02e7de21 | ||
|
|
458c08a137 | ||
|
|
7012e0e305 | ||
|
|
91ce365f78 | ||
|
|
17be4304c1 |
14
.github/workflows/build-v3.yml
vendored
Normal file
14
.github/workflows/build-v3.yml
vendored
Normal 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
|
||||
@@ -131,6 +131,7 @@ def simplify_search_result(
|
||||
context: Context,
|
||||
index: int,
|
||||
include_description: bool = False,
|
||||
include_labels: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
精简单条搜索结果
|
||||
@@ -138,6 +139,7 @@ def simplify_search_result(
|
||||
:param context: 搜索结果上下文
|
||||
:param index: 搜索结果在原始缓存中的序号
|
||||
:param include_description: 是否返回种子简介
|
||||
:param include_labels: 是否返回种子标签
|
||||
:return: 精简后的搜索结果
|
||||
"""
|
||||
simplified = {}
|
||||
@@ -160,6 +162,8 @@ def simplify_search_result(
|
||||
}
|
||||
if include_description:
|
||||
simplified["torrent_info"]["description"] = torrent_info.description
|
||||
if include_labels:
|
||||
simplified["torrent_info"]["labels"] = torrent_info.labels or []
|
||||
|
||||
if media_info:
|
||||
simplified["media_info"] = {
|
||||
|
||||
@@ -42,6 +42,10 @@ class GetSearchResultsInput(BaseModel):
|
||||
False,
|
||||
description="Whether to include torrent descriptions in returned results",
|
||||
)
|
||||
include_labels: Optional[bool] = Field(
|
||||
False,
|
||||
description="Whether to include torrent labels in returned results",
|
||||
)
|
||||
show_filter_options: Optional[bool] = Field(
|
||||
False,
|
||||
description="Whether to return only optional filter options for re-checking available conditions",
|
||||
@@ -79,6 +83,7 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
title_pattern: Optional[str] = None,
|
||||
content_pattern: Optional[str] = None,
|
||||
include_description: bool = False,
|
||||
include_labels: bool = False,
|
||||
show_filter_options: bool = False,
|
||||
page: Optional[int] = 1,
|
||||
**kwargs,
|
||||
@@ -96,6 +101,7 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
:param title_pattern: 仅匹配种子标题的正则表达式
|
||||
:param content_pattern: 匹配种子标题、简介和标签的正则表达式
|
||||
:param include_description: 是否在结果中返回种子简介
|
||||
:param include_labels: 是否在结果中返回种子标签
|
||||
:param show_filter_options: 是否只返回可用筛选项
|
||||
:param page: 分页页码
|
||||
:param kwargs: 工具框架附加参数
|
||||
@@ -103,7 +109,7 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
"""
|
||||
page = max(1, page or 1)
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, show_filter_options={show_filter_options}, page={page}"
|
||||
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, include_labels={include_labels}, show_filter_options={show_filter_options}, page={page}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -193,6 +199,7 @@ class GetSearchResultsTool(MoviePilotTool):
|
||||
item,
|
||||
index,
|
||||
include_description=include_description,
|
||||
include_labels=include_labels,
|
||||
)
|
||||
for item, index in zip(page_items, page_indices)
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
12
app/modules/filemanager/storages/alistgo.py
Normal file
12
app/modules/filemanager/storages/alistgo.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -413,6 +413,7 @@ class StorageSchema(Enum):
|
||||
U115 = "u115"
|
||||
Rclone = "rclone"
|
||||
Alist = "alist"
|
||||
AlistGo = "alistgo"
|
||||
SMB = "smb"
|
||||
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
|
||||
|
||||
媒体相关 MCP 工具(如 `query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。
|
||||
|
||||
`get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。
|
||||
`get_search_results` 可使用 `title_pattern` 对种子标题执行正则筛选,也可使用 `content_pattern` 联合匹配种子标题、简介和标签。`title_pattern` 保持仅匹配标题的兼容语义;需要在结果中查看种子简介时,传入 `include_description=true`;需要查看种子标签时,传入 `include_labels=true`。两种正则参数与站点、分辨率等结构化筛选条件同时传入时按 AND 关系组合。
|
||||
|
||||
#### Agent 自主定时任务工具
|
||||
|
||||
|
||||
@@ -103,8 +103,8 @@ Filter values must come from the `filter_options` returned by `search_torrents`
|
||||
Fetch results with selected filters:
|
||||
`moviepilot tool run get_search_results resolution='1080p,2160p' free_state='免费,50%'`
|
||||
|
||||
To filter subtitle, audio, DIY, translation, or release notes that may appear outside the title, use `content_pattern`. It matches the torrent title, description, and labels while `title_pattern` continues to match the title only. Set `include_description=true` when the description is needed to explain why a result matched:
|
||||
`moviepilot tool run get_search_results content_pattern='特效字幕|国语|DIY' include_description=true`
|
||||
To filter subtitle, audio, DIY, translation, or release notes that may appear outside the title, use `content_pattern`. It matches the torrent title, description, and labels while `title_pattern` continues to match the title only. Set `include_description=true` when the description is needed to explain why a result matched, and `include_labels=true` when the labels should be returned:
|
||||
`moviepilot tool run get_search_results content_pattern='特效字幕|国语|DIY' include_description=true include_labels=true`
|
||||
|
||||
If empty, tell the user which filter to relax and ask before retrying.
|
||||
|
||||
|
||||
@@ -54,6 +54,17 @@ def test_simplify_search_result_only_includes_description_when_requested():
|
||||
assert detailed_result["torrent_info"]["description"] == "简繁特效字幕"
|
||||
|
||||
|
||||
def test_simplify_search_result_only_includes_labels_when_requested():
|
||||
"""精简结果应按参数控制标签输出,避免默认增加上下文长度。"""
|
||||
context = _build_context("Movie.2026.1080p", labels=["官译", "特效"])
|
||||
|
||||
default_result = simplify_search_result(context, 1)
|
||||
detailed_result = simplify_search_result(context, 1, include_labels=True)
|
||||
|
||||
assert "labels" not in default_result["torrent_info"]
|
||||
assert detailed_result["torrent_info"]["labels"] == ["官译", "特效"]
|
||||
|
||||
|
||||
def test_content_pattern_matches_title_description_and_labels():
|
||||
"""内容正则应联合匹配标题、简介和标签,并可返回命中的简介。"""
|
||||
items = [
|
||||
|
||||
94
tests/test_alistgo_storage.py
Normal file
94
tests/test_alistgo_storage.py
Normal 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
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.15.5'
|
||||
FRONTEND_VERSION = 'v2.15.5'
|
||||
APP_VERSION = 'v2.15.6'
|
||||
FRONTEND_VERSION = 'v2.15.6'
|
||||
|
||||
Reference in New Issue
Block a user